Standalone scripts in any language#
Conda scripts let you write scripts in any language, with dependencies and instructions for running them included in the same file.
Experimental
This implements the draft conda-script proposal from issue #3751, which is on its way to becoming a CEP for the whole conda ecosystem.
Opt in with pixi config set experimental.conda-script true --global for now, and let us know on the issue how it goes.
Let's take this script written in R for example.
This is the most optimal use case for conda-script, R is meant for scripting, conda-forge features a wide selection of R libraries and unlike Python it doesn't have its own script syntax.
# /// conda-script
# channels = ["https://prefix.dev/conda-forge"]
# entrypoint = "Rscript ${SCRIPT}"
#
# [dependencies]
# r-base = "*"
# r-jsonlite = "*"
# /// end-conda-script
library(jsonlite)
document <- list(
name = "conda-script",
languages = c("r", "python"),
count = 2
)
writeLines(toJSON(document, auto_unbox = TRUE, pretty = TRUE))
Every conda script needs to declare the channels where the packages come from and the entrypoint describing how the script should be run.
Typically, you also want to add the toolchain of your language (r-base) and maybe a few dependencies (r-jsonlite) in order to make sure the script is self-contained.
In the script itself, we create a variable called document and then print it as JSON.
We can then run it with by executing the following command:
Pixi solves the dependencies, installs the environment into its cache and runs the entrypoint inside it.
More languages#
Ideally, dependencies also come from a conda channel. Here are examples for languages that have a great selection of libraries on conda-forge or other channels.
# /// conda-script
# channels = ["https://prefix.dev/conda-forge"]
# entrypoint = "python ${SCRIPT}"
#
# [dependencies]
# python = "*"
# pyyaml = "*"
# /// end-conda-script
import yaml
document = yaml.safe_load(
"""
name: conda-script
languages:
- python
- c
"""
)
document["count"] = len(document["languages"])
print(yaml.safe_dump(document, sort_keys=True), end="")
# /// conda-script
# channels = ["https://prefix.dev/conda-forge"]
# entrypoint = "Rscript ${SCRIPT}"
#
# [dependencies]
# r-base = "*"
# r-jsonlite = "*"
# /// end-conda-script
library(jsonlite)
document <- list(
name = "conda-script",
languages = c("r", "python"),
count = 2
)
writeLines(toJSON(document, auto_unbox = TRUE, pretty = TRUE))
// /// conda-script
// channels = ["https://prefix.dev/conda-forge"]
// entrypoint = "gcc -o ${CACHE}/main ${SCRIPT} $(pkg-config --cflags --libs glib-2.0) && ${CACHE}/main"
//
// [dependencies]
// gcc = "*"
// glib = "*"
// pkg-config = "*"
// /// end-conda-script
#include <glib.h>
int main(void) {
gchar *digest = g_compute_checksum_for_string(G_CHECKSUM_SHA256, "conda-script", -1);
g_print("sha256(\"conda-script\") = %s\n", digest);
g_free(digest);
return 0;
}
// /// conda-script
// channels = ["https://prefix.dev/conda-forge"]
// entrypoint = "g++ -o ${CACHE}/main ${SCRIPT} -lfmt && ${CACHE}/main"
//
// [dependencies]
// gxx = "*"
// fmt = "*"
// /// end-conda-script
#include <fmt/core.h>
#include <fmt/ranges.h>
#include <vector>
int main() {
std::vector<int> primes{2, 3, 5, 7, 11};
fmt::print("primes: {}\n", fmt::join(primes, ", "));
fmt::print("pi is roughly {:.3f}\n", 3.14159);
}
! /// conda-script
! channels = ["https://prefix.dev/conda-forge"]
! entrypoint = "gfortran -o ${CACHE}/main ${SCRIPT} -llapack -lblas && ${CACHE}/main"
!
! [dependencies]
! gfortran = "*"
! liblapack = "*"
! /// end-conda-script
program solve
implicit none
real(8) :: a(2, 2), b(2)
integer :: ipiv(2), info
a = reshape([2.0d0, 1.0d0, 1.0d0, 3.0d0], [2, 2])
b = [5.0d0, 10.0d0]
call dgesv(2, 1, a, 2, ipiv, b, 2, info)
write (*, '(a, i0)') 'info = ', info
write (*, '(a, 2f8.3)') 'x =', b
end program solve
# /// conda-script
# channels = [
# "https://prefix.dev/modular-community",
# "https://conda.modular.com/max",
# "https://prefix.dev/conda-forge",
# ]
# entrypoint = "mojo ${SCRIPT}"
#
# [dependencies]
# mojo = "*"
# emberjson = "*"
# /// end-conda-script
from emberjson import parse, to_string
def main() raises:
var document = parse(
'{"name": "conda-script", "languages": ["mojo", "python"], "count": 2}'
)
ref languages = document.object()["languages"].array()
print("languages:", len(languages), "first:", languages[0].string())
print(to_string[pretty=True](document))
Other languages work well with conda script, even though only the toolchain is available. That is because they allow to specify dependencies as part of the program.
// /// conda-script
// channels = ["https://prefix.dev/conda-forge"]
// entrypoint = "dotnet run ${SCRIPT}"
//
// [dependencies]
// dotnet = "*"
// /// end-conda-script
#:package Newtonsoft.Json@13.*
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
var payload = new JObject { ["item"] = "answer", ["value"] = 42 };
Console.WriteLine(payload.ToString(Formatting.None));
// /// conda-script
// channels = ["https://prefix.dev/conda-forge"]
// entrypoint = "kotlin ${SCRIPT}"
//
// [dependencies]
// kotlin = "*"
// /// end-conda-script
@file:DependsOn("com.google.code.gson:gson:2.13.1")
import com.google.gson.GsonBuilder
data class Language(val name: String, val year: Int)
val gson = GsonBuilder().setPrettyPrinting().create()
println(gson.toJson(Language("kotlin", 2011)))
// /// conda-script
// channels = ["https://prefix.dev/conda-forge"]
// entrypoint = "deno run ${SCRIPT}"
//
// [dependencies]
// deno = "*"
// /// end-conda-script
import { chunk } from "npm:lodash-es@4";
const pairs: string[][] = chunk(["a", "b", "c", "d"], 2);
console.log(JSON.stringify(pairs));
Creating a script#
pixi init --script <PATH> writes a runnable starting point, choosing the comment syntax, an entrypoint and the toolchain dependency from the file extension:
A Python file gets a PEP 723 block instead; --format conda-script overrides that default.
Unknown extensions error and list the supported ones.
The comment block#
The metadata lives in a comment block at the top of the file, written with your language's own line comments. The file therefore stays valid source code that editors, formatters and the language's own tooling keep understanding.
Open the block with /// conda-script, start every line with the same comment characters and close it with /// end-conda-script:
# /// conda-script
# channels = ["https://prefix.dev/conda-forge"]
# entrypoint = "Rscript ${SCRIPT}"
# /// end-conda-script
C uses // for the same block, Fortran !.
Any comment characters work as long as they contain no letters or digits, which rules out languages that spell their comments as a word like REM.
Block comments such as /* */ are not supported, and a file holds at most one block.
Inside the block you write TOML 1.1, so inline tables may span several lines.
Dependencies#
[dependencies] maps conda package names to matchspecs.
The string form is a version:
The table form supports version, build, build-number, channel, subdir, extras, flags, md5, sha256, url and when.
Platform specific dependencies use conditional dependencies with virtual packages:
[dependencies]
gcc = { version = "*", when = "__unix" }
vs2022_win-64 = { version = "*", when = "__win" }
The entrypoint#
entrypoint is the command that runs the script.
It is either a string or a table keyed by platform, where the most specific key wins:
entrypoint = {
unix = "cc -o ${CACHE}/main ${SCRIPT} && ${CACHE}/main",
win = "cl /Fe:${CACHE}/main.exe ${SCRIPT} && ${CACHE}/main.exe",
}
The command is not passed to a system shell.
Instead, Pixi runs a built-in shell so it behaves the same on every platform.
The following syntax is supported: whitespace splitting, single and double quotes, ${VAR} substitution, $(command) command substitution and && sequencing.
There are no pipes, redirects, globbing, ||, ;, subshells or environment variable assignments.
Two variables are defined:
${SCRIPT}: the absolute path of the script file.${CACHE}: a persistent per-script directory for build artifacts and other state that survives between runs.
Arguments after the script path are appended to the last command:
An argument list that starts with a flag needs -- in front, so pixi does not read the flag itself: pixi run --script main.R -- --verbose.
The entrypoint runs in the directory pixi run was invoked from, so relative paths passed to the script work.
Pixi-specific configuration#
Tables under [tool.*] belong to the named tool.
Pixi reads [tool.pixi] the same way as in a pyproject.toml, restricted to what one implicit environment needs.
PEP 723 scripts accept the same subset:
[tool.pixi.dependencies]holds conda specs in pixi's native syntax, including source dependencies, which need thepixi-buildpreview declared under[tool.pixi.workspace].[tool.pixi.pypi-dependencies]holds PyPI packages.[tool.pixi.constraints],[tool.pixi.activation]and[tool.pixi.target.<platform>]hold constraints, activation settings and platform-specific dependencies.[tool.pixi.exclude-newer]and[tool.pixi.pypi-exclude-newer]override the cutoff date per package.[tool.pixi.workspace]holds resolver options:platforms,channel-priority,solve-strategy,exclude-newer,conda-pypi-map,pypi-options,previewandrequires-pixi. Channels stay in the block'schannels, sotool.pixi.workspace.channelsis rejected.
A script without platforms resolves for the machine it runs on.
Declaring them pins the script to those platforms, which lets pixi lock --script produce a lock file for other machines.
Every other key is rejected with an error pointing at it: features, environments, tasks and package sections have no meaning in a single implicit environment.
The deprecated system-requirements table is rejected too, since a script either takes the virtual packages of the machine or declares them on its platforms.
[tool.pixi.dependencies] merges with [dependencies] the way pixi merges features: every spec applies.
A source dependency there composes with a version constraint in [dependencies], so tools that only implement the conda-script specification still see a solvable script:
[dependencies]
simple-app = "0.1.*"
[tool.pixi.workspace]
preview = ["pixi-build"]
[tool.pixi.dependencies]
simple-app = { git = "https://github.com/prefix-dev/pixi-build-testsuite.git", subdirectory = "tests/data/pixi_build/minimal-backend-workspaces/pixi-build-python" }
Managing dependencies#
The --script commands work on conda-script files the same way they work on PEP 723 scripts.
pixi add edits the block in place, preserving the comment prefix and the code around it:
$ pixi add --script main.c zlib # writes [dependencies]
$ pixi add --script main.py --pypi rich # writes [tool.pixi.pypi-dependencies]
pixi list --script, pixi tree --script and pixi update --script read and refresh the same environment.
Since the block cannot express platform-specific tables or git specs under [dependencies], pixi add rejects --platform and --git with a hint towards when conditions and [tool.pixi.dependencies].
Locking#
Running a script does not create a lock file; the resolution is cached internally. To pin the environment, write a lock file next to the script:
This creates main.c.pixi.lock, and a run uses the adjacent lock file whenever it exists, the same convention as for PEP 723 scripts.
Shebang#
Since the shebang line lies outside the block, a Unix script can make itself executable with env -S:
The same shebang works for a PEP 723 script, so both block kinds share it.