Single-file scripts that download their dependencies

An ideal distributable script is fully contained in a single file. It runs on any compatible operating system with an appropriate language runtime. It is plain text, and you can copy and paste it. It does not require mucking about with a package manager, or several, to run. It does not conflict with other scripts’ packages or require managing a project environment to avoid such conflicts.

The classic way to get around all of these issues with scripts is to limit yourself to using the scripting language’s standard library. However, programmers writing scripts don’t want to; they want to use libraries that do not come with the language by default. Some scripting languages, runtimes, and environments resolve this conflict by offering a means to download and cache a script’s dependencies with just declarations in the script itself. This page lists such languages, runtimes, and environments.

Contents

Anything with a Nix package

The Nix package manager can act as a #! interpreter and start another program with a list of dependencies available to it.

#! /usr/bin/env nix-shell
#! nix-shell -i python3 -p python310 python310Packages.ansicolors

from colors import bold

print(bold("Hello, world!".rjust(20, "-")))

C# (dotnet-script)

The unofficial .NET tool dotnet-script can download NuGet packages.

#! /usr/bin/env dotnet-script
#r "nuget: Spectre.Console, 0.46.0"

using Spectre.Console;

AnsiConsole.Markup("[bold]" + "Hello, world!".PadLeft(20, '-') + "[/]\n");

Clojure (Babashka)

The Babashka interpreter runs scripts in (a large subset of) Clojure. It has dynamic dependency resolution.

#! /usr/bin/env bb

