Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-latest, windows-latest, windows-11-arm]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
exclude:
# CPython has no Windows ARM64 release before 3.11
- os: windows-11-arm
python-version: "3.10"
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
steps:
Expand Down Expand Up @@ -80,21 +84,21 @@ jobs:
uses: actions/cache@v6
with:
path: ~/.cmdstan
key: ${{ runner.os }}-cmdstan-${{ needs.get-cmdstan-version.outputs.version }}-${{ hashFiles('**/install_cmdstan.py') }}
key: ${{ runner.os }}-${{ runner.arch }}-cmdstan-${{ needs.get-cmdstan-version.outputs.version }}-${{ hashFiles('**/install_cmdstan.py') }}

- name: Delete precompiled header (MacOS)
if: matrix.os == 'macos-latest' && steps.cache-cmdstan.outputs.cache-hit == 'true'
run: rm -rf ~/.cmdstan/cmdstan-${{ needs.get-cmdstan-version.outputs.version }}/stan/src/stan/model/*.hpp.gch

- name: Install CmdStan (Linux, macOS)
if: matrix.os != 'windows-latest'
if: runner.os != 'Windows'
run: |
install_cmdstan -h
install_cxx_toolchain -h
python -c "import cmdstanpy; cmdstanpy.install_cmdstan(version='${{ needs.get-cmdstan-version.outputs.version }}', cores=4)"

- name: Install CmdStan (Windows)
if: matrix.os == 'windows-latest'
if: runner.os == 'Windows'
run: |
install_cmdstan -h
install_cxx_toolchain -h
Expand Down
156 changes: 156 additions & 0 deletions .github/workflows/windows-toolchain.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Windows toolchain

# Exercises the RTools install + discovery path on real Windows runners.
# Kept out of the main matrix because the RTools 4.4/4.5 installers are ~450MB.

on:
workflow_dispatch:
pull_request:
paths:
- "cmdstanpy/install_cxx_toolchain.py"
- "cmdstanpy/utils/cmdstan.py"
- "cmdstanpy/install_cmdstan.py"
- "test/test_cxx_installation.py"
- ".github/workflows/windows-toolchain.yml"

jobs:
# Fast, hardware-independent: the layout table is exercised with fake trees.
unit:
name: layout unit tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, windows-11-arm]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: python -m pip install --upgrade pip
- run: python -m pip install .[test]
- run: python -m pytest test/test_cxx_installation.py -v

install:
name: RTools ${{ matrix.rtools }} on ${{ matrix.arch }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
arch: x86_64
machine: x86-64
rtools: "4.0"
- os: windows-latest
arch: x86_64
machine: x86-64
rtools: "4.4"
- os: windows-latest
arch: x86_64
machine: x86-64
rtools: "4.5"
- os: windows-11-arm
arch: aarch64
machine: ARM64
rtools: "4.4"
- os: windows-11-arm
arch: aarch64
machine: ARM64
rtools: "4.5"
steps:
- uses: actions/checkout@v7

- uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Install package
run: |
python -m pip install --upgrade pip
python -m pip install .

- name: Report detected architecture
shell: python
run: |
import platform
from cmdstanpy.utils.cmdstan import determine_windows_arch

arch = determine_windows_arch()
print(platform.machine(), "->", arch)
assert arch == "${{ matrix.arch }}", arch

# The runner images ship their own RTools; make sure discovery only sees
# what this job installed.
- name: Clear pre-existing RTools environment
run: |
"RTOOLS40_HOME=" >> $env:GITHUB_ENV
"RTOOLS42_HOME=" >> $env:GITHUB_ENV
"RTOOLS43_HOME=" >> $env:GITHUB_ENV
"RTOOLS44_HOME=" >> $env:GITHUB_ENV
"RTOOLS45_HOME=" >> $env:GITHUB_ENV

- name: Install RTools ${{ matrix.rtools }}
run: |
python -m cmdstanpy.install_cxx_toolchain --version ${{ matrix.rtools }} --dir ${{ runner.temp }}\toolchain --verbose

- name: Discover and activate toolchain
shell: python
run: |
import os, shutil, subprocess
from cmdstanpy.install_cxx_toolchain import get_toolchain_version, is_installed
from cmdstanpy.utils import cxx_toolchain_path
from cmdstanpy.utils.cmdstan import make_command

install_dir = os.path.join(os.environ["RUNNER_TEMP"], "toolchain")
version = "${{ matrix.rtools }}"
folder = get_toolchain_version("RTools", version)
print("expecting install folder:", folder)
assert is_installed(os.path.join(install_dir, folder), version)

compiler_path, tool_path = cxx_toolchain_path(version, install_dir)
print("compiler:", compiler_path)
print("tools: ", tool_path)
assert compiler_path.startswith(install_dir), compiler_path

# CmdStan invokes g++; on ARM64 it is an alias for clang
cxx = shutil.which("g++")
assert cxx and cxx.startswith(compiler_path), cxx

make = make_command()
print("make:", make, "->", shutil.which(make))
assert shutil.which(make), f"{make} not found on PATH"
subprocess.run([make, "--version"], check=True)

with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as f:
f.write(f"CXX_BIN={compiler_path}\n")
f.write(f"TOOL_BIN={tool_path}\n")

- name: Compile and run a trivial C++ program
shell: pwsh
run: |
#Requires -Version 7.4
$PSNativeCommandUseErrorActionPreference = $true
$cxx = Join-Path $env:CXX_BIN 'g++.exe'

& $cxx --version
$triple = (& $cxx -dumpmachine).Trim()
Write-Host "target triple: $triple"
if (-not $triple.StartsWith('${{ matrix.arch }}')) {
throw "expected a ${{ matrix.arch }} target, got '$triple'"
}

Set-Content hello.cpp @(
'#include <cstdint>',
'#include <iostream>',
'int main() { std::cout << "hello" << std::endl; return 0; }'
)
& $cxx -O0 hello.cpp -o hello.exe
./hello.exe

# file(1) ships in RTools' usr/bin; proves what was actually emitted
$described = & (Join-Path $env:TOOL_BIN 'file.exe') hello.exe
Write-Host $described
if ($described -notlike '*${{ matrix.machine }}*') {
throw "expected a ${{ matrix.machine }} binary, got '$described'"
}
20 changes: 12 additions & 8 deletions cmdstanpy/compilation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
import io
import json
import os
import platform
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable

from cmdstanpy.utils import get_logger
from cmdstanpy.utils.cmdstan import EXTENSION, cmdstan_path, stanc_path
from cmdstanpy.utils.cmdstan import (
EXTENSION,
cmdstan_path,
make_command,
stanc_path,
)
from cmdstanpy.utils.command import do_command
from cmdstanpy.utils.filesystem import SanitizedOrTmpFilePath

Expand Down Expand Up @@ -195,8 +199,11 @@ def validate_user_header(self) -> None:
)
if "allow-undefined" not in self._stanc_options:
self._stanc_options["allow-undefined"] = True
# set full path
self._user_header = os.path.abspath(self._user_header)
# CmdStan's make/program does not apply its usual
# $(subst \,/,...) to USER_HEADER, and Stan Math sets SHELL on
# Windows, so recipes run through sh, which eats the backslashes
# before the compiler sees the -include path
self._user_header = Path(self._user_header).absolute().as_posix()

if ' ' in self._user_header:
raise ValueError(
Expand Down Expand Up @@ -372,10 +379,7 @@ def compile_stan_file(
exe_target,
)

make = os.getenv(
'MAKE',
'make' if platform.system() != 'Windows' else 'mingw32-make',
)
make = make_command()
cmd = [make]
cmd.extend(compiler_options.compose(filename_in_msg=src.name))
cmd.append(Path(exe_file).as_posix())
Expand Down
57 changes: 22 additions & 35 deletions cmdstanpy/install_cmdstan.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
validate_dir,
wrap_url_progress_hook,
)
from cmdstanpy.utils.cmdstan import get_download_url
from cmdstanpy.utils.cmdstan import get_download_url, make_command

from . import progress as progbar

Expand All @@ -71,7 +71,6 @@ def is_windows() -> bool:
return platform.system() == 'Windows'


MAKE = os.getenv('MAKE', 'make' if not is_windows() else 'mingw32-make')
EXTENSION = '.exe' if is_windows() else ''


Expand Down Expand Up @@ -203,7 +202,7 @@ def overwrite(self) -> bool:
def compiler(self) -> bool:
if not is_windows():
return False
print("Would you like to install the RTools40 C++ toolchain?")
print("Would you like to install the RTools C++ toolchain?")
print("A C++ toolchain is required for CmdStan.")
print(
"If you are not sure if you need the toolchain or not, "
Expand Down Expand Up @@ -234,7 +233,7 @@ def clean_all(verbose: bool = False) -> None:

:param verbose: Boolean value; when ``True``, show output from make command.
"""
cmd = [MAKE, 'clean-all']
cmd = [make_command(), 'clean-all']
try:
if verbose:
do_command(cmd)
Expand Down Expand Up @@ -262,7 +261,7 @@ def build(verbose: bool = False, progress: bool = True, cores: int = 1) -> None:
:param cores: Integer, number of cores to use in the ``make`` command.
Default is 1 core.
"""
cmd = [MAKE, 'build', f'-j{cores}']
cmd = [make_command(), 'build', f'-j{cores}']
try:
if verbose:
do_command(cmd)
Expand Down Expand Up @@ -343,7 +342,7 @@ def compile_example(verbose: bool = False) -> None:
if path.is_file():
path.unlink()

cmd = [MAKE, path.as_posix()]
cmd = [make_command(), path.as_posix()]
try:
if verbose:
do_command(cmd)
Expand Down Expand Up @@ -544,37 +543,26 @@ def retrieve_version(version: str, progress: bool = True) -> None:


def run_compiler_install(dir: str, verbose: bool, progress: bool) -> None:
from .install_cxx_toolchain import is_installed as _is_installed_cxx
from .install_cxx_toolchain import latest_version as _latest_version_cxx
from .install_cxx_toolchain import run_rtools_install as _main_cxx
from .utils import cxx_toolchain_path

compiler_found = False
rtools40_home = os.environ.get('RTOOLS40_HOME')
for cxx_loc in ([rtools40_home] if rtools40_home is not None else []) + [
home_cmdstan(),
os.path.join(os.path.abspath("/"), "RTools40"),
os.path.join(os.path.abspath("/"), "RTools"),
os.path.join(os.path.abspath("/"), "RTools35"),
os.path.join(os.path.abspath("/"), "RBuildTools"),
]:
for cxx_version in ['40', '35']:
if _is_installed_cxx(cxx_loc, cxx_version):
compiler_found = True
break
if compiler_found:
break
if not compiler_found:
print('Installing RTools40')
# copy argv and clear sys.argv
_main_cxx(
{
'dir': dir,
'progress': progress,
'version': None,
'verbose': verbose,
}
)
cxx_version = '40'
try:
cxx_toolchain_path(None, dir)
return
except ValueError:
pass

cxx_version = _latest_version_cxx()
print(f'Installing RTools {cxx_version}')
_main_cxx(
{
'dir': dir,
'progress': progress,
'version': None,
'verbose': verbose,
}
)
# Add toolchain to $PATH
cxx_toolchain_path(cxx_version, dir)

Expand Down Expand Up @@ -676,7 +664,6 @@ def parse_cmdline_args() -> dict[str, Any]:
if is_windows():
# use compiler installed with install_cxx_toolchain
# Install a new compiler if compiler not found
# Search order is RTools40, RTools35
parser.add_argument(
'--compiler',
'-c',
Expand Down
Loading
Loading