Packages

A lightweight Elixir interface to CmdStan, the command line interface to the Stan probabilistic programming language.

Current section

Files

Jump to
cmdstanex lib cmdstan downloader.ex
Raw

lib/cmdstan/downloader.ex

defmodule CmdStan.Downloader do
@moduledoc """
Handles downloading CmdStan releases from GitHub.
Provides functionality to download tar.gz files, handle retries,
and show progress during downloads.
"""
alias CmdStan.Platform
require Logger
@max_retries 6
# milliseconds
@retry_delay 1000
@doc """
Get the download URL for a CmdStan version.
## Examples
iex> CmdStan.Downloader.get_download_url("2.32.2")
"https://github.com/stan-dev/cmdstan/releases/download/v2.32.2/cmdstan-2.32.2.tar.gz"
"""
@spec get_download_url(String.t()) :: String.t()
def get_download_url(version) do
arch = Platform.final_arch()
url_end =
if arch && arch != "false" do
"v#{version}/cmdstan-#{version}-linux-#{arch}.tar.gz"
else
"v#{version}/cmdstan-#{version}.tar.gz"
end
"https://github.com/stan-dev/cmdstan/releases/download/#{url_end}"
end
@doc """
Get headers for GitHub API requests, including authorization if available.
"""
@spec get_headers(String.t() | nil) :: [{String.t(), String.t()}]
def get_headers(github_token \\ nil) do
token = github_token || System.get_env("GITHUB_TOKEN")
if token do
[{"Authorization", "token #{token}"}]
else
[]
end ++ [{"User-Agent", "CmdStan-Elixir"}]
end
@doc """
Get the latest CmdStan version from GitHub API.
## Examples
iex> CmdStan.Downloader.get_latest_version()
{:ok, "2.32.2"}
iex> CmdStan.Downloader.get_latest_version("your_token")
{:ok, "2.32.2"}
"""
@spec get_latest_version(String.t() | nil) :: {:ok, String.t()} | {:error, term()}
def get_latest_version(github_token \\ nil) do
url = "https://api.github.com/repos/stan-dev/cmdstan/releases/latest"
headers = get_headers(github_token)
retry_request(fn ->
case HTTPoison.get(url, headers, timeout: 10000, recv_timeout: 10000) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
case Jason.decode(body) do
{:ok, %{"tag_name" => tag_name}} ->
# Remove leading 'v' if present
version = String.trim_leading(tag_name, "v")
{:ok, version}
{:error, error} ->
{:error, {:json_decode, error}}
end
{:ok, %HTTPoison.Response{status_code: status_code, body: body}} ->
{:error, {:http_error, status_code, body}}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, {:network_error, reason}}
end
end)
end
@doc """
Download a CmdStan version to a temporary file.
Returns the path to the downloaded file.
## Examples
iex> config = %CmdStan.Config{progress: true}
iex> CmdStan.Downloader.download_release("2.32.2", config)
{:ok, "/tmp/cmdstan-2.32.2.tar.gz"}
"""
@spec download_release(String.t(), CmdStan.Config.t()) :: {:ok, String.t()} | {:error, term()}
def download_release(version, config) do
url = get_download_url(version)
headers = get_headers(config.github_token)
Logger.info("Downloading CmdStan version #{version} from #{url}")
# Get file size for progress bar if enabled
file_size =
if config.progress do
case get_file_size(url, headers) do
{:ok, size} -> size
{:error, _} -> nil
end
else
nil
end
# Create temporary file
temp_file = Path.join(System.tmp_dir!(), "cmdstan-#{version}.tar.gz")
retry_request(fn ->
download_with_progress(url, temp_file, headers, config.progress, file_size)
end)
end
@doc """
Clone a CmdStan git repository.
## Examples
iex> CmdStan.Downloader.clone_git("develop", "/path/to/cmdstan-develop")
:ok
"""
@spec clone_git(String.t(), String.t()) :: :ok | {:error, term()}
def clone_git(tag, dest_dir) do
Logger.info("Cloning CmdStan branch '#{tag}' from stan-dev/cmdstan on GitHub")
cmd = [
"git",
"clone",
"--depth",
"1",
"--branch",
tag,
"--recursive",
"--shallow-submodules",
"https://github.com/stan-dev/cmdstan.git",
dest_dir
]
case System.cmd("git", cmd, stderr_to_stdout: true) do
{output, 0} ->
Logger.info("Git clone successful")
:ok
{output, code} ->
Logger.error("Git clone failed with exit code #{code}: #{output}")
{:error, {:git_clone_failed, code, output}}
end
end
# Private functions
defp retry_request(fun, attempt \\ 1) do
case fun.() do
{:error, reason} when attempt < @max_retries ->
Logger.warning("Request failed (attempt #{attempt}/#{@max_retries}): #{inspect(reason)}")
Process.sleep(@retry_delay * attempt)
retry_request(fun, attempt + 1)
result ->
result
end
end
defp get_file_size(url, headers) do
case HTTPoison.head(url, headers) do
{:ok, %HTTPoison.Response{status_code: 200, headers: headers}} ->
case List.keyfind(headers, "Content-Length", 0) do
{"Content-Length", size_str} ->
case Integer.parse(size_str) do
{size, ""} -> {:ok, size}
_ -> {:error, :invalid_content_length}
end
nil ->
{:error, :no_content_length}
end
_ ->
{:error, :head_request_failed}
end
end
defp download_with_progress(url, dest_file, headers, show_progress?, file_size) do
# Use streaming download with progress bar if enabled
if show_progress? && file_size && file_size > 0 do
download_with_streaming_progress(url, dest_file, headers, file_size)
else
download_simple(url, dest_file, headers)
end
end
defp download_simple(url, dest_file, headers) do
case HTTPoison.get(url, headers, follow_redirect: true) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
File.write!(dest_file, body)
{:ok, dest_file}
{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, {:http_error, status_code}}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, {:network_error, reason}}
end
end
defp download_with_streaming_progress(url, dest_file, headers, file_size) do
filename = Path.basename(url)
progress = CmdStan.DownloadProgress.new(filename, file_size)
# Initial render
initial_bar = CmdStan.ProgressRenderer.render(progress)
IO.write("\r#{initial_bar}")
# Stream download with progress updates
stream_fun = fn
{:data, chunk}, acc ->
# Write chunk to file
File.write!(dest_file, chunk, [:append])
# Update progress
progress = CmdStan.DownloadProgress.update(acc.progress, byte_size(chunk))
# Render progress bar (throttled)
if CmdStan.DownloadProgress.should_update?(progress) do
bar = CmdStan.ProgressRenderer.render(progress)
IO.write("\r#{bar}")
end
%{acc | progress: progress}
other, acc ->
acc
end
initial_acc = %{progress: progress}
result = HTTPoison.get(url, headers, stream_to: stream_fun, follow_redirect: true)
# Final newline
final_progress = CmdStan.DownloadProgress.update(initial_acc.progress, 0)
final_bar = CmdStan.ProgressRenderer.render(final_progress)
IO.puts("\r#{final_bar}")
case result do
{:ok, %HTTPoison.Response{status_code: 200}} ->
{:ok, dest_file}
{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, {:http_error, status_code}}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, {:network_error, reason}}
end
end
end