Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@ config :pythonx, :uv_init,
"""
```

Additionally, you may configure an explicit version of the uv package manager to use with Pythonx.
```elixir
import Config

config :pythonx, :uv_init,
pyproject_toml: """
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = [
"numpy==2.2.2"
]
""",
uv_version: "0.7.21"
```

With that, you can use `Pythonx.eval/2` and other APIs in your
application. The downloads will happen at compile time, and the
interpreter will get initialized automatically on boot. All necessary
Expand Down
8 changes: 5 additions & 3 deletions lib/pythonx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defmodule Pythonx do

@type encoder :: (term(), encoder() -> Object.t())

@doc ~S'''
@doc ~s'''
Installs Python and dependencies using [uv](https://docs.astral.sh/uv)
package manager and initializes the interpreter.

Expand Down Expand Up @@ -48,14 +48,16 @@ defmodule Pythonx do
## Options

* `:force` - if true, runs with empty project cache. Defaults to `false`.
* `:uv_version` - select the version of the uv package manager to use.
Defaults to `#{inspect(Pythonx.Uv.default_uv_version())}`.

'''
@spec uv_init(String.t(), keyword()) :: :ok
def uv_init(pyproject_toml, opts \\ []) when is_binary(pyproject_toml) and is_list(opts) do
opts = Keyword.validate!(opts, force: false)
opts = Keyword.validate!(opts, force: false, uv_version: Pythonx.Uv.default_uv_version())

Pythonx.Uv.fetch(pyproject_toml, false, opts)
Pythonx.Uv.init(pyproject_toml, false)
Pythonx.Uv.init(pyproject_toml, false, Keyword.take(opts, [:uv_version]))
end

# Initializes the Python interpreter.
Expand Down
11 changes: 8 additions & 3 deletions lib/pythonx/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,14 @@ defmodule Pythonx.Application do

# If configured, we fetch Python and dependencies at compile time
# and we automatically initialize the interpreter on boot.
if pyproject_toml = Application.compile_env(:pythonx, :uv_init)[:pyproject_toml] do
Pythonx.Uv.fetch(pyproject_toml, true)
defp maybe_uv_init(), do: Pythonx.Uv.init(unquote(pyproject_toml), true)
uv_init_env = Application.compile_env(:pythonx, :uv_init)
pyproject_toml = uv_init_env[:pyproject_toml]
uv_version = uv_init_env[:uv_version] || Pythonx.Uv.default_uv_version()
opts = [uv_version: uv_version]

if pyproject_toml do
Pythonx.Uv.fetch(pyproject_toml, true, opts)
defp maybe_uv_init(), do: Pythonx.Uv.init(unquote(pyproject_toml), true, unquote(opts))
else
defp maybe_uv_init(), do: :noop
end
Expand Down
45 changes: 24 additions & 21 deletions lib/pythonx/uv.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ defmodule Pythonx.Uv do

require Logger

@uv_version "0.5.21"
def default_uv_version(), do: "0.8.5"

@doc """
Fetches Python and dependencies based on the given configuration.
"""
@spec fetch(String.t(), boolean(), keyword()) :: :ok
def fetch(pyproject_toml, priv?, opts \\ []) do
opts = Keyword.validate!(opts, force: false)
opts = Keyword.validate!(opts, force: false, uv_version: default_uv_version())

project_dir = project_dir(pyproject_toml, priv?)
python_install_dir = python_install_dir(priv?)
project_dir = project_dir(pyproject_toml, priv?, opts[:uv_version])
python_install_dir = python_install_dir(priv?, opts[:uv_version])

if opts[:force] || priv? do
_ = File.rm_rf(project_dir)
Expand All @@ -30,7 +30,8 @@ defmodule Pythonx.Uv do
# We always use uv-managed Python, so the paths are predictable.
if run!(["sync", "--python-preference", "only-managed"],
cd: project_dir,
env: %{"UV_PYTHON_INSTALL_DIR" => python_install_dir}
env: %{"UV_PYTHON_INSTALL_DIR" => python_install_dir},
uv_version: opts[:uv_version]
) != 0 do
_ = File.rm_rf(project_dir)
raise "fetching Python and dependencies failed, see standard output for details"
Expand All @@ -40,15 +41,15 @@ defmodule Pythonx.Uv do
:ok
end

defp python_install_dir(priv?) do
defp python_install_dir(priv?, uv_version) do
if priv? do
Path.join(:code.priv_dir(:pythonx), "uv/python")
else
Path.join(cache_dir(), "python")
Path.join(cache_dir(uv_version), "python")
end
end

defp project_dir(pyproject_toml, priv?) do
defp project_dir(pyproject_toml, priv?, uv_version) do
if priv? do
Path.join(:code.priv_dir(:pythonx), "uv/project")
else
Expand All @@ -57,7 +58,7 @@ defmodule Pythonx.Uv do
|> :erlang.md5()
|> Base.encode32(case: :lower, padding: false)

Path.join([cache_dir(), "projects", cache_id])
Path.join([cache_dir(uv_version), "projects", cache_id])
end
end

Expand All @@ -66,8 +67,9 @@ defmodule Pythonx.Uv do
fetched by `fetch/3`.
"""
@spec init(String.t(), boolean()) :: :ok
def init(pyproject_toml, priv?) do
project_dir = project_dir(pyproject_toml, priv?)
def init(pyproject_toml, priv?, opts \\ []) do
opts = Keyword.validate!(opts, uv_version: default_uv_version())
project_dir = project_dir(pyproject_toml, priv?, opts[:uv_version])

