diff --git a/README.md b/README.md index 5efb02c..6e80038 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,10 @@ import or execute that strategy Python. | Scope | Status | | --- | --- | -| Latest public release | [v1.6.0](https://github.com/vntrevx/NFI_BackTestEngine/releases/tag/v1.6.0) | +| Latest public release | [v1.6.1](https://github.com/vntrevx/NFI_BackTestEngine/releases/tag/v1.6.1) | | Five-year Spot | Certified independently by v1.0.0 | | Five-year Futures | Certified independently by v1.1.0 | -| Current `main` | v1.6.0 Full Native Strategy release; no new combined Full X7 certification claim | +| Current `main` | v1.6.1 product update; no new combined Full X7 certification claim | The Spot and Futures certificates remain valid for their own sealed strategy, configuration, data, wheel, and host. They are not a same-candidate Spot-versus-Futures @@ -158,8 +158,26 @@ nfi-bte --version nfi-bte doctor ``` -The latest public installer and a source checkout of `main` return `nfi-bte 1.6.0`, -the Full Native Strategy release. +The latest public installer and a source checkout of `main` return `nfi-bte 1.6.1`. + +### Keep the CLI updated + +Update an installed CLI to the latest public release with one command: + +```text +nfi-bte update +``` + +Successful commands check GitHub Releases at most once every 24 hours. When a newer release is +available, the CLI prints one line to stderr without changing the command result: + +```text +Update available: 1.6.0 -> 1.6.1. Run `nfi-bte update`. +``` + +The updater reuses the active `uv tool`, `pipx`, or Python environment. Source +checkouts remain developer-managed and must be updated through Git and `uv sync`. +Set `NFI_BTE_DISABLE_UPDATE_CHECK=1` to disable the automatic version check. ## Quick start @@ -316,6 +334,7 @@ cleanup](docs/clean.md). | Command | Purpose | | --- | --- | | `nfi-bte run` | Run or resume native research | +| `nfi-bte update` | Update the installed CLI to the latest release | | `nfi-bte strategy check ...` | Check a newly downloaded NFI revision | | `nfi-bte doctor` | Inspect the current machine | | `nfi-bte reference research ...` | Run official Freqtrade | diff --git a/docs/releases/v1.6.1.md b/docs/releases/v1.6.1.md new file mode 100644 index 0000000..d70f8af --- /dev/null +++ b/docs/releases/v1.6.1.md @@ -0,0 +1,35 @@ +# NFI Backtest Engine v1.6.1 + +v1.6.1 adds an in-product update path for installed NFI Backtest Engine CLIs. + +## Update command + +Run one command to install the latest stable GitHub release: + +```text +nfi-bte update +``` + +The updater selects the wheel for the current supported platform from GitHub +Releases, verifies its published SHA-256 digest, and delegates installation to the +active `uv tool`, `pipx`, or Python environment. A source checkout remains +developer-managed and is never replaced by the command. + +## Update notice + +Successful commands check the latest stable GitHub release at most once every 24 +hours. When the installed version is older, the CLI writes a single notice to stderr +without changing the command result: + +```text +Update available: 1.6.0 -> 1.6.1. Run `nfi-bte update`. +``` + +Set `NFI_BTE_DISABLE_UPDATE_CHECK=1` to disable this check. CI and source checkouts +skip it automatically. + +## Certification boundary + +This product update changes CLI distribution behavior, not simulation semantics. +It remains a product release with `combined_full_x7_certified=false`. Existing Spot +and Futures certificates remain bound to their original versions and evidence. diff --git a/pyproject.toml b/pyproject.toml index d129617..605db1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "nfi-backtest-engine" -version = "1.6.0" +version = "1.6.1" description = "Exact-parity research backtesting infrastructure for NFI strategies" readme = "README.md" requires-python = ">=3.12,<3.15" diff --git a/python/nfi_backtest_engine/cli.py b/python/nfi_backtest_engine/cli.py index c911c6b..8ba2c94 100644 --- a/python/nfi_backtest_engine/cli.py +++ b/python/nfi_backtest_engine/cli.py @@ -34,6 +34,9 @@ from .commands import ( system as system_commands, ) +from .commands import ( + update as update_commands, +) from .config_loader import load_effective_config from .errors import NfiBacktestError from .hardware import create_execution_profile @@ -44,6 +47,7 @@ ) from .reference_runtime import load_reference_leverage_tiers from .state_trace import TraceMismatch +from .update_check import maybe_print_update_notice def _add_project_setup_arguments(parser: argparse.ArgumentParser) -> None: @@ -105,6 +109,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subcommands = parser.add_subparsers(dest="command_name", required=True) + subcommands.add_parser("update", help="update the installed CLI to the latest release") + fixture = subcommands.add_parser("fixture", help="manage benchmark fixtures") fixture_commands = fixture.add_subparsers(dest="fixture_command", required=True) validate = fixture_commands.add_parser("validate", help="validate and hash-check a fixture") @@ -1301,6 +1307,8 @@ def _dispatch_command( return certify_commands.execute(args) if command_name in release_commands.COMMAND_NAMES: return release_commands.execute(args) + if command_name in update_commands.COMMAND_NAMES: + return update_commands.execute(args) raise AssertionError(f"unhandled command: {command_name}") @@ -1313,7 +1321,10 @@ def main(argv: Sequence[str] | None = None) -> int: raw_args = raw_args[:separator] args = build_parser().parse_args(raw_args) try: - return _dispatch_command(args, benchmark_command=benchmark_command) + result = _dispatch_command(args, benchmark_command=benchmark_command) + if result == 0 and args.command_name != "update": + maybe_print_update_notice(__version__) + return result except ParityMismatch as exc: print(str(exc), file=sys.stderr) return 1 diff --git a/python/nfi_backtest_engine/commands/update.py b/python/nfi_backtest_engine/commands/update.py new file mode 100644 index 0000000..a44bb91 --- /dev/null +++ b/python/nfi_backtest_engine/commands/update.py @@ -0,0 +1,129 @@ +"""Installed CLI update command.""" + +from __future__ import annotations + +import hashlib +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from urllib.request import Request, urlopen + +from .. import __version__ +from ..errors import NfiBacktestError +from ..update_check import LatestRelease, fetch_latest_release, is_newer_release + +COMMAND_NAMES = frozenset({"update"}) +DOWNLOAD_TIMEOUT_SECONDS = 30.0 + + +def _is_source_checkout() -> bool: + repository_root = Path(__file__).resolve().parents[3] + return (repository_root / "pyproject.toml").is_file() and ( + repository_root / ".git" + ).exists() + + +def _wheel_suffix() -> str: + system = platform.system() + machine = platform.machine().lower() + if system == "Linux" and machine in {"x86_64", "amd64"}: + return "manylinux2014_x86_64.whl" + if system == "Linux" and machine in {"aarch64", "arm64"}: + return "manylinux2014_aarch64.whl" + if system == "Darwin" and machine == "arm64": + return "macosx_11_0_arm64.whl" + if system == "Windows" and machine in {"amd64", "x86_64"}: + return "win_amd64.whl" + raise NfiBacktestError(f"no release wheel is available for {system} {machine}") + + +def _download_release_wheel(release: LatestRelease, destination: Path) -> Path: + suffix = _wheel_suffix() + matching = [asset for asset in release.assets if asset.name.endswith(suffix)] + if len(matching) != 1: + raise NfiBacktestError( + f"expected one {suffix} wheel in v{release.version}; found {len(matching)}" + ) + asset = matching[0] + headers = {"User-Agent": "nfi-bte updater"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = Request(asset.download_url, headers=headers) + wheel_path = destination / asset.name + hasher = hashlib.sha256() + with ( + urlopen(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response, + wheel_path.open("wb") as output, + ): + while chunk := response.read(1024 * 1024): + output.write(chunk) + hasher.update(chunk) + if hasher.hexdigest() != asset.sha256: + wheel_path.unlink(missing_ok=True) + raise NfiBacktestError("downloaded wheel SHA-256 differs from the GitHub release") + return wheel_path + + +def _select_upgrade_command(wheel_path: Path) -> list[str]: + executable = Path(sys.executable).resolve().as_posix().lower() + uv = shutil.which("uv") + if uv is not None and "/uv/tools/" in executable: + return [ + uv, + "tool", + "install", + "--force", + "--python", + "3.12", + str(wheel_path), + ] + + pipx = shutil.which("pipx") + if pipx is not None and "/pipx/venvs/" in executable: + return [pipx, "install", "--force", str(wheel_path)] + + if uv is not None: + return [ + uv, + "pip", + "install", + "--python", + sys.executable, + "--upgrade", + str(wheel_path), + ] + return [sys.executable, "-m", "pip", "install", "--upgrade", str(wheel_path)] + + +def execute(args: object) -> int: + """Upgrade an installed CLI through the environment's package manager.""" + if getattr(args, "command_name", None) != "update": + raise AssertionError("unhandled update command") + if _is_source_checkout(): + raise NfiBacktestError( + "self-update is unavailable in a source checkout; update the checkout " + "and run `uv sync --extra dev --frozen`" + ) + + try: + release = fetch_latest_release() + except (OSError, ValueError) as exc: + raise NfiBacktestError(f"could not read the latest GitHub release: {exc}") from exc + if not is_newer_release(release.version, __version__): + print(f"Already up to date: {__version__}.") + return 0 + + with tempfile.TemporaryDirectory(prefix="nfi-bte-update-") as temporary_directory: + wheel_path = _download_release_wheel(release, Path(temporary_directory)) + command = _select_upgrade_command(wheel_path) + completed = subprocess.run(command, check=False) + if completed.returncode != 0: + raise NfiBacktestError("update failed; the installed version was left unchanged") + + print(f"Updated to NFI Backtest Engine {release.version}.") + return 0 diff --git a/python/nfi_backtest_engine/update_check.py b/python/nfi_backtest_engine/update_check.py new file mode 100644 index 0000000..cf48760 --- /dev/null +++ b/python/nfi_backtest_engine/update_check.py @@ -0,0 +1,219 @@ +"""Low-overhead latest-release checks for the CLI.""" + +from __future__ import annotations + +import json +import os +import re +import sys +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TextIO +from urllib.request import Request, urlopen + +GITHUB_LATEST_RELEASE_URL = ( + "https://api.github.com/repos/vntrevx/NFI_BackTestEngine/releases/latest" +) +CHECK_INTERVAL_SECONDS = 24 * 60 * 60 +NETWORK_TIMEOUT_SECONDS = 1.0 +_RELEASE_PATTERN = re.compile(r"^\d+(?:\.\d+)*") +_STABLE_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") + + +@dataclass(frozen=True) +class ReleaseAsset: + """One checksum-addressed GitHub release asset.""" + + name: str + download_url: str + sha256: str + + +@dataclass(frozen=True) +class LatestRelease: + """The latest stable GitHub release and its downloadable assets.""" + + version: str + assets: tuple[ReleaseAsset, ...] + + +def _release_tuple(version: str) -> tuple[int, ...]: + match = _RELEASE_PATTERN.match(version) + if match is None: + raise ValueError(f"unsupported version: {version}") + return tuple(int(part) for part in match.group().split(".")) + + +def is_newer_release(latest_version: str, current_version: str) -> bool: + latest = _release_tuple(latest_version) + current = _release_tuple(current_version) + width = max(len(latest), len(current)) + return latest + (0,) * (width - len(latest)) > current + (0,) * (width - len(current)) + + +def _read_cached_version(cache_path: Path, *, now_epoch: float) -> str | None: + try: + payload = json.loads(cache_path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + + checked_at = payload.get("checked_at") + latest_version = payload.get("latest_version") + if not isinstance(checked_at, (int, float)) or not isinstance(latest_version, str): + return None + age = now_epoch - float(checked_at) + if not 0 <= age < CHECK_INTERVAL_SECONDS: + return None + return latest_version + + +def _write_cached_version(cache_path: Path, *, now_epoch: float, latest_version: str) -> None: + cache_path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = cache_path.with_suffix(f"{cache_path.suffix}.{os.getpid()}.tmp") + temporary_path.write_text( + json.dumps( + { + "checked_at": now_epoch, + "latest_version": latest_version, + }, + sort_keys=True, + ), + encoding="utf-8", + ) + temporary_path.replace(cache_path) + + +def parse_latest_release(payload: object) -> LatestRelease: + """Parse the stable GitHub release contract without guessing.""" + if not isinstance(payload, dict): + raise ValueError("GitHub returned a non-object release") + tag_name = payload.get("tag_name") + if not isinstance(tag_name, str) or not tag_name.startswith("v"): + raise ValueError("GitHub release has no stable version tag") + version = tag_name.removeprefix("v") + if _STABLE_VERSION_PATTERN.fullmatch(version) is None: + raise ValueError(f"GitHub latest release tag is not stable: {tag_name}") + + raw_assets = payload.get("assets") + if not isinstance(raw_assets, list): + raise ValueError("GitHub release has no asset list") + assets: list[ReleaseAsset] = [] + for raw_asset in raw_assets: + if not isinstance(raw_asset, dict): + raise ValueError("GitHub release contains a non-object asset") + name = raw_asset.get("name") + download_url = raw_asset.get("browser_download_url") + digest = raw_asset.get("digest") + if ( + not isinstance(name, str) + or not isinstance(download_url, str) + or not isinstance(digest, str) + or not digest.startswith("sha256:") + ): + raise ValueError("GitHub release asset is missing its SHA-256 identity") + sha256 = digest.removeprefix("sha256:") + if len(sha256) != 64: + raise ValueError(f"GitHub release asset has an invalid SHA-256 digest: {name}") + assets.append( + ReleaseAsset( + name=name, + download_url=download_url, + sha256=sha256, + ) + ) + return LatestRelease(version=version, assets=tuple(assets)) + + +def fetch_latest_release() -> LatestRelease: + """Read the latest checksum-addressed stable release from GitHub.""" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "nfi-bte update-check", + "X-GitHub-Api-Version": "2022-11-28", + } + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = Request( + GITHUB_LATEST_RELEASE_URL, + headers=headers, + ) + with urlopen(request, timeout=NETWORK_TIMEOUT_SECONDS) as response: + payload: object = json.load(response) + return parse_latest_release(payload) + + +def fetch_latest_version() -> str: + """Read the latest published GitHub release version.""" + return fetch_latest_release().version + + +def available_update_notice( + current_version: str, + *, + cache_path: Path, + now_epoch: float, + fetch_latest: Callable[[], str], +) -> str | None: + """Return a notice only when GitHub has a newer stable release.""" + latest_version = _read_cached_version(cache_path, now_epoch=now_epoch) + if latest_version is None: + latest_version = fetch_latest() + _write_cached_version( + cache_path, + now_epoch=now_epoch, + latest_version=latest_version, + ) + if not is_newer_release(latest_version, current_version): + return None + return ( + f"Update available: {current_version} -> {latest_version}. " + "Run `nfi-bte update`." + ) + + +def _cache_path(environment: Mapping[str, str]) -> Path: + configured = environment.get("XDG_CACHE_HOME") + cache_root = Path(configured) if configured else Path.home() / ".cache" + return cache_root / "nfi-backtest-engine" / "update-check.json" + + +def _is_source_checkout() -> bool: + repository_root = Path(__file__).resolve().parents[2] + return (repository_root / "pyproject.toml").is_file() and ( + repository_root / ".git" + ).exists() + + +def maybe_print_update_notice( + current_version: str, + *, + environment: Mapping[str, str] | None = None, + stderr: TextIO | None = None, +) -> None: + """Print a cached daily update notice without changing command status.""" + active_environment = os.environ if environment is None else environment + output = sys.stderr if stderr is None else stderr + if ( + active_environment.get("NFI_BTE_DISABLE_UPDATE_CHECK") == "1" + or active_environment.get("CI") + or _is_source_checkout() + ): + return + + try: + notice = available_update_notice( + current_version, + cache_path=_cache_path(active_environment), + now_epoch=time.time(), + fetch_latest=fetch_latest_version, + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"warning: update check failed: {exc}", file=output) + return + if notice is not None: + print(notice, file=output) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a785f88..7603cea 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -390,7 +390,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nfi-py" -version = "1.6.0" +version = "1.6.1" dependencies = [ "nfi-sim-core", "nfi-vector-core", @@ -403,7 +403,7 @@ dependencies = [ [[package]] name = "nfi-sim-cli" -version = "1.6.0" +version = "1.6.1" dependencies = [ "nfi-sim-core", "nfi-vector-io", @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "nfi-sim-core" -version = "1.6.0" +version = "1.6.1" dependencies = [ "num-bigint", "num-rational", @@ -427,7 +427,7 @@ dependencies = [ [[package]] name = "nfi-vector-core" -version = "1.6.0" +version = "1.6.1" dependencies = [ "arrow2", "serde", @@ -438,7 +438,7 @@ dependencies = [ [[package]] name = "nfi-vector-io" -version = "1.6.0" +version = "1.6.1" dependencies = [ "arrow2", "fs2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c6c0481..aa98a48 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,7 +9,7 @@ members = [ resolver = "2" [workspace.package] -version = "1.6.0" +version = "1.6.1" edition = "2021" license = "MIT" rust-version = "1.83" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7e5d7ab..73095e9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,6 +29,9 @@ from nfi_backtest_engine.commands import ( system as system_commands, ) +from nfi_backtest_engine.commands import ( + update as update_commands, +) from nfi_backtest_engine.parity import ParityDifference, ParityMismatch @@ -259,6 +262,7 @@ def test_every_top_level_command_has_one_handler() -> None: clean_commands.COMMAND_NAMES, certify_commands.COMMAND_NAMES, release_commands.COMMAND_NAMES, + update_commands.COMMAND_NAMES, ] assert set().union(*handler_sets) == set(subparsers.choices) @@ -287,6 +291,35 @@ def fail_dispatch(*_args, **_kwargs): assert "parity mismatch at $.trades" in capsys.readouterr().err +def test_successful_command_checks_for_update_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + checked_versions: list[str] = [] + + monkeypatch.setattr(cli, "_dispatch_command", lambda *_args, **_kwargs: 0) + monkeypatch.setattr( + cli, + "maybe_print_update_notice", + lambda version: checked_versions.append(version), + ) + + assert cli.main(["doctor"]) == 0 + assert checked_versions == [cli.__version__] + + +def test_update_command_does_not_repeat_update_check( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "_dispatch_command", lambda *_args, **_kwargs: 0) + monkeypatch.setattr( + cli, + "maybe_print_update_notice", + lambda _version: pytest.fail("update must not trigger an update check"), + ) + + assert cli.main(["update"]) == 0 + + def test_futures_market_capture_loads_pinned_binance_tiers( monkeypatch, tmp_path: Path, diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..5187c32 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from nfi_backtest_engine import update_check +from nfi_backtest_engine.commands import update as update_commands +from nfi_backtest_engine.errors import NfiBacktestError + + +def test_update_executes_detected_upgrade_command( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + observed: list[list[str]] = [] + release = update_check.LatestRelease(version="1.6.1", assets=()) + + monkeypatch.setattr(update_commands, "_is_source_checkout", lambda: False) + monkeypatch.setattr(update_commands, "__version__", "1.6.0") + monkeypatch.setattr(update_commands, "fetch_latest_release", lambda: release) + monkeypatch.setattr( + update_commands, + "_download_release_wheel", + lambda fetched, destination: destination / f"engine-{fetched.version}.whl", + ) + monkeypatch.setattr( + update_commands, + "_select_upgrade_command", + lambda wheel: ["/usr/bin/uv", "tool", "install", "--force", str(wheel)], + ) + + def fake_run( + arguments: list[str], + *, + check: bool, + ) -> subprocess.CompletedProcess[str]: + observed.append(arguments) + assert check is False + return subprocess.CompletedProcess(arguments, returncode=0) + + monkeypatch.setattr(update_commands.subprocess, "run", fake_run) + + result = update_commands.execute(argparse.Namespace(command_name="update")) + + assert result == 0 + assert observed[0][:4] == ["/usr/bin/uv", "tool", "install", "--force"] + assert observed[0][4].endswith("engine-1.6.1.whl") + assert "Updated to NFI Backtest Engine 1.6.1." in capsys.readouterr().out + + +def test_update_failure_preserves_installed_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = update_check.LatestRelease(version="1.6.1", assets=()) + + monkeypatch.setattr(update_commands, "_is_source_checkout", lambda: False) + monkeypatch.setattr(update_commands, "__version__", "1.6.0") + monkeypatch.setattr(update_commands, "fetch_latest_release", lambda: release) + monkeypatch.setattr( + update_commands, + "_download_release_wheel", + lambda fetched, destination: destination / f"engine-{fetched.version}.whl", + ) + monkeypatch.setattr( + update_commands, + "_select_upgrade_command", + lambda wheel: ["/usr/bin/uv", "tool", "install", "--force", str(wheel)], + ) + monkeypatch.setattr( + update_commands.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess(_args[0], returncode=1), + ) + + with pytest.raises(NfiBacktestError, match="installed version was left unchanged"): + update_commands.execute(argparse.Namespace(command_name="update")) + + +def test_update_does_not_downgrade_newer_installed_version( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + release = update_check.LatestRelease(version="1.6.0", assets=()) + + monkeypatch.setattr(update_commands, "_is_source_checkout", lambda: False) + monkeypatch.setattr(update_commands, "__version__", "1.6.1") + monkeypatch.setattr(update_commands, "fetch_latest_release", lambda: release) + monkeypatch.setattr( + update_commands, + "_download_release_wheel", + lambda *_args: pytest.fail("a newer installation must not be replaced"), + ) + + result = update_commands.execute(argparse.Namespace(command_name="update")) + + assert result == 0 + assert "Already up to date: 1.6.1." in capsys.readouterr().out + + +def test_uv_tool_install_selects_verified_wheel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wheel = Path("/tmp/nfi-backtest-engine-1.6.1.whl") + monkeypatch.setattr(sys, "executable", "/home/user/.local/share/uv/tools/pkg/bin/python") + monkeypatch.setattr( + update_commands.shutil, + "which", + lambda executable: f"/usr/bin/{executable}" if executable == "uv" else None, + ) + + assert update_commands._select_upgrade_command(wheel) == [ + "/usr/bin/uv", + "tool", + "install", + "--force", + "--python", + "3.12", + str(wheel), + ] + + +def test_github_release_payload_parses_version_and_assets() -> None: + release = update_check.parse_latest_release( + { + "tag_name": "v1.6.1", + "assets": [ + { + "name": "nfi_backtest_engine-1.6.1-manylinux2014_x86_64.whl", + "browser_download_url": "https://example.test/engine.whl", + "digest": "sha256:" + "a" * 64, + } + ], + } + ) + + assert release.version == "1.6.1" + assert release.assets == ( + update_check.ReleaseAsset( + name="nfi_backtest_engine-1.6.1-manylinux2014_x86_64.whl", + download_url="https://example.test/engine.whl", + sha256="a" * 64, + ), + ) + + +def test_update_notice_fetches_and_caches_latest_version(tmp_path: Path) -> None: + cache_path = tmp_path / "update-check.json" + + notice = update_check.available_update_notice( + "1.6.0", + cache_path=cache_path, + now_epoch=10_000.0, + fetch_latest=lambda: "1.7.0", + ) + + assert notice == "Update available: 1.6.0 -> 1.7.0. Run `nfi-bte update`." + assert json.loads(cache_path.read_text(encoding="utf-8")) == { + "checked_at": 10_000.0, + "latest_version": "1.7.0", + } + + +def test_update_notice_uses_fresh_cache_without_network(tmp_path: Path) -> None: + cache_path = tmp_path / "update-check.json" + cache_path.write_text( + json.dumps({"checked_at": 9_900.0, "latest_version": "1.7.0"}), + encoding="utf-8", + ) + + def fail_fetch() -> str: + raise AssertionError("fresh cache must avoid a network request") + + notice = update_check.available_update_notice( + "1.6.0", + cache_path=cache_path, + now_epoch=10_000.0, + fetch_latest=fail_fetch, + ) + + assert notice == "Update available: 1.6.0 -> 1.7.0. Run `nfi-bte update`." + + +def test_update_notice_does_not_downgrade_newer_local_version(tmp_path: Path) -> None: + notice = update_check.available_update_notice( + "2.0.0", + cache_path=tmp_path / "update-check.json", + now_epoch=10_000.0, + fetch_latest=lambda: "1.7.0", + ) + + assert notice is None diff --git a/uv.lock b/uv.lock index 148319c..a3307c2 100644 --- a/uv.lock +++ b/uv.lock @@ -741,7 +741,7 @@ wheels = [ [[package]] name = "nfi-backtest-engine" -version = "1.6.0" +version = "1.6.1" source = { editable = "." } dependencies = [ { name = "blake3" },