(require '[babashka.deps :as deps])
(deps/add-deps '{:deps {coldnew/left-pad {:mvn/version "1.0.0"}}})

(require '[coldnew.left-pad :refer [leftpad]])
(print (leftpad "Hello, world!" 20 "-"))

Crystal (crun)

You can write scripts in Crystal with crun. It was inspired by gorun.

#! /usr/bin/env crun
# ---
# crinja:
#   github: straight-shoota/crinja
# ...

require "crinja"

puts Crinja.render(
  "{{ greeting }}!",
  {"greeting" => "Hello, world".rjust(19, '-')}
)

D

D’s official package manager DUB supports single-file packages.

#! /usr/bin/env dub
/+ dub.sdl:
dependency "colorize" version="~>1.0.5"
+/

import std.conv : to;
import std.range : padLeft;
import colorize : color, cwriteln, mode;

void main() {
    auto greeting = "Hello, world!".padLeft('-', 20).to!string;
    cwriteln(greeting.color(mode.bold));
}

Dhall

The configuration language Dhall has native support for remote imports. You can write shebang scripts in Dhall with env -S.

#! /usr/bin/env -S dhall text --file

let Text/replicate = https://prelude.dhall-lang.org/Text/replicate

in      Text/replicate 7 "-"
    ++  ''
        Hello, world!
        ''

Elixir

In Elixir 1.12 and later you can call Mix.install/2 from scripts.

#! /usr/bin/env elixir

Mix.install([
  {:leftpad, "~> 1.0"}
])

Leftpad.pad("Hello, world!", 20, ?-)
|> IO.puts()

F#

The .NET SDK has integrated support for F# scripts with dependencies.

#! /usr/bin/env -S dotnet fsi
#r "nuget: Spectre.Console, 0.45.0"

open Spectre.Console

AnsiConsole.Markup("[bold]" + "Hello, world!".PadLeft(20, '-') + "[/]\n")

Go (gorun)

gorun lets you embed go.mod and go.sum in the source file.

/// 2>/dev/null ; gorun "$0" "$@" ; exit $?
//
// go.mod >>>
// module foo
// go 1.18
// require github.com/keltia/leftpad v0.1.0
// <<< go.mod
//
// go.sum >>>
// github.com/keltia/leftpad v0.1.0 h1:b2jL8i1cRsuLITknjWZHRAwf1Jwh+twMUkhwDXD7fqc=
// github.com/keltia/leftpad v0.1.0/go.mod h1:f/6pIO5tikWLxSz68iAcl9OXDpV6k2+2mRm9jShMzoU=
// <<< go.sum

package main

import "fmt"
import "github.com/keltia/leftpad"

func main() {
    padded, _ := leftpad.PadChar("Hello, world!", 20, '-')
    fmt.Printf("%v\n", padded)
}

Groovy

Groovy comes with an embedded JAR dependency manager.

#! /usr/bin/env groovy
@Grab(group='org.apache.commons', module='commons-lang3', version='3.12.0')

import org.apache.commons.lang3.StringUtils

println StringUtils.leftPad('Hello, world!', 20, '-')

Haskell (Stack)

The Haskell build tool Stack has integrated support for scripts.

#! /usr/bin/env stack
-- stack --resolver lts-6.35 script --package acme-left-pad

import Data.Text.LeftPad

main :: IO ()
main = do
    putStrLn $ leftPad "Hello, world!" 20 "-"

Java (JBang)

JBang lets you write scripts in Java, Kotlin, and Groovy. It was inspired by kscript.

///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS org.apache.commons:commons-lang3:3.12.0

import static java.lang.System.*;
import org.apache.commons.lang3.StringUtils;

public class hello {
    public static void main(String... args) {
        out.println(StringUtils.leftPad("Hello, world!", 20, "-"));
    }
}

JavaScript

Bun

Bun resolves NPM dependencies in scripts.

#! /usr/bin/env bun

import leftPad from "left-pad";

console.log(leftPad("Hello, world!", 20, "-"));

Deno

Deno downloads dependencies like a browser. Deno 1.28 and later can also import from NPM packages. Current versions of Deno require you to pass a run argument to deno. One way to accomplish this from a script is with a form of “exec magic”. Here the magic is modified from a comment by Rafał Pocztarski.

#! /bin/sh
":" //#; exec /usr/bin/env deno run "$0" "$@"

import leftPad from "npm:left-pad";

console.log(leftPad("Hello, world!", 20, "-"));

On Linux systems with recent GNU env(1) and on FreeBSD you can replace the magic with env -S.

#! /usr/bin/env -S deno run

import leftPad from "npm:left-pad";

console.log(leftPad("Hello, world!", 20, "-"));

Kotlin (kscript)

kscript is an unofficial scripting tool for Kotlin that understands several comment-based directives, including one for dependencies. Since version 1.4 Kotlin also has experimental integrated scripting.

New versions of kscript do not allow a space after #!.

#!/usr/bin/env kscript
@file:DependsOn("org.apache.commons:commons-lang3:3.12.0")

import org.apache.commons.lang3.StringUtils

println(StringUtils.leftPad("Hello, world!", 20, "-"))

Python

fades

fades runs Python scripts with dependencies. It offers four ways to specify dependencies:

  • In comments, normally on imports;
  • With one or more command-line option -d or --dependency;
  • With the command-line option -r or --requirements and a requirements.txt file;
  • In the script docstring.

Here is the comments method:

#! /usr/bin/env fades

from colors import bold  # fades ansicolors>=1,<2

print(bold("Hello, world!".rjust(20, "-")))

The option -d with env -S:

#! /usr/bin/env -S fades -d ansicolors>=1,<2

from colors import bold

print(bold("Hello, world!".rjust(20, "-")))

A docstring:

#! /usr/bin/env fades
"""
fades:
    ansicolors>=1,<2
"""

from colors import bold

print(bold("Hello, world!".rjust(20, "-")))

pip-run

One feature of pip-run is running Python scripts with dependencies. The script can declare its dependencies

  • In the env -S shebang line (in the example);
  • Using the special variable __requires__;
  • In a commment (like the pipx example below).
#! /usr/bin/env -S pip-run ansicolors>=1,<2

from colors import bold

print(bold("Hello, world!".rjust(20, "-")))

pipx

The development version of pipx after the 1.2.0 release supports running scripts in addition to packages. You will once again need env -S to make the script run as a command.

#! /usr/bin/env -S pipx run
# Requirements:
#     ansicolors>=1,<2

from colors import bold

print(bold("Hello, world!".rjust(20, "-")))

If your system does not support env -S, you can instead use exec magic:

#! /bin/sh
"exec" "/usr/bin/env" "python3" "-m" "pipx" "run" "$0"

Comparison

Performance

pip-run recreates the virtualenv on every run of the script. This avoids the potential problem of virtualenv rot and saves some disk space but adds a noticeable delay to script startup. fades and pipx create a virtualenv for the script on the first run and caches it.

In September 2023 I compared the performance of pip-run v12.2.2 and pipx 1.2.0.1.dev0 (commit 271d0f1). I chose the Python example script with the # Requirements: line above as my benchmark. I measured the run time using the following fish shell command on Linux. time(1) was provided by GNU Time.

for i in (seq 10); command time -f '%e' ./test.py; end 2> times

The run times I got with pip-run were

1.36, 1.37, 1.36, 1.38, 1.36, 1.36, 1.37, 1.34, 1.36, 1.35

With pipx, they were

0.98, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12

pipx creating the virtualenv and installing the requirements in it (the package ansicolors) was what delayed the first run.

Tracebacks

pip-run and pipx produce somewhat different trackbacks when a script encounters an exception. pipx’s omits the filename of the script.

Let’s run a script that deliberately fails with an exception.

#! /usr/bin/env -S ...

import re


def fail():
    re.search(r"hello", None)


def main():
    fail()


if __name__ == "__main__":
    main()

Running the script with pipx run results in a traceback that does not include the script’s filename. It includes filenames for imports.

Traceback (most recent call last):
  File "<string>", line 15, in <module>
  File "<string>", line 11, in main
  File "<string>", line 7, in fail
  File "/home/dbohdan/.pyenv/versions/3.11.4/lib/python3.11/re/__init__.py", line 176, in search
    return _compile(pattern, flags).search(string)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: expected string or bytes-like object, got 'NoneType'

The fades traceback includes the filename, like you expect from a Python script.

Traceback (most recent call last):
  File "/tmp/./exception.py", line 15, in <module>
    main()
  File "/tmp/./exception.py", line 11, in main
    fail()
  File "/tmp/./exception.py", line 7, in fail
    re.search(r"hello", None)
  File "/home/dbohdan/.pyenv/versions/3.11.6/lib/python3.11/re/__init__.py", line 176, in search
    return _compile(pattern, flags).search(string)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: expected string or bytes-like object, got 'NoneType'

The pip-run traceback also includes the filename.

Traceback (most recent call last):
  File "/tmp/./exception.py", line 15, in <module>
    main()
  File "/tmp/./exception.py", line 11, in main
    fail()
  File "/tmp/./exception.py", line 7, in fail
    re.search(r"hello", None)
  File "/home/dbohdan/.pyenv/versions/3.11.4/lib/python3.11/re/__init__.py", line 176, in search
    return _compile(pattern, flags).search(string)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: expected string or bytes-like object, got 'NoneType'

Racket (Scripty)

Scripty interactively prompts you to install the missing dependencies for a script in any Racket language.

#! /usr/bin/env racket
#lang scripty
#:dependencies '("base" "typed-racket-lib" "left-pad")
------------------------------------------

#lang typed/racket/base

(require left-pad/typed)
(displayln (left-pad "Hello, world!" 20 "-"))

Ruby (Bundler)

The dependency manager Bundler has an API for single-file scripts.

#! /usr/bin/env ruby

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'left-pad', '~> 1.1'
end

puts 'Hello, world!'.leftpad(20, '-')

Rust (rust-script)

rust-script can evaluate expressions and run scripts in Rust.

#! /usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! leftpad-rs = "1.2.0"
//! ```

use leftpad_rs::pad_char;

fn main() {
    println!("{}", pad_char("Hello, world!", 20, '-').unwrap());
}

Scala

Ammonite

The scripting environment in Ammonite lets you import Ivy dependencies.

#! /usr/bin/env amm

import $ivy.`org.apache.commons:commons-lang3:3.12.0`,
  org.apache.commons.lang3.StringUtils

println(StringUtils.leftPad("Hello, world!", 20, "-"))

Scala CLI

Scala CLI is a newer tool that understands a subset of Ammonite’s imports.

#! /usr/bin/env scala-cli

import $ivy.`org.apache.commons:commons-lang3:3.12.0`,
  org.apache.commons.lang3.StringUtils

println(StringUtils.leftPad("Hello, world!", 20, "-"))

See also