From d7f36470ea6e17bc8e2a03f74eb2c880259978b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schnabel?= Date: Thu, 20 Aug 2026 15:11:34 +0200 Subject: [PATCH 1/6] Initial version of fetch script --- fetch-cuoptlink.py | 343 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 fetch-cuoptlink.py diff --git a/fetch-cuoptlink.py b/fetch-cuoptlink.py new file mode 100644 index 0000000..b3c3421 --- /dev/null +++ b/fetch-cuoptlink.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import ctypes +import os +import platform +import shutil +import tempfile +import zipfile +from typing import Optional + +import typer + +app = typer.Typer( + help="Manage cuOpt solver link for standalone GAMS system on Linux.", + invoke_without_command=True, +) + +SOLVER_NAME = "cuopt" +REPOSITORY = "GAMS-dev/cuoptlink-builder" +BASE_RELEASE_URL = f"https://api.github.com/repos/{REPOSITORY}/releases" +CUDA_VERSIONS = ("12", "13") +DEFAULT_CUDA_VERSION = "13" +CONFIG_FILE = "gamsconfig.yaml" +BACKUP_FILE = "gamsconfig.yaml.cuopt_backup" +MANIFEST_FILE = ".cuopt_installed_files.txt" + + +def _get_manifest_path(gams_dir: str) -> str: + return os.path.join(gams_dir, MANIFEST_FILE) + + +def get_installed_files(gams_dir: str) -> list[str]: + try: + with open(_get_manifest_path(gams_dir), encoding="utf-8") as file: + return [line for line in file.read().splitlines() if line] + except FileNotFoundError: + return [] + + +def _set_installed_files(gams_dir: str, files: list[str]) -> None: + with open(_get_manifest_path(gams_dir), "w", encoding="utf-8") as file: + file.write("\n".join(sorted(files)) + "\n") + + +def _get_architecture() -> str: + if platform.system() != "Linux": + typer.echo(f"`{SOLVER_NAME}` link installer only supports Linux systems.") + raise typer.Exit(code=1) + + machine = platform.machine().lower() + if machine in ("x86_64", "amd64"): + return "x86_64" + if machine in ("aarch64", "arm64"): + return "arm64" + + typer.echo(f"`{SOLVER_NAME}` supports x86_64 and arm64, found {machine}.") + raise typer.Exit(code=1) + + +def _detect_cuda_version() -> str | None: + for version in reversed(CUDA_VERSIONS): + for library in (f"libcudart.so.{version}", f"libcublas.so.{version}"): + try: + _ = ctypes.CDLL(library) + return version + except OSError: + continue + return None + + +def _get_default_gams_dir() -> Optional[str]: + gams_bin = shutil.which("gams") + if gams_bin: + resolved_bin = os.path.realpath(gams_bin) + return os.path.dirname(resolved_bin) + return None + + +def _get_asset_urls(names: list[str], release_tag: Optional[str] = None) -> list[str]: + import requests + + if release_tag and release_tag.lower() != "latest": + url = f"{BASE_RELEASE_URL}/tags/{release_tag}" + else: + url = f"{BASE_RELEASE_URL}/latest" + + try: + response = requests.get(url, timeout=30) + except requests.RequestException as e: + typer.echo(f"Could not reach GitHub API ({url}): {e}") + raise typer.Exit(code=1) from e + + if response.status_code != 200: + typer.echo( + f"Failed to fetch release info ({response.status_code}): {response.text}" + ) + raise typer.Exit(code=1) + + release = response.json() + assets = { + asset["name"]: asset["browser_download_url"] for asset in release["assets"] + } + + urls = [] + for name in names: + if name not in assets: + typer.echo( + f"Release `{release['tag_name']}` does not contain `{name}`. " + f"Available assets: {sorted(assets)}" + ) + raise typer.Exit(code=1) + urls.append(assets[name]) + + typer.echo(f"Installing `{SOLVER_NAME}` from release `{release['tag_name']}`...") + return urls + + +def _download(url: str, path: str) -> None: + import requests + from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + TextColumn, + TimeRemainingColumn, + ) + + name = os.path.basename(path) + try: + with requests.get(url, stream=True, timeout=60) as response: + response.raise_for_status() + total = int(response.headers.get("Content-Length", 0)) + + with ( + Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TimeRemainingColumn(), + ) as progress, + open(path, "wb") as file, + ): + task = progress.add_task(name, total=total or None) + for chunk in response.iter_content(chunk_size=1024 * 1024): + _ = file.write(chunk) + progress.update(task, advance=len(chunk)) + except requests.RequestException as e: + typer.echo(f"Could not download {url}: {e}") + raise typer.Exit(code=1) from e + + +def _extract(path: str, directory: str) -> list[str]: + names = [] + with zipfile.ZipFile(path) as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + + name = os.path.basename(info.filename) + if not name: + continue + + target_path = os.path.join(directory, name) + with archive.open(info) as source, open(target_path, "wb") as target: + shutil.copyfileobj(source, target) + + mode = (info.external_attr >> 16) & 0o777 + if mode: + os.chmod(target_path, mode) + + names.append(name) + return names + + +def _backup_config(gams_dir: str, installed_files: list[str]) -> None: + config_path = os.path.join(gams_dir, CONFIG_FILE) + if not os.path.isfile(config_path) or CONFIG_FILE in installed_files: + return + + backup_path = os.path.join(gams_dir, BACKUP_FILE) + shutil.copy2(config_path, backup_path) + typer.echo(f"Backed up original `{config_path}` to `{backup_path}`.") + + +def _run_interactive_install() -> None: + default_gams_dir = _get_default_gams_dir() + + while True: + prompt_kwargs = {} + if default_gams_dir: + prompt_kwargs["default"] = default_gams_dir + + gams_dir_input = typer.prompt("GAMS system directory path", **prompt_kwargs) + gams_dir = os.path.abspath(os.path.expanduser(gams_dir_input)) + + if os.path.isdir(gams_dir) and os.access(gams_dir, os.W_OK): + break + typer.echo(f"Error: Directory '{gams_dir}' does not exist or is not writable. Try again.\n") + + cuda_version = typer.prompt( + "CUDA version", + default=DEFAULT_CUDA_VERSION, + ) + + cuda_runtime = typer.confirm( + "Download and install bundled CUDA runtime libraries?", + default=False, + ) + + release = typer.prompt( + "cuoptlink-builder release version", + default="latest", + ) + + _execute_install(gams_dir, cuda_version, cuda_runtime, release) + + +def _execute_install( + gams_dir: str, + cuda_version: Optional[str], + cuda_runtime: bool, + release: Optional[str], +) -> None: + architecture = _get_architecture() + + if cuda_version is None: + cuda_version = _detect_cuda_version() + if cuda_version is None: + if not cuda_runtime: + typer.echo( + "CUDA runtime auto-detection failed. Pass `--cuda-version` " + "or enable `--cuda-runtime`." + ) + raise typer.Exit(code=1) + cuda_version = DEFAULT_CUDA_VERSION + else: + typer.echo(f"Detected CUDA runtime version {cuda_version}.") + + if cuda_version not in CUDA_VERSIONS: + typer.echo(f"Unsupported CUDA version `{cuda_version}`. Choice: {CUDA_VERSIONS}") + raise typer.Exit(code=1) + + archives = [f"cuopt-link-release-cu{cuda_version}-{architecture}.zip"] + if cuda_runtime: + archives.append(f"cu{cuda_version}-runtime-{architecture}.zip") + + urls = _get_asset_urls(archives, release_tag=release) + installed_files = get_installed_files(gams_dir) + _backup_config(gams_dir, installed_files) + + files = set(installed_files) + with tempfile.TemporaryDirectory() as directory: + for name, url in zip(archives, urls): + archive_path = os.path.join(directory, name) + _download(url, archive_path) + files.update(_extract(archive_path, gams_dir)) + + _set_installed_files(gams_dir, sorted(files)) + typer.echo(f"Successfully installed `{SOLVER_NAME}` into `{gams_dir}`.") + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + _run_interactive_install() + + +@app.command() +def install( + gams_dir: str = typer.Option( + ..., + "--gams-dir", + "-g", + help="Path to the GAMS system directory.", + exists=True, + file_okay=False, + dir_okay=True, + writable=True, + resolve_path=True, + ), + cuda_version: Optional[str] = typer.Option( + None, + "--cuda-version", + "-c", + help="CUDA version (12 or 13). Auto-detected if omitted.", + ), + cuda_runtime: bool = typer.Option( + False, + "--cuda-runtime", + help="Download and unpack bundled CUDA runtime libraries.", + ), + release: Optional[str] = typer.Option( + "latest", + "--release", + "-r", + help="Tag name of cuoptlink-builder release (e.g. v1.0.0 or 'latest').", + ), +) -> None: + """Install the cuOpt link into a target GAMS system directory.""" + _execute_install(gams_dir, cuda_version, cuda_runtime, release) + + +@app.command() +def uninstall( + gams_dir: str = typer.Option( + ..., + "--gams-dir", + "-g", + help="Path to the GAMS system directory.", + exists=True, + file_okay=False, + dir_okay=True, + writable=True, + resolve_path=True, + ), +) -> None: + """Uninstall the cuOpt link from a target GAMS system directory.""" + _get_architecture() + installed_files = get_installed_files(gams_dir) + if not installed_files: + typer.echo(f"`{SOLVER_NAME}` is not recorded as installed in `{gams_dir}`.") + raise typer.Exit(code=1) + + for name in installed_files: + try: + os.unlink(os.path.join(gams_dir, name)) + except FileNotFoundError: + pass + + backup_path = os.path.join(gams_dir, BACKUP_FILE) + if os.path.isfile(backup_path): + shutil.move(backup_path, os.path.join(gams_dir, CONFIG_FILE)) + + try: + os.unlink(_get_manifest_path(gams_dir)) + except FileNotFoundError: + pass + + typer.echo(f"Successfully uninstalled `{SOLVER_NAME}` from `{gams_dir}`.") + + +if __name__ == "__main__": + app() \ No newline at end of file From 8a237014278cf2e78cab5ab15ff1679fa3d9caaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schnabel?= Date: Thu, 20 Aug 2026 15:13:08 +0200 Subject: [PATCH 2/6] Also prompt GAMS sysdir in uninstall action when it's not specified in command line --- fetch-cuoptlink.py | 99 +++++++++++++++++++++++----------------------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/fetch-cuoptlink.py b/fetch-cuoptlink.py index b3c3421..cbda62e 100644 --- a/fetch-cuoptlink.py +++ b/fetch-cuoptlink.py @@ -76,6 +76,21 @@ def _get_default_gams_dir() -> Optional[str]: return None +def _prompt_gams_dir() -> str: + default_gams_dir = _get_default_gams_dir() + while True: + prompt_kwargs = {} + if default_gams_dir: + prompt_kwargs["default"] = default_gams_dir + + gams_dir_input = typer.prompt("GAMS system directory path", **prompt_kwargs) + gams_dir = os.path.abspath(os.path.expanduser(gams_dir_input)) + + if os.path.isdir(gams_dir) and os.access(gams_dir, os.W_OK): + return gams_dir + typer.echo(f"Error: Directory '{gams_dir}' does not exist or is not writable. Try again.\n") + + def _get_asset_urls(names: list[str], release_tag: Optional[str] = None) -> list[str]: import requests @@ -183,19 +198,7 @@ def _backup_config(gams_dir: str, installed_files: list[str]) -> None: def _run_interactive_install() -> None: - default_gams_dir = _get_default_gams_dir() - - while True: - prompt_kwargs = {} - if default_gams_dir: - prompt_kwargs["default"] = default_gams_dir - - gams_dir_input = typer.prompt("GAMS system directory path", **prompt_kwargs) - gams_dir = os.path.abspath(os.path.expanduser(gams_dir_input)) - - if os.path.isdir(gams_dir) and os.access(gams_dir, os.W_OK): - break - typer.echo(f"Error: Directory '{gams_dir}' does not exist or is not writable. Try again.\n") + gams_dir = _prompt_gams_dir() cuda_version = typer.prompt( "CUDA version", @@ -259,6 +262,31 @@ def _execute_install( typer.echo(f"Successfully installed `{SOLVER_NAME}` into `{gams_dir}`.") +def _execute_uninstall(gams_dir: str) -> None: + _get_architecture() + installed_files = get_installed_files(gams_dir) + if not installed_files: + typer.echo(f"`{SOLVER_NAME}` is not recorded as installed in `{gams_dir}`.") + raise typer.Exit(code=1) + + for name in installed_files: + try: + os.unlink(os.path.join(gams_dir, name)) + except FileNotFoundError: + pass + + backup_path = os.path.join(gams_dir, BACKUP_FILE) + if os.path.isfile(backup_path): + shutil.move(backup_path, os.path.join(gams_dir, CONFIG_FILE)) + + try: + os.unlink(_get_manifest_path(gams_dir)) + except FileNotFoundError: + pass + + typer.echo(f"Successfully uninstalled `{SOLVER_NAME}` from `{gams_dir}`.") + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context) -> None: if ctx.invoked_subcommand is None: @@ -267,16 +295,11 @@ def main(ctx: typer.Context) -> None: @app.command() def install( - gams_dir: str = typer.Option( - ..., + gams_dir: Optional[str] = typer.Option( + None, "--gams-dir", "-g", help="Path to the GAMS system directory.", - exists=True, - file_okay=False, - dir_okay=True, - writable=True, - resolve_path=True, ), cuda_version: Optional[str] = typer.Option( None, @@ -297,46 +320,24 @@ def install( ), ) -> None: """Install the cuOpt link into a target GAMS system directory.""" + if gams_dir is None: + gams_dir = _prompt_gams_dir() _execute_install(gams_dir, cuda_version, cuda_runtime, release) @app.command() def uninstall( - gams_dir: str = typer.Option( - ..., + gams_dir: Optional[str] = typer.Option( + None, "--gams-dir", "-g", help="Path to the GAMS system directory.", - exists=True, - file_okay=False, - dir_okay=True, - writable=True, - resolve_path=True, ), ) -> None: """Uninstall the cuOpt link from a target GAMS system directory.""" - _get_architecture() - installed_files = get_installed_files(gams_dir) - if not installed_files: - typer.echo(f"`{SOLVER_NAME}` is not recorded as installed in `{gams_dir}`.") - raise typer.Exit(code=1) - - for name in installed_files: - try: - os.unlink(os.path.join(gams_dir, name)) - except FileNotFoundError: - pass - - backup_path = os.path.join(gams_dir, BACKUP_FILE) - if os.path.isfile(backup_path): - shutil.move(backup_path, os.path.join(gams_dir, CONFIG_FILE)) - - try: - os.unlink(_get_manifest_path(gams_dir)) - except FileNotFoundError: - pass - - typer.echo(f"Successfully uninstalled `{SOLVER_NAME}` from `{gams_dir}`.") + if gams_dir is None: + gams_dir = _prompt_gams_dir() + _execute_uninstall(gams_dir) if __name__ == "__main__": From 760dd17551e93183070d8825484fa9e2d84e1e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schnabel?= Date: Thu, 20 Aug 2026 15:19:53 +0200 Subject: [PATCH 3/6] Improve prompt for CUDA version --- fetch-cuoptlink.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/fetch-cuoptlink.py b/fetch-cuoptlink.py index cbda62e..55fcb18 100644 --- a/fetch-cuoptlink.py +++ b/fetch-cuoptlink.py @@ -91,6 +91,22 @@ def _prompt_gams_dir() -> str: typer.echo(f"Error: Directory '{gams_dir}' does not exist or is not writable. Try again.\n") +def _prompt_cuda_version() -> str: + detected_cuda = _detect_cuda_version() + if detected_cuda: + typer.echo(f"Detected CUDA runtime on system: CUDA {detected_cuda}") + default_cuda = detected_cuda + else: + typer.echo("No CUDA runtime automatically detected on system.") + default_cuda = DEFAULT_CUDA_VERSION + + versions_str = ", ".join(CUDA_VERSIONS) + return typer.prompt( + f"CUDA version (supported: {versions_str})", + default=default_cuda, + ) + + def _get_asset_urls(names: list[str], release_tag: Optional[str] = None) -> list[str]: import requests @@ -199,11 +215,7 @@ def _backup_config(gams_dir: str, installed_files: list[str]) -> None: def _run_interactive_install() -> None: gams_dir = _prompt_gams_dir() - - cuda_version = typer.prompt( - "CUDA version", - default=DEFAULT_CUDA_VERSION, - ) + cuda_version = _prompt_cuda_version() cuda_runtime = typer.confirm( "Download and install bundled CUDA runtime libraries?", From a8a35e0a0353c23a9921ca46638423a6ab5005ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schnabel?= Date: Thu, 20 Aug 2026 15:26:51 +0200 Subject: [PATCH 4/6] Make fetch&install runtime the default when it's not found --- fetch-cuoptlink.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/fetch-cuoptlink.py b/fetch-cuoptlink.py index 55fcb18..a7b9c94 100644 --- a/fetch-cuoptlink.py +++ b/fetch-cuoptlink.py @@ -6,7 +6,7 @@ import shutil import tempfile import zipfile -from typing import Optional +from typing import Optional, Tuple import typer @@ -91,21 +91,34 @@ def _prompt_gams_dir() -> str: typer.echo(f"Error: Directory '{gams_dir}' does not exist or is not writable. Try again.\n") -def _prompt_cuda_version() -> str: +def _prompt_cuda_version() -> Tuple[str, bool]: detected_cuda = _detect_cuda_version() if detected_cuda: typer.echo(f"Detected CUDA runtime on system: CUDA {detected_cuda}") default_cuda = detected_cuda + has_detected_cuda = True else: typer.echo("No CUDA runtime automatically detected on system.") default_cuda = DEFAULT_CUDA_VERSION + has_detected_cuda = False versions_str = ", ".join(CUDA_VERSIONS) - return typer.prompt( + cuda_version = typer.prompt( f"CUDA version (supported: {versions_str})", default=default_cuda, ) + # If CUDA was detected on the host system, default runtime download to False. + # Otherwise, default runtime download to True. + default_runtime_download = not has_detected_cuda + + cuda_runtime = typer.confirm( + "Download and install bundled CUDA runtime libraries?", + default=default_runtime_download, + ) + + return cuda_version, cuda_runtime + def _get_asset_urls(names: list[str], release_tag: Optional[str] = None) -> list[str]: import requests @@ -215,12 +228,7 @@ def _backup_config(gams_dir: str, installed_files: list[str]) -> None: def _run_interactive_install() -> None: gams_dir = _prompt_gams_dir() - cuda_version = _prompt_cuda_version() - - cuda_runtime = typer.confirm( - "Download and install bundled CUDA runtime libraries?", - default=False, - ) + cuda_version, cuda_runtime = _prompt_cuda_version() release = typer.prompt( "cuoptlink-builder release version", From 7b69c8afedaf3b951a9fc9e538bf7b10128bc5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schnabel?= Date: Thu, 20 Aug 2026 15:30:26 +0200 Subject: [PATCH 5/6] Add test after install --- fetch-cuoptlink.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/fetch-cuoptlink.py b/fetch-cuoptlink.py index a7b9c94..8b9f48f 100644 --- a/fetch-cuoptlink.py +++ b/fetch-cuoptlink.py @@ -4,6 +4,7 @@ import os import platform import shutil +import subprocess import tempfile import zipfile from typing import Optional, Tuple @@ -226,6 +227,32 @@ def _backup_config(gams_dir: str, installed_files: list[str]) -> None: typer.echo(f"Backed up original `{config_path}` to `{backup_path}`.") +def _test_installation(gams_dir: str) -> None: + typer.echo("Testing installation with GAMS trnsport model...") + gamslib_bin = os.path.join(gams_dir, "gamslib") + gams_bin = os.path.join(gams_dir, "gams") + + with tempfile.TemporaryDirectory() as temp_dir: + try: + # Fetch model 1 (trnsport) using gamslib + subprocess.run( + [gamslib_bin, "-q", "1"], + cwd=temp_dir, + check=True, + ) + + # Execute gams with cuopt solver + subprocess.run( + [gams_bin, "trnsport", "solver=cuopt", "lo=0"], + cwd=temp_dir, + check=True, + ) + typer.echo("Installation test completed successfully!") + except (subprocess.CalledProcessError, FileNotFoundError) as e: + typer.echo(f"Installation test failed: {e}") + raise typer.Exit(code=1) from e + + def _run_interactive_install() -> None: gams_dir = _prompt_gams_dir() cuda_version, cuda_runtime = _prompt_cuda_version() @@ -281,6 +308,8 @@ def _execute_install( _set_installed_files(gams_dir, sorted(files)) typer.echo(f"Successfully installed `{SOLVER_NAME}` into `{gams_dir}`.") + _test_installation(gams_dir) + def _execute_uninstall(gams_dir: str) -> None: _get_architecture() From fac48da44f827ca2ec30dff5dddad3e0bf4914b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schnabel?= Date: Thu, 20 Aug 2026 15:38:36 +0200 Subject: [PATCH 6/6] Document use of fetch script in README markdown --- README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 912436d..6e22a8c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,42 @@ Supported model types are LP, MIP, RMIP, QCP, MIQCP, RMIQCP. - **NVIDIA GPU:** Volta architecture or better - **CUDA Runtime Libraries:** 12 or 13 -## Getting started / installation +## Installation using `fetch-cuoptlink.py` + +You can automatically download, install, test, and manage the cuOpt solver link using the provided `fetch-cuoptlink.py` script. + +### Interactive Mode + +Running the script with no arguments launches an interactive prompt. It auto-detects your GAMS path (via `which gams`) and system CUDA version, prompting you for any missing options: + +```bash +python fetch-cuoptlink.py +``` + +Calling `uninstall` without additional options will interactively prompt for the GAMS directory path: + +```bash +python fetch-cuoptlink.py uninstall +``` + +### Non-interactive CLI Mode + +You can also pass command-line arguments to automate installation and uninstallation: + +```bash +# Basic installation using detected GAMS directory and CUDA runtime download +python fetch-cuoptlink.py install --gams-dir /opt/gams/gams55.0_linux_x64_64_sfx --cuda-runtime + +# Specify a CUDA version and release tag explicitly +python fetch-cuoptlink.py install -g /opt/gams/gams55.0 -c 12 -r v0.0.8 + +# Uninstall the solver link from a GAMS system directory +python fetch-cuoptlink.py uninstall -g /opt/gams/gams55.0 +``` + +> **Note:** Successful installations automatically verify the solver link by running the GAMS `trnsport` test model with `solver=cuopt`. + +## Manual installation - Make sure [CUDA runtime](https://developer.nvidia.com/cuda-downloads?target_os=Linux) is installed - Download and unpack `cuopt-link-release-cu12-{x86_64,arm64}.zip` or `cuopt-link-release-cu13-{x86_64,arm64}.zip` (for CUDA 12 and 13 respectively) from the [releases page](https://github.com/GAMS-dev/cuoptlink-builder/releases):