diff --git a/docs/metrics/DESIGN.md b/docs/metrics/DESIGN.md index e8f7b044c..042ec3b1b 100644 --- a/docs/metrics/DESIGN.md +++ b/docs/metrics/DESIGN.md @@ -94,6 +94,7 @@ single object that `display()` and `to_json()` render. class Report(msgspec.Struct, frozen=True): version: str git_sha: str | None + git_sha_source: str # which channel resolved git_sha: git | env | none test_started_at: int n_samples_issued: int n_samples_completed: int diff --git a/docs/metrics/report_design.md b/docs/metrics/report_design.md index 7819ba77e..c9a38d899 100644 --- a/docs/metrics/report_design.md +++ b/docs/metrics/report_design.md @@ -74,7 +74,7 @@ returns `{}` (or an all-null early-stopping map if the feature is enabled). ### `Report` (frozen `msgspec.Struct`) -Fields: `version`, `git_sha`, `test_started_at`, +Fields: `version`, `git_sha`, `git_sha_source`, `test_started_at`, `n_samples_issued/completed/failed`, `duration_ns`, `state`, `complete`, the five rollup dicts (`ttft`, `tpot`, `latency`, `input_sequence_lengths`, `output_sequence_lengths`), `legacy_loadgen_window_duration_ns`, `qps`, `tps`, diff --git a/docs/utils/DESIGN.md b/docs/utils/DESIGN.md index ea2d357be..c7de50ae6 100644 --- a/docs/utils/DESIGN.md +++ b/docs/utils/DESIGN.md @@ -39,9 +39,11 @@ benchmarking tool that lives here for convenience but does import from other `logging.py` and `dataset_utils.py` stay lightweight and broadly reusable. `version.py` is also small, but it intentionally imports -`inference_endpoint.__version__` and shells out to `git` to report build -metadata. `benchmark_httpclient.py` is exempt entirely: it is a standalone tool, -not a reusable helper. +`inference_endpoint.__version__` and resolves the source SHA to report build +metadata: a live `git` query of its own checkout, or, only outside a git +checkout, the `ENDPOINTS_GIT_SHA` env var. It is resolved once per process. +`benchmark_httpclient.py` is exempt entirely: it is a standalone tool, not a +reusable helper. **`benchmark_httpclient.py` is a standalone tool** @@ -58,7 +60,9 @@ uv run python -m inference_endpoint.utils.benchmark_httpclient --endpoint URL -- ## Integration Points -| Consumer | Usage | -| ------------------ | -------------------------------------------- | -| `main.py` | Calls `setup_logging()` at startup | -| `commands/info.py` | Imports `__version__` for the `info` command | +| Consumer | Usage | +| ------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `main.py` | Calls `setup_logging()` at startup | +| `commands/info.py` | Imports `__version__` for the `info` command | +| `commands/benchmark/execute.py` | `setup_benchmark` calls `get_version_info()` first, pinning provenance before the run | +| `metrics/report.py` | `Report.from_snapshot` calls `get_version_info()` to record `git_sha` / `git_sha_source` in the report | diff --git a/scripts/Dockerfile.dev b/scripts/Dockerfile.dev index 32db12721..d3f168c31 100644 --- a/scripts/Dockerfile.dev +++ b/scripts/Dockerfile.dev @@ -1,6 +1,6 @@ # Development Dockerfile for the MLPerf Inference Endpoint Benchmarking System -# From project root: -# docker build -f scripts/Dockerfile.dev --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t inference-endpoint-dev . +# From project root (GIT_SHA is the source commit the report prints; see the end of this file): +# docker build -f scripts/Dockerfile.dev --build-arg GIT_SHA=$(git rev-parse --short=7 HEAD)$(git diff --quiet HEAD -- src/inference_endpoint pyproject.toml uv.lock examples/09_Wan22_VideoGen_Example/accuracy || echo -dirty) --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t inference-endpoint-dev . # docker run -v $(pwd):/mnt/inference-endpoint -it --shm-size=512m inference-endpoint-dev bash # # VBench (WAN 2.2) accuracy scorer is ON by default (consistent with the DeepSeek-R1 @@ -130,8 +130,9 @@ RUN if [ "${PROVISION_ROUGE}" = "1" ]; then \ fi # OCI image metadata so `docker inspect` self-identifies this image (the endpoints benchmark -# client, distinct from the lcb-service evaluator image). Declared last so a wording change -# only rebuilds this tiny config layer, not the apt/uv/DSR1/VBench stages above. +# client, distinct from the lcb-service evaluator image). Declared after the build stages +# so a wording change only rebuilds the trailing config layers, not the +# apt/uv/DSR1/VBench stages above. # push_docker_image.sh additionally stamps dynamic source/revision/version/description # annotations on the pushed manifest. The description is deliberately capability-neutral: # PROVISION_DSR1 / PROVISION_VBENCH can be 0, so it names the evaluators without asserting @@ -139,3 +140,14 @@ RUN if [ "${PROVISION_ROUGE}" = "1" ]; then \ LABEL org.opencontainers.image.title="inference-endpoint" \ org.opencontainers.image.description="MLPerf inference endpoint benchmarking client (the inference-endpoint CLI); DeepSeek-R1 and VBench accuracy evaluators are provisioned by default, toggled by PROVISION_DSR1 / PROVISION_VBENCH." \ org.opencontainers.image.source="https://github.com/mlcommons/endpoints" + +# Runtime provenance. This image COPYs src/ without .git, so the report prints +# this value (source: env). A git checkout bind-mounted over the workdir (the +# `docker run -v` above) reports live git instead, or no SHA if git can't read +# it: this value describes the image, not the mounted code. Mount the whole +# checkout: code mounted without .git (a tarball, or src/ alone) is reported +# with this value. Declared last so a new SHA doesn't invalidate the cached +# layers above; empty or non-hex values are ignored. push_docker_image.sh passes +# it and tags the image with the same SHA. +ARG GIT_SHA= +ENV ENDPOINTS_GIT_SHA=${GIT_SHA} diff --git a/scripts/push_docker_image.sh b/scripts/push_docker_image.sh index 9a920e407..13bfd487b 100755 --- a/scripts/push_docker_image.sh +++ b/scripts/push_docker_image.sh @@ -40,6 +40,9 @@ # PROVISION_VBENCH passthrough build-arg (default: Dockerfile default = 1) # IMAGE_DESCRIPTION GHCR package-page description (default: "endpoints client image (ref , commit )") # GHCR_USER/GHCR_TOKEN optional registry login (fallback: GITHUB_ACTOR/GITHUB_TOKEN) +# +# Always passes --build-arg GIT_SHA=[-dirty], which the image reports +# at runtime as its source commit. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -153,7 +156,7 @@ else ENDPOINTS_REF="$ORIGINAL_REF" fi -SHORT_SHA="$(git rev-parse --short HEAD)" +SHORT_SHA="$(git rev-parse --short=7 HEAD)" # Docker tags allow only [A-Za-z0-9_.-], must not start with '.' or '-', and cap at # 128 chars. Map every other char (e.g. '/', '+', '~', '#') to '-' so a legitimate # git ref name can't yield an invalid tag that fails `-t` only after the whole build. @@ -213,6 +216,14 @@ BUILD_ARGS=() [[ "$NO_CACHE" == "1" ]] && BUILD_ARGS+=(--no-cache) [[ -n "${PROVISION_DSR1:-}" ]] && BUILD_ARGS+=(--build-arg "PROVISION_DSR1=${PROVISION_DSR1}") [[ -n "${PROVISION_VBENCH:-}" ]] && BUILD_ARGS+=(--build-arg "PROVISION_VBENCH=${PROVISION_VBENCH}") +# Source SHA the image reports at runtime (Dockerfile ARG GIT_SHA -> ENV +# ENDPOINTS_GIT_SHA): the :$SHORT_SHA tag, formatted like live git's +# (--short=7). The -dirty check uses the runtime check's scope +# (utils/version._DIRTY_PATHSPEC); it is reachable only via --allow-dirty / +# --no-push, since a clean :SHA is otherwise required above. +GIT_SHA_VALUE="$SHORT_SHA" +git diff --quiet HEAD -- src/inference_endpoint pyproject.toml uv.lock examples/09_Wan22_VideoGen_Example/accuracy || GIT_SHA_VALUE="${GIT_SHA_VALUE}-dirty" +BUILD_ARGS+=(--build-arg "GIT_SHA=${GIT_SHA_VALUE}") # Don't attach SLSA provenance attestations. buildx adds them by default on --push, # which surfaces a spurious `unknown/unknown` entry on the GHCR package page and diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index ff7f95ade..361c79f6e 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -108,6 +108,7 @@ SessionResult, ) from inference_endpoint.metrics.report import Report +from inference_endpoint.utils.version import get_version_info if TYPE_CHECKING: from inference_endpoint.async_utils.event_publisher import EventPublisherService @@ -471,6 +472,15 @@ def setup_benchmark( ``audit_run_spec``, when set, overrides the issue count and sample order for a compliance-audit phase (see ``commands/audit.py:run_audit``). """ + # Pins source provenance for the process before any work runs; every report + # (main run and audit phases) records this value. + version_info = get_version_info() + logger.info( + "Endpoints %s, git SHA %s (source: %s)", + version_info["version"], + version_info["git_sha"] or "unknown", + version_info["git_sha_source"], + ) # Accuracy-only runs force single-stream (1 worker / 1 connection) for # deterministic sample ordering. Bake it into the config here — before CPU # affinity, report_dir/config.yaml persistence, and RuntimeSettings — so the diff --git a/src/inference_endpoint/metrics/report.py b/src/inference_endpoint/metrics/report.py index 98834de34..6f4261850 100644 --- a/src/inference_endpoint/metrics/report.py +++ b/src/inference_endpoint/metrics/report.py @@ -211,6 +211,10 @@ class Report(msgspec.Struct, frozen=True): # type: ignore[call-arg] version: str git_sha: str | None + # Which channel resolved git_sha: "git" (read from the running checkout), + # "env" (ENDPOINTS_GIT_SHA, an attestation by the image or launcher), or + # "none". See utils/version.resolve_git_sha. + git_sha_source: str test_started_at: int n_samples_issued: int # Terminal responses; failed samples are a subset of this count. @@ -429,6 +433,7 @@ def _series_dict(key: str) -> dict[str, Any]: return cls( version=str(version_info.get("version", "unknown")), git_sha=version_info.get("git_sha"), + git_sha_source=str(version_info.get("git_sha_source", "none")), test_started_at=0, # TODO: surface session_started_ns via snapshot n_samples_issued=n_issued, n_samples_completed=n_completed, @@ -528,8 +533,10 @@ def display( f"final snapshot received) — some async metrics may be missing.{newline}" ) fn(f"Version: {self.version}{newline}") - if self.git_sha: - fn(f"Git SHA: {self.git_sha}{newline}") + # Always emitted, so an unresolved SHA reads "unknown (source: none)". + fn( + f"Git SHA: {self.git_sha or 'unknown'} (source: {self.git_sha_source}){newline}" + ) if self.run_config: fn(f"Run config:{newline}") for section, params in self.run_config.items(): diff --git a/src/inference_endpoint/utils/version.py b/src/inference_endpoint/utils/version.py index 4ef2e52de..d9daa5f3c 100644 --- a/src/inference_endpoint/utils/version.py +++ b/src/inference_endpoint/utils/version.py @@ -15,31 +15,145 @@ """Version and git information utilities.""" +import functools +import logging +import os +import re import subprocess -from functools import lru_cache from pathlib import Path from .. import __version__ +logger = logging.getLogger(__name__) -def _resolve_repo_root() -> Path: +# Attestation for code running outside a git checkout, e.g. a container image +# built with `--build-arg GIT_SHA=...` (scripts/Dockerfile.dev). +_GIT_SHA_ENV_VAR = "ENDPOINTS_GIT_SHA" + +# A git hex object name (SHA-1 short/full or SHA-256, any case) with an optional +# -dirty marker. Every channel is validated so a sentinel ("unknown") or an +# injected newline/ANSI sequence can't pose as a SHA or corrupt report.txt. +_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}(?:-dirty)?") + +# Per-probe budget. Provenance is resolved once per process before the run +# starts, so slow filesystems (NFS/HPC homes) can afford it. +_GIT_TIMEOUT_S = 5.0 + +# What "dirty" covers: code a run executes and its dependency pins, i.e. the +# package plus the VBench scorer subproject it launches from examples/. Edits +# elsewhere (other examples, docs, LFS datasets) don't change what runs. +# scripts/push_docker_image.sh and scripts/Dockerfile.dev repeat this list. +_DIRTY_PATHSPEC = ( + "src/inference_endpoint", + "pyproject.toml", + "uv.lock", + "examples/09_Wan22_VideoGen_Example/accuracy", +) + + +def _valid_sha(value: object) -> str | None: + """Return ``value`` stripped if it is a well-formed (optionally dirty) SHA.""" + if not isinstance(value, str): + return None + value = value.strip() + return value if _SHA_RE.fullmatch(value) else None + + +def _resolve_repo_root() -> Path | None: """Repo root of this source checkout, anchored to the module location. - ``src/inference_endpoint/utils/version.py`` -> ``parents[3]`` is the repo root. - Falls back to the filesystem root when the package lives in a shallower tree - (e.g. an installed wheel copied to ``/inference_endpoint``) so import never - crashes; get_git_sha's toplevel guard then yields None, not a foreign SHA. + Only the source layout ``/src/inference_endpoint/utils/version.py`` has + one. Any other location (an installed wheel, a copy vendored inside another + repo) yields None, so get_git_sha never asks git about a foreign repo. """ parents = Path(__file__).resolve().parents - return parents[3] if len(parents) > 3 else parents[-1] + if len(parents) < 4 or [p.name for p in parents[2::-1]] != [ + "src", + "inference_endpoint", + "utils", + ]: + return None + return parents[3] _REPO_ROOT = _resolve_repo_root() -@lru_cache(maxsize=1) +# Git's repo-location env family. A stray value (a CI wrapper, ``git submodule +# foreach``) can point git at a foreign repo/index while ``--show-toplevel`` still +# reports our cwd, slipping a foreign SHA past the toplevel guard or a foreign +# index past the dirty probe. Scrubbing it anchors every probe to its ``cwd``. +_GIT_LOCATION_ENV_VARS = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +) + + +def _git_env() -> dict[str, str]: + """Environment for read-only git probes. + + Drops the repo-location family and sets ``GIT_OPTIONAL_LOCKS=0`` so + ``git status`` never rewrites the caller's index. A git too old to know the + variable ignores it, where it would reject ``--no-optional-locks``. + """ + env = dict(os.environ) + for var in _GIT_LOCATION_ENV_VARS: + env.pop(var, None) + env["GIT_OPTIONAL_LOCKS"] = "0" + return env + + +def _git_tree_dirty(repo_root: Path) -> bool: + """True iff ``_DIRTY_PATHSPEC`` has uncommitted tracked changes. + + Untracked files don't count. ``core.checkStat=minimal`` compares only mtime + and size, so a copied checkout, or a bind mount presenting new inode numbers, + isn't re-hashed: re-hashing a git-lfs file where git-lfs isn't installed would + always report it modified. + + Fails to dirty: a git error (e.g. a dubious-ownership refusal), a timeout or + an OSError reports dirty, with a warning, so an unverifiable tree is never + blessed as clean. + """ + try: + # Bytes, not text: with core.quotePath=false a non-UTF-8 path would + # otherwise raise UnicodeDecodeError. + result = subprocess.run( + [ + "git", + "-c", + "core.checkStat=minimal", + "status", + "--porcelain", + "--untracked-files=no", + "--", + *_DIRTY_PATHSPEC, + ], + capture_output=True, + timeout=_GIT_TIMEOUT_S, + check=False, + cwd=repo_root, + env=_git_env(), + ) + except (OSError, subprocess.TimeoutExpired) as e: + reason = str(e) + else: + if result.returncode == 0: + return bool(result.stdout.strip()) + stderr = result.stderr.decode(errors="replace").strip() + reason = f"git status exited {result.returncode}: {stderr}" + logger.warning( + "Could not verify %s is clean (%s); reporting -dirty", repo_root, reason + ) + return True + + def get_git_sha() -> str | None: - """Get the git commit SHA of the endpoints source checkout. + """Get the git commit SHA of the endpoints source checkout via live git. The query is anchored to this package's own location (``_REPO_ROOT``) rather than the process working directory, so the SHA reflects the endpoints repo @@ -47,42 +161,105 @@ def get_git_sha() -> str | None: Returns: The short git SHA (at least 7 chars; git lengthens it if a 7-char - prefix is ambiguous), or None if the package is not in a git checkout - (e.g. an installed wheel) or git is unavailable. + prefix is ambiguous), suffixed with ``-dirty`` when ``_DIRTY_PATHSPEC`` has + uncommitted tracked changes; or None if the package is not in a source + checkout (e.g. an installed wheel) or git is unavailable. """ + repo_root = _REPO_ROOT + if repo_root is None: + return None try: + # Bytes, not text: --show-toplevel prints the path raw, and a directory + # name need not be UTF-8. result = subprocess.run( ["git", "rev-parse", "--show-toplevel", "--short=7", "HEAD"], capture_output=True, - text=True, - timeout=1.0, + timeout=_GIT_TIMEOUT_S, check=False, - cwd=_REPO_ROOT, + cwd=repo_root, + env=_git_env(), ) if result.returncode != 0: return None lines = result.stdout.splitlines() if len(lines) != 2: return None - toplevel, sha = lines - # Only trust the SHA if the discovered repo IS this source tree. Guards against - # an installed wheel nested inside an unrelated repo reporting a foreign SHA — + toplevel = os.fsdecode(lines[0]) + sha = lines[1].decode("ascii", "replace").strip() + # Only trust the SHA if the discovered repo IS this source tree (e.g. not + # an enclosing repo whose subdirectory merely holds a checkout's src/) — # a wrong provenance SHA is worse than None. - if Path(toplevel).resolve() != _REPO_ROOT: + if Path(toplevel).resolve() != repo_root: return None - return sha.strip() + return f"{sha}-dirty" if _git_tree_dirty(repo_root) else sha except (OSError, subprocess.TimeoutExpired): return None -@lru_cache(maxsize=1) +def _in_git_checkout() -> bool: + # lexists: a .git symlink whose target isn't visible (outside a bind mount) + # still marks a checkout. + return _REPO_ROOT is not None and os.path.lexists(_REPO_ROOT / ".git") + + +def resolve_git_sha() -> tuple[str | None, str]: + """Resolve the source SHA and record which channel provided it. + + 1. ``"git"`` — a live query of this package's own source checkout, + ``-dirty`` included. + 2. ``"env"`` — ``ENDPOINTS_GIT_SHA``, honored only when the package is not in + a git checkout (an installed package, or an image's copy of ``src/``). + A checkout git can't read (git missing, a timeout, a dubious-ownership + refusal, a ``.git`` pointing outside a bind mount) reports nothing + instead: the variable describes whichever image or launcher set it, not + this tree. Code mounted without its ``.git`` (a tarball, or ``src/`` + alone) can't be told apart from the image's copy and gets the variable. + 3. ``"none"`` — nothing resolved. + + Every value is validated, so a wrapping git on PATH can't carry ANSI or + newlines into the report. + + Returns: + ``(sha, source)`` where ``source`` is one of the labels above. ``sha`` is + None only when ``source == "none"``. + """ + live_sha = _valid_sha(get_git_sha()) + if live_sha: + return live_sha, "git" + if _in_git_checkout(): + logger.warning( + "Not reporting a git SHA: %s is a git checkout but git could not " + "read it (e.g. git missing, a timeout, dubious ownership, or a .git " + "pointing outside a mount)", + _REPO_ROOT, + ) + return None, "none" + env_sha = _valid_sha(os.environ.get(_GIT_SHA_ENV_VAR)) + if env_sha: + return env_sha, "env" + return None, "none" + + +@functools.cache +def _process_git_sha() -> tuple[str | None, str]: + return resolve_git_sha() + + def get_version_info() -> dict[str, str | None]: - """Get version and git information. + """Get version and git provenance information. + + The SHA is resolved once per process, on the first call (``setup_benchmark`` + makes it before the run starts), so every report the process writes + describes the checkout as it was at setup rather than whenever a report is + built. Subprocesses started later (e.g. audit-phase services) import + whatever the checkout holds then. Returns: - Dictionary with 'version' and 'git_sha' keys. + Dictionary with 'version', 'git_sha', and 'git_sha_source' keys. """ + git_sha, git_sha_source = _process_git_sha() return { "version": __version__, - "git_sha": get_git_sha(), + "git_sha": git_sha, + "git_sha_source": git_sha_source, } diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 9b4729a32..27343f4cd 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -3162,6 +3162,28 @@ def test_perf_run_leaves_target_concurrency_untouched( assert ctx.config.settings.client.num_workers == 4 assert ctx.config.settings.load_pattern.target_concurrency == 10 + @pytest.mark.unit + def test_setup_resolves_source_provenance_before_the_run( + self, tmp_path, _base_patches, _simple_dataset, _rt_settings, caplog + ): + """The report's SHA is sampled at setup, not after a long run.""" + config = OfflineConfig(**_OFFLINE_KWARGS, report_dir=str(tmp_path)) + resolve = MagicMock( + return_value={ + "version": "0.1.0", + "git_sha": "abc1234-dirty", + "git_sha_source": "git", + } + ) + with ( + patch.object(execute_mod, "get_version_info", resolve), + caplog.at_level(logging.INFO, logger=execute_mod.__name__), + ): + self._setup(config, TestMode.PERF, (_simple_dataset, []), _rt_settings) + + resolve.assert_called_once_with() + assert "abc1234-dirty (source: git)" in caplog.text + @pytest.mark.unit def test_accuracy_dataset_named_performance_is_counted_by_type( self, tmp_path, _base_patches, _simple_dataset, _rt_settings diff --git a/tests/unit/metrics/test_report_builder.py b/tests/unit/metrics/test_report_builder.py index b284f983d..7db277497 100644 --- a/tests/unit/metrics/test_report_builder.py +++ b/tests/unit/metrics/test_report_builder.py @@ -225,6 +225,24 @@ def test_empty_registry(self): assert report.output_sequence_lengths == {} assert report.tpot == {} + def test_git_provenance_comes_from_version_info(self, monkeypatch): + monkeypatch.setattr( + "inference_endpoint.metrics.report.get_version_info", + lambda: {"version": "v", "git_sha": "abc1234", "git_sha_source": "env"}, + ) + report = _build_report(_make_registry(n_samples=5)) + assert (report.git_sha, report.git_sha_source) == ("abc1234", "env") + data = json.loads(report.to_json()) + assert (data["git_sha"], data["git_sha_source"]) == ("abc1234", "env") + + def test_git_sha_source_defaults_to_none_when_absent(self, monkeypatch): + monkeypatch.setattr( + "inference_endpoint.metrics.report.get_version_info", + lambda: {"version": "v", "git_sha": None}, + ) + report = _build_report(_make_registry(n_samples=5)) + assert report.git_sha_source == "none" + def test_isl_series_absent(self): # settings.metrics_isl=false: the aggregator never registers the # series, so the snapshot lacks it entirely (not merely count==0). @@ -549,6 +567,7 @@ def test_display_no_started_at(self): report = Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=0, n_samples_completed=0, @@ -567,11 +586,32 @@ def test_display_no_started_at(self): output = "\n".join(lines) assert "Test started at" not in output + @pytest.mark.parametrize( + ("git_sha", "source", "line"), + [ + (None, "none", "Git SHA: unknown (source: none)"), + ("abc1234-dirty", "git", "Git SHA: abc1234-dirty (source: git)"), + ], + ids=["unresolved", "resolved"], + ) + def test_display_always_prints_git_sha_line_with_source( + self, git_sha, source, line + ): + report = msgspec.structs.replace( + _build_report(_make_registry(n_samples=5)), + git_sha=git_sha, + git_sha_source=source, + ) + lines: list[str] = [] + report.display(fn=lines.append, summary_only=True) + assert line in lines + def test_display_warns_when_incomplete(self): """Reports with ``complete=False`` surface a WARNING in display().""" report = Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=10, n_samples_completed=10, @@ -594,6 +634,7 @@ def _report_with_full_run_osl(self, fr, *, osl_windowed=None, complete=True): return Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=10, n_samples_completed=10, @@ -690,6 +731,7 @@ def test_display_warns_when_interrupted(self): report = Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=10, n_samples_completed=5, diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 56a43fdab..680a4646f 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -15,6 +15,7 @@ """Tests for version utilities.""" +import os import shutil import subprocess from pathlib import Path @@ -22,156 +23,617 @@ import pytest from inference_endpoint import __version__ from inference_endpoint.utils import version as version_mod -from inference_endpoint.utils.version import _REPO_ROOT, get_git_sha, get_version_info + +# Fixed mtime for files committed by _commit_tree. Keeping it well before the +# index write means no entry is "racily clean", so git trusts the index stat data. +_PAST_MTIME = 1_000_000_000 + + +@pytest.fixture(autouse=True) +def _fresh_process_provenance(): + version_mod._process_git_sha.cache_clear() + yield + version_mod._process_git_sha.cache_clear() + + +def _hermetic_git_env() -> dict[str, str]: + """Env for git calls that build throwaway repos. + + Drops every ambient ``GIT_*`` variable (a stray ``GIT_DIR`` would redirect + commits into a foreign repo) and the user's global/system config (commit + signing, hooks, templates), and supplies an identity. + """ + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env.update( + GIT_CONFIG_GLOBAL=os.devnull, + GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="t", + GIT_AUTHOR_EMAIL="t@t.co", + GIT_COMMITTER_NAME="t", + GIT_COMMITTER_EMAIL="t@t.co", + ) + return env def _git(*args: str, cwd: Path) -> str: return subprocess.run( ["git", *args], cwd=cwd, + env=_hermetic_git_env(), capture_output=True, text=True, check=True, ).stdout.strip() +def _commit_tree(repo: Path, files: dict[str, str], *add_flags: str) -> Path: + """Create a repo at ``repo`` holding ``files`` in one commit.""" + if shutil.which("git") is None: + pytest.skip("git not available") + repo.mkdir(parents=True) + _git("init", "-q", cwd=repo) + for rel, content in files.items(): + path = repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + os.utime(path, (_PAST_MTIME, _PAST_MTIME)) + _git(*add_flags, "add", "-A", cwd=repo) + _git("commit", "-q", "-m", "init", cwd=repo) + return repo + + +def _real_checkout_sha() -> str: + """Short SHA of the checkout this package runs from; skip if there is none. + + Reads the checkout the way get_git_sha does: ambient config (so CI's + safe.directory applies) without a stray GIT_DIR. + """ + root = version_mod._REPO_ROOT + if shutil.which("git") is None or root is None: + pytest.skip("not running from a git source checkout") + + def rev_parse(*args: str) -> str: + return subprocess.run( + ["git", "rev-parse", *args], + cwd=root, + env=version_mod._git_env(), + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + try: + toplevel = rev_parse("--show-toplevel") + sha = rev_parse("--short=7", "HEAD") + except subprocess.CalledProcessError: + pytest.skip("source tree is not a git checkout") + if Path(toplevel).resolve() != root: + pytest.skip("source tree is nested inside another repo") + return sha + + +def _fake_git(rev_parse=None, status=b""): + """subprocess.run stand-in dispatching on the git subcommand. + + Each outcome is stdout bytes, a CompletedProcess, or an exception to raise; + ``rev_parse`` defaults to this checkout's toplevel plus ``abc1234``. Output is + decoded whenever the caller's arguments would make the real call decode it, + with the same encoding and error handler, so a probe switched to text mode + fails here too. + """ + if rev_parse is None: + rev_parse = os.fsencode(version_mod._REPO_ROOT) + b"\nabc1234\n" + + def run(cmd, *args, **kwargs): + if "rev-parse" in cmd: + outcome = rev_parse + elif "status" in cmd: + outcome = status + else: + raise AssertionError(f"unexpected git invocation: {cmd}") + if isinstance(outcome, BaseException): + raise outcome + if isinstance(outcome, bytes): + outcome = subprocess.CompletedProcess(cmd, 0, stdout=outcome) + text_keys = ("text", "universal_newlines", "encoding", "errors") + if any(kwargs.get(key) for key in text_keys): + stdout = outcome.stdout.decode( + kwargs.get("encoding") or "utf-8", kwargs.get("errors") or "strict" + ) + outcome = subprocess.CompletedProcess( + outcome.args, outcome.returncode, stdout=stdout, stderr=outcome.stderr + ) + return outcome + + return run + + +# --------------------------------------------------------------------------- +# _valid_sha +# --------------------------------------------------------------------------- + + @pytest.mark.unit -def test_get_git_sha(): - """Test that get_git_sha returns a string or None.""" - sha = get_git_sha() - if sha is not None: - assert isinstance(sha, str) - # --short=7 is a minimum width; git lengthens it on prefix collision. - assert 7 <= len(sha) <= 40 - assert sha.isalnum() # Should only contain alphanumeric chars +@pytest.mark.parametrize( + "value", + [ + "abc1234", # 7-char short SHA-1 + "abc1234-dirty", # with dirty marker + "a" * 40, # full SHA-1 + "A1B2C3D", # uppercase accepted + "f" * 64, # full SHA-256 + " abc1234 ", # surrounding whitespace stripped + ], +) +def test_valid_sha_accepts(value): + assert version_mod._valid_sha(value) == value.strip() @pytest.mark.unit -def test_get_version_info(): - """Test that get_version_info returns correct structure.""" - info = get_version_info() - assert isinstance(info, dict) - assert "version" in info - assert "git_sha" in info - assert info["version"] == __version__ - # git_sha can be None if not in a git repo - if info["git_sha"] is not None: - assert isinstance(info["git_sha"], str) - assert 7 <= len(info["git_sha"]) <= 40 +@pytest.mark.parametrize( + "value", + [ + None, + 1234567, # non-string + "", + " ", + "unknown", # the display sentinel + "not-a-sha", + "abc123", # too short (6) + "a" * 65, # too long (>64) + "abc1234\ndef5678", # embedded newline (injection) — fullmatch anchors + "abc1234-dirty-dirty", # trailing junk + "abc1234\x1b[31m", # ANSI escape + ], +) +def test_valid_sha_rejects(value): + assert version_mod._valid_sha(value) is None + + +# --------------------------------------------------------------------------- +# get_git_sha (live-git channel) +# --------------------------------------------------------------------------- @pytest.mark.unit -def test_version_info_cached(): - """Test that get_version_info is properly cached.""" - info1 = get_version_info() - info2 = get_version_info() - # Should return the same object due to lru_cache - assert info1 is info2 +def test_get_git_sha_reports_a_valid_sha_or_none(): + sha = version_mod.get_git_sha() + assert sha is None or version_mod._valid_sha(sha) == sha @pytest.mark.unit -def test_git_sha_is_endpoints_repo_not_cwd(tmp_path, monkeypatch): - """get_git_sha reports the endpoints repo SHA, not the launch dir's repo. +def test_get_git_sha_returns_bare_sha_for_clean_tree(monkeypatch): + monkeypatch.setattr(version_mod.subprocess, "run", _fake_git(status=b"")) + assert version_mod.get_git_sha() == "abc1234" - Without anchoring to the package location, running the CLI from an unrelated - git repo would record that repo's SHA into run provenance. - """ - if shutil.which("git") is None: - pytest.skip("git not available") - # The endpoints repo SHA, resolved independently of the process CWD. - try: - expected = _git("rev-parse", "--short=7", "HEAD", cwd=_REPO_ROOT) - except subprocess.CalledProcessError: - pytest.skip("Source tree is not a git repository") +@pytest.mark.unit +def test_get_git_sha_appends_dirty_when_tree_has_tracked_changes(monkeypatch): + monkeypatch.setattr( + version_mod.subprocess, "run", _fake_git(status=b" M src/x.py\n") + ) + assert version_mod.get_git_sha() == "abc1234-dirty" - # A distinct, unrelated git repo the CLI is pretend-launched from. - other = tmp_path / "other_repo" - other.mkdir() - _git("init", cwd=other) - _git( - "-c", - "user.email=t@t.co", - "-c", - "user.name=t", - "commit", - "--allow-empty", - "-m", - "unrelated", - cwd=other, + +@pytest.mark.unit +@pytest.mark.parametrize( + "status", + [ + subprocess.CompletedProcess( + [], 128, stdout=b"", stderr=b"fatal: index file corrupt" + ), + subprocess.TimeoutExpired(cmd="git", timeout=5.0), + OSError("exec failed"), + ], + ids=["error-returncode", "timeout", "oserror"], +) +def test_dirty_probe_failure_reports_dirty_and_warns(monkeypatch, caplog, status): + """An unverifiable tree is flagged -dirty, and the log says it wasn't verified.""" + monkeypatch.setattr(version_mod.subprocess, "run", _fake_git(status=status)) + with caplog.at_level("WARNING", logger=version_mod.__name__): + assert version_mod.get_git_sha() == "abc1234-dirty" + assert "Could not verify" in caplog.text + + +@pytest.mark.unit +def test_git_probes_use_cold_path_timeout(monkeypatch): + timeouts = [] + fake = _fake_git() + + def run(cmd, *args, **kwargs): + timeouts.append(kwargs["timeout"]) + return fake(cmd, *args, **kwargs) + + monkeypatch.setattr(version_mod.subprocess, "run", run) + version_mod.get_git_sha() + assert timeouts == [5.0, 5.0] + + +@pytest.mark.unit +def test_dirty_probe_reads_non_utf8_status_output(tmp_path, monkeypatch): + """A non-UTF-8 path in status output (core.quotePath=false) reads as dirty.""" + monkeypatch.setattr( + version_mod.subprocess, "run", _fake_git(status=b" M caf\xe9.txt\n") ) - other_sha = _git("rev-parse", "--short=7", "HEAD", cwd=other) - assert other_sha != expected # sanity: the two repos differ + assert version_mod._git_tree_dirty(tmp_path) is True - monkeypatch.chdir(other) - get_git_sha.cache_clear() - try: - sha = get_git_sha() - finally: - # Don't leak the cache-cleared value into other tests' assumptions. - get_git_sha.cache_clear() - assert sha == expected - assert sha != other_sha +@pytest.mark.unit +def test_get_git_sha_reads_non_utf8_checkout_path(monkeypatch): + """A checkout under a non-UTF-8 directory name still resolves (Linux allows it).""" + raw_root = b"/nonexistent/caf\xe9/endpoints" + monkeypatch.setattr( + version_mod, "_REPO_ROOT", Path(os.fsdecode(raw_root)).resolve() + ) + monkeypatch.setattr( + version_mod.subprocess, + "run", + _fake_git(rev_parse=raw_root + b"\nabc1234\n", status=b""), + ) + assert version_mod.get_git_sha() == "abc1234" + + +@pytest.mark.unit +def test_non_ascii_sha_line_is_read_and_then_rejected(monkeypatch): + """A wrapping git printing garbage for the SHA can't crash resolution.""" + root = os.fsencode(version_mod._REPO_ROOT) + monkeypatch.setattr( + version_mod.subprocess, + "run", + _fake_git(rev_parse=root + b"\n\xff\xfe\n", status=b""), + ) + sha = version_mod.get_git_sha() + assert sha is not None + assert version_mod._valid_sha(sha) is None + + +@pytest.mark.unit +def test_get_git_sha_none_outside_source_layout(monkeypatch): + """Without a source-checkout root, git is never consulted.""" + + def fail_run(*args, **kwargs): + raise AssertionError("git must not run outside a source checkout") + + monkeypatch.setattr(version_mod, "_REPO_ROOT", None) + monkeypatch.setattr(version_mod.subprocess, "run", fail_run) + assert version_mod.get_git_sha() is None @pytest.mark.unit @pytest.mark.parametrize( - "fake", + "rev_parse", [ - # git missing / cwd gone / timed out FileNotFoundError(), - subprocess.TimeoutExpired(cmd="git", timeout=1.0), - # not a git repo (non-zero exit) - subprocess.CompletedProcess(args=[], returncode=128, stdout="", stderr="x"), - # malformed output (not exactly two lines) - subprocess.CompletedProcess(args=[], returncode=0, stdout="only-one-line\n"), - # discovered repo is NOT this source tree (e.g. wheel nested in another repo) - subprocess.CompletedProcess( - args=[], returncode=0, stdout="/some/other/repo\ndeadbee\n" - ), + subprocess.TimeoutExpired(cmd="git", timeout=5.0), + subprocess.CompletedProcess([], 128, stdout=b"", stderr=b"x"), + b"only-one-line\n", + b"/some/other/repo\ndeadbee\n", ], + ids=["no-git", "timeout", "not-a-repo", "short-output", "foreign-toplevel"], ) -def test_git_sha_returns_none_on_untrusted_or_missing_repo(fake, monkeypatch): +def test_get_git_sha_none_on_untrusted_or_missing_repo(rev_parse, monkeypatch): """A foreign/absent repo yields None rather than a wrong provenance SHA.""" + monkeypatch.setattr(version_mod.subprocess, "run", _fake_git(rev_parse=rev_parse)) + assert version_mod.get_git_sha() is None - def fake_run(*args, **kwargs): - if isinstance(fake, BaseException): - raise fake - return fake - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - get_git_sha.cache_clear() - try: - assert get_git_sha() is None - finally: - get_git_sha.cache_clear() +@pytest.mark.unit +def test_get_git_sha_is_endpoints_repo_not_cwd(tmp_path, monkeypatch): + """Launching from an unrelated repo must not record that repo's SHA.""" + expected = _real_checkout_sha() + other = _commit_tree(tmp_path / "other_repo", {"f.txt": "unrelated\n"}) + other_sha = _git("rev-parse", "--short=7", "HEAD", cwd=other) + assert other_sha != expected + + monkeypatch.chdir(other) + sha = version_mod.get_git_sha() + + assert sha is not None + assert sha.removesuffix("-dirty") == expected + + +_GIT_LOCATION_VARS = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +) + + +@pytest.mark.unit +def test_git_env_drops_git_location_family(monkeypatch): + for var in _GIT_LOCATION_VARS: + monkeypatch.setenv(var, "/foreign") + monkeypatch.setenv("PATH", "/usr/bin") + env = version_mod._git_env() + for var in _GIT_LOCATION_VARS: + assert var not in env + assert env["GIT_OPTIONAL_LOCKS"] == "0" + assert env["PATH"] == "/usr/bin" @pytest.mark.unit -def test_git_sha_returned_when_toplevel_matches(monkeypatch): - """Happy path without a real git repo: toplevel == _REPO_ROOT -> return sha. +def test_get_git_sha_passes_scrubbed_env_to_both_probes(monkeypatch): + monkeypatch.setenv("GIT_DIR", "/foreign/.git") + monkeypatch.setenv("GIT_INDEX_FILE", "/foreign/.git/index") + seen_envs = [] + fake = _fake_git(status=b" M src/x.py\n") + + def run(cmd, *args, **kwargs): + seen_envs.append(kwargs.get("env")) + return fake(cmd, *args, **kwargs) + + monkeypatch.setattr(version_mod.subprocess, "run", run) + assert version_mod.get_git_sha() == "abc1234-dirty" + assert len(seen_envs) == 2 # rev-parse + status + for env in seen_envs: + assert env is not None + assert "GIT_DIR" not in env + assert "GIT_INDEX_FILE" not in env - Covers the success branch deterministically so it holds in a no-git - environment (installed wheel / clean container). + +@pytest.mark.unit +def test_get_git_sha_ignores_stray_git_dir(tmp_path, monkeypatch): + """A stray GIT_DIR must not slip a foreign repo's SHA past the toplevel guard. + + With GIT_DIR set (and GIT_WORK_TREE unset) git defaults the work-tree to cwd, + so ``--show-toplevel`` reports our repo (guard passes) while ``HEAD`` would + resolve the foreign repo. """ + our_sha = _real_checkout_sha() + foreign = _commit_tree(tmp_path / "foreign", {"f.txt": "foreign\n"}) + foreign_sha = _git("rev-parse", "--short=7", "HEAD", cwd=foreign) + assert foreign_sha != our_sha - def fake_run(*args, **kwargs): - return subprocess.CompletedProcess( - args=[], returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" - ) + monkeypatch.setenv("GIT_DIR", str(foreign / ".git")) + monkeypatch.delenv("GIT_WORK_TREE", raising=False) + sha = version_mod.get_git_sha() - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - get_git_sha.cache_clear() - try: - assert get_git_sha() == "abc1234" - finally: - get_git_sha.cache_clear() + assert sha is not None + assert sha.removesuffix("-dirty") == our_sha + + +# --------------------------------------------------------------------------- +# _git_tree_dirty (real git) +# --------------------------------------------------------------------------- + +_VBENCH_RUNNER = "examples/09_Wan22_VideoGen_Example/accuracy/vbench_runner.py" +_LAYOUT = { + "src/inference_endpoint/mod.py": "x = 1\n", + "pyproject.toml": "[project]\n", + "uv.lock": "version = 1\n", + _VBENCH_RUNNER: "run()\n", + "examples/data.bin": "payload\n", + "docs/notes.md": "notes\n", +} + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("path", "dirty"), + [ + ("src/inference_endpoint/mod.py", True), + ("pyproject.toml", True), + ("uv.lock", True), + (_VBENCH_RUNNER, True), + ("examples/data.bin", False), + ("docs/notes.md", False), + ], +) +def test_dirty_probe_covers_code_that_runs_and_its_pins(tmp_path, path, dirty): + repo = _commit_tree(tmp_path / "repo", _LAYOUT) + (repo / path).write_text("edited\n") + assert version_mod._git_tree_dirty(repo) is dirty + + +@pytest.mark.unit +@pytest.mark.parametrize( + "script", ["scripts/push_docker_image.sh", "scripts/Dockerfile.dev"] +) +def test_image_build_stamps_sha_like_the_runtime_probe(script): + """Image builds compute GIT_SHA with the runtime's SHA length and dirty scope.""" + text = (Path(__file__).resolve().parents[2] / script).read_text() + assert "rev-parse --short=7 HEAD" in text + assert f"git diff --quiet HEAD -- {' '.join(version_mod._DIRTY_PATHSPEC)}" in text + + +@pytest.mark.unit +def test_dirty_probe_treats_copied_checkout_as_clean(tmp_path): + """New inodes/ctimes with unchanged content are clean; untracked files don't count.""" + repo = _commit_tree(tmp_path / "repo", _LAYOUT) + copy = tmp_path / "copy" + shutil.copytree(repo, copy) + (copy / "src" / "inference_endpoint" / "scratch.jsonl").write_text("untracked\n") + + assert version_mod._git_tree_dirty(copy) is False + (copy / "src" / "inference_endpoint" / "mod.py").write_text("x = 2\n") + assert version_mod._git_tree_dirty(copy) is True + + +@pytest.mark.unit +def test_dirty_probe_never_rewrites_the_index(tmp_path): + """A probe that refreshes stale stat data leaves the caller's index untouched.""" + repo = _commit_tree(tmp_path / "repo", _LAYOUT) + stale = repo / "src" / "inference_endpoint" / "mod.py" + os.utime(stale, (_PAST_MTIME + 60, _PAST_MTIME + 60)) # new mtime, same content + index_before = (repo / ".git" / "index").read_bytes() + + assert version_mod._git_tree_dirty(repo) is False + assert (repo / ".git" / "index").read_bytes() == index_before + + +@pytest.mark.unit +def test_dirty_probe_skips_rehash_of_filtered_file_on_stat_only_change(tmp_path): + """A filtered file (git-lfs style) with only inode/ctime changes stays clean. + + The repo is committed through a clean filter that isn't configured afterwards, + like an LFS checkout opened where git-lfs isn't installed (a container over a + Docker Desktop bind mount, which presents new inode numbers). Re-hashing the + raw file can never match the filtered blob, so only a stat check limited to + mtime and size reports the tree correctly. + """ + repo = _commit_tree( + tmp_path / "repo", + { + ".gitattributes": "src/inference_endpoint/blob.bin filter=fake\n", + "src/inference_endpoint/blob.bin": "aaaa\n", + }, + "-c", + "filter.fake.clean=tr a b", + ) + copy = tmp_path / "copy" + shutil.copytree(repo, copy) + assert version_mod._git_tree_dirty(copy) is False + + +# --------------------------------------------------------------------------- +# resolve_git_sha (channel precedence + validation) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("live", "env", "expected"), + [ + ("0000fff-dirty", "def5678", ("0000fff-dirty", "git")), + (None, "def5678", ("def5678", "env")), + (None, "ABC1234-dirty", ("ABC1234-dirty", "env")), + (None, None, (None, "none")), + (None, "", (None, "none")), + (None, " ", (None, "none")), + (None, "unknown", (None, "none")), + (None, "not-a-sha", (None, "none")), + ("abc1234\x1b[31m", "def5678", ("def5678", "env")), + ], + ids=[ + "git-wins", + "env", + "env-dirty-uppercase", + "nothing", + "env-blank", + "env-whitespace", + "env-sentinel", + "env-non-hex", + "polluted-git-falls-through", + ], +) +def test_resolve_git_sha_outside_a_checkout(tmp_path, monkeypatch, live, env, expected): + """Without a .git at the source root, live git then env, both validated.""" + monkeypatch.setattr(version_mod, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: live) + if env is None: + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + else: + monkeypatch.setenv("ENDPOINTS_GIT_SHA", env) + assert version_mod.resolve_git_sha() == expected + + +@pytest.mark.unit +def test_resolve_git_sha_uses_env_for_installed_package(monkeypatch): + monkeypatch.setattr(version_mod, "_REPO_ROOT", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + assert version_mod.resolve_git_sha() == ("def5678", "env") + + +@pytest.mark.unit +@pytest.mark.parametrize( + "make_git", + [ + lambda git: git.write_text("gitdir: /nonexistent/.git/worktrees/x\n"), + lambda git: git.symlink_to("/nonexistent/.git"), + ], + ids=["gitfile-outside-mount", "dangling-symlink"], +) +def test_unreadable_checkout_reports_none_not_env( + tmp_path, monkeypatch, caplog, make_git +): + """A checkout git can't read reports no SHA; the env SHA describes other code. + + Models a bind-mounted checkout whose ``.git`` points outside the mount: the + image's ENDPOINTS_GIT_SHA names the image's commit, not this tree's. + """ + root = tmp_path / "mnt" + (root / "src" / "inference_endpoint").mkdir(parents=True) + make_git(root / ".git") + monkeypatch.setattr(version_mod, "_REPO_ROOT", root.resolve()) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + + with caplog.at_level("WARNING", logger=version_mod.__name__): + assert version_mod.resolve_git_sha() == (None, "none") + assert str(root.resolve()) in caplog.text + + +@pytest.mark.unit +def test_source_tree_without_git_uses_env(tmp_path, monkeypatch): + """A source layout with no .git (an image's COPY of src/) takes the env SHA.""" + root = tmp_path / "image" + (root / "src" / "inference_endpoint").mkdir(parents=True) + monkeypatch.setattr(version_mod, "_REPO_ROOT", root.resolve()) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + assert version_mod.resolve_git_sha() == ("def5678", "env") + + +# --------------------------------------------------------------------------- +# get_version_info +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_version_info_shape(): + info = version_mod.get_version_info() + assert set(info) == {"version", "git_sha", "git_sha_source"} + assert info["version"] == __version__ + assert info["git_sha_source"] in {"env", "git", "none"} + assert (info["git_sha"] is None) == (info["git_sha_source"] == "none") + + +@pytest.mark.unit +def test_get_version_info_resolves_once_per_process(monkeypatch): + """Every report a process writes describes the code it started with.""" + calls = [] + + def resolve(): + calls.append(None) + return ("abc1234", "git") + + monkeypatch.setattr(version_mod, "resolve_git_sha", resolve) + first = version_mod.get_version_info() + first["git_sha"] = "mutated" + second = version_mod.get_version_info() + + assert second == { + "version": __version__, + "git_sha": "abc1234", + "git_sha_source": "git", + } + assert len(calls) == 1 + + +# --------------------------------------------------------------------------- +# _resolve_repo_root +# --------------------------------------------------------------------------- @pytest.mark.unit -def test_resolve_repo_root_falls_back_on_shallow_tree(monkeypatch): - """A too-shallow module path yields the filesystem root, not IndexError.""" - monkeypatch.setattr(version_mod, "__file__", "/inference_endpoint/utils/version.py") - root = version_mod._resolve_repo_root() - assert root == Path("/inference_endpoint/utils/version.py").resolve().parents[-1] +def test_resolve_repo_root_in_source_layout(monkeypatch): + monkeypatch.setattr( + version_mod, "__file__", "/repo/src/inference_endpoint/utils/version.py" + ) + assert version_mod._resolve_repo_root() == Path("/repo").resolve() + + +@pytest.mark.unit +@pytest.mark.parametrize( + "module_path", + [ + "/inference_endpoint/utils/version.py", + "/venv/lib/python3.12/site-packages/inference_endpoint/utils/version.py", + "/other_repo/vendor/inference_endpoint/utils/version.py", + ], + ids=["too-shallow", "site-packages", "vendored"], +) +def test_resolve_repo_root_none_outside_source_layout(monkeypatch, module_path): + monkeypatch.setattr(version_mod, "__file__", module_path) + assert version_mod._resolve_repo_root() is None