# Uv stores Python installations in versioned directories in the
# Python install dir. To find the versioned name for this project,
Expand All @@ -91,7 +93,7 @@ defmodule Pythonx.Uv do
{:unix, _osname} -> Path.basename(Path.dirname(abs_executable_dir))
end

root_dir = Path.join(python_install_dir(priv?), versioned_dir_name)
root_dir = Path.join(python_install_dir(priv?, opts[:uv_version]), versioned_dir_name)

case :os.type() do
{:win32, _osname} ->
Expand Down Expand Up @@ -158,10 +160,11 @@ defmodule Pythonx.Uv do
defp make_windows_slashes(path), do: String.replace(path, "/", "\\")

defp run!(args, opts) do
path = uv_path()
{uv_version, opts} = Keyword.pop(opts, :uv_version, default_uv_version())
path = uv_path(uv_version)

if not File.exists?(path) do
download!()
download!(uv_version)
end

{_stream, status} =
Expand All @@ -170,31 +173,31 @@ defmodule Pythonx.Uv do
status
end

defp uv_path() do
Path.join([cache_dir(), "bin", "uv"])
defp uv_path(uv_version) do
Path.join([cache_dir(uv_version), "bin", "uv"])
end

@version Mix.Project.config()[:version]

defp cache_dir() do
defp cache_dir(uv_version) do
base_dir =
if dir = System.get_env("PYTHONX_CACHE_DIR") do
Path.expand(dir)
else
:filename.basedir(:user_cache, "pythonx")
end

Path.join([base_dir, @version, "uv", @uv_version])
Path.join([base_dir, @version, "uv", uv_version])
end

defp download!() do
defp download!(uv_version) do
{archive_type, archive_name} = archive_name()

url = "https://github.com/astral-sh/uv/releases/download/#{@uv_version}/#{archive_name}"
url = "https://github.com/astral-sh/uv/releases/download/#{uv_version}/#{archive_name}"
Logger.debug("Downloading uv archive from #{url}")
archive_binary = Pythonx.Utils.fetch_body!(url)

path = uv_path()
path = uv_path(uv_version)
{:ok, uv_binary} = extract_executable(archive_type, archive_binary)
File.mkdir_p!(Path.dirname(path))
File.write!(path, uv_binary)
Expand Down