Skip to content

Commit 0cd9ff0

Browse files
authored
Merge pull request #163 from patrickcrocker/feature/uv-tool
feat: add uv-tool feature for installing Python tools via uv
2 parents 906455d + 0988482 commit 0cd9ff0

9 files changed

+394
-0
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"id": "uv-tool",
3+
"version": "1.0.0",
4+
"name": "Python tool (via uv)",
5+
"documentationURL": "http://github.com/devcontainers-extra/features/tree/main/src/uv-tool",
6+
"description": "Installs a Python tool using uv.",
7+
"options": {
8+
"uv_version": {
9+
"default": "latest",
10+
"description": "The version of uv to install. Set to 'latest' to always get the latest version.",
11+
"type": "string"
12+
},
13+
"package": {
14+
"default": "",
15+
"description": "The package to install commands from.",
16+
"type": "string"
17+
},
18+
"from": {
19+
"default": "",
20+
"description": "Use the given package to provide the command. By default, the package name is assumed to match the command name.",
21+
"type": "string"
22+
},
23+
"with": {
24+
"default": "",
25+
"description": "Space-separated list of additional packages to include as dependencies (does not install their executables). Supports version constraints, e.g., 'mkdocs-material mkdocs-minify-plugin>=0.7,<0.8'",
26+
"type": "string"
27+
},
28+
"with-executables-from": {
29+
"default": "",
30+
"description": "Comma-separated list of packages to include as dependencies and install their executables, e.g., 'ansible-core,ansible-lint'",
31+
"type": "string"
32+
},
33+
"python": {
34+
"default": "",
35+
"description": "Specify the Python version to use.",
36+
"type": "string"
37+
}
38+
},
39+
"installsAfter": [
40+
"ghcr.io/devcontainers-extra/features/uv"
41+
]
42+
}

src/uv-tool/install.sh

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
UV_VERSION="${UV_VERSION:-"latest"}"
6+
PACKAGE="${PACKAGE:-""}"
7+
FROM="${FROM:-""}"
8+
WITH="${WITH:-""}"
9+
WITH_EXECUTABLES_FROM="${WITH_EXECUTABLES_FROM:-""}"
10+
PYTHON="${PYTHON:-""}"
11+
12+
USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}"
13+
14+
# Validate required parameters
15+
if [ -z "${PACKAGE}" ]; then
16+
echo "Error: PACKAGE option is required"
17+
exit 1
18+
fi
19+
20+
source ./library_scripts.sh
21+
22+
# nanolayer is a cli utility which keeps container layers as small as possible
23+
# source code: https://github.com/devcontainers-extra/nanolayer
24+
# `ensure_nanolayer` is a bash function that will find any existing nanolayer installations,
25+
# and if missing - will download a temporary copy that automatically get deleted at the end
26+
# of the script
27+
ensure_nanolayer nanolayer_location "v0.5.6"
28+
29+
$nanolayer_location \
30+
install \
31+
devcontainer-feature \
32+
"ghcr.io/devcontainers-extra/features/uv:1" \
33+
--option version="$UV_VERSION"
34+
35+
# Determine the appropriate non-root user
36+
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
37+
USERNAME=""
38+
possible_users=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
39+
for current_user in "${possible_users[@]}"; do
40+
if id -u "${current_user}" > /dev/null 2>&1; then
41+
USERNAME=${current_user}
42+
break
43+
fi
44+
done
45+
if [ "${USERNAME}" = "" ]; then
46+
USERNAME=root
47+
fi
48+
elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" > /dev/null 2>&1; then
49+
USERNAME=root
50+
fi
51+
52+
uv_args=("${PACKAGE}")
53+
54+
[ -n "${FROM}" ] && uv_args+=(--from "${FROM}")
55+
[ -n "${WITH_EXECUTABLES_FROM}" ] && uv_args+=(--with-executables-from "${WITH_EXECUTABLES_FROM}")
56+
[ -n "${PYTHON}" ] && uv_args+=(--python "${PYTHON}")
57+
58+
# Convert space-delimited WITH packages to multiple --with flags
59+
if [ -n "${WITH}" ]; then
60+
read -ra with_array <<<"${WITH}"
61+
for pkg in "${with_array[@]}"; do
62+
[ -n "$pkg" ] && uv_args+=(--with "$pkg")
63+
done
64+
fi
65+
66+
if [ "${USERNAME}" != "root" ] && [ "${USERNAME}" != "" ]; then
67+
echo "Installing ${PACKAGE} for user ${USERNAME}..."
68+
# Export the uv_args array as a properly quoted string
69+
uv_cmd="uv tool install $(printf '%q ' "${uv_args[@]}")"
70+
sudo -u "${USERNAME}" bash -c "
71+
export PATH=\"/home/${USERNAME}/.local/bin:\$PATH\"
72+
${uv_cmd}
73+
"
74+
else
75+
echo "Installing ${PACKAGE} for root..."
76+
uv tool install "${uv_args[@]}"
77+
fi
78+
79+
echo 'Done!'

src/uv-tool/library_scripts.sh

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env bash
2+
3+
clean_download() {
4+
# The purpose of this function is to download a file with minimal impact on container layer size
5+
# this means if no valid downloader is found (curl or wget) then we install a downloader (currently wget) in a
6+
# temporary manner, and making sure to
7+
# 1. uninstall the downloader at the return of the function
8+
# 2. revert back any changes to the package installer database/cache (for example apt-get lists)
9+
# The above steps will minimize the leftovers being created while installing the downloader
10+
# Supported distros:
11+
# debian/ubuntu/alpine
12+
13+
url=$1
14+
output_location=$2
15+
tempdir=$(mktemp -d)
16+
downloader_installed=""
17+
18+
function _apt_get_install() {
19+
tempdir=$1
20+
21+
# copy current state of apt list - in order to revert back later (minimize contianer layer size)
22+
cp -p -R /var/lib/apt/lists $tempdir
23+
apt-get update -y
24+
apt-get -y install --no-install-recommends wget ca-certificates
25+
}
26+
27+
function _apt_get_cleanup() {
28+
tempdir=$1
29+
30+
echo "removing wget"
31+
apt-get -y purge wget --auto-remove
32+
33+
echo "revert back apt lists"
34+
rm -rf /var/lib/apt/lists/*
35+
rm -r /var/lib/apt/lists && mv $tempdir/lists /var/lib/apt/lists
36+
}
37+
38+
function _apk_install() {
39+
tempdir=$1
40+
# copy current state of apk cache - in order to revert back later (minimize contianer layer size)
41+
cp -p -R /var/cache/apk $tempdir
42+
43+
apk add --no-cache wget
44+
}
45+
46+
function _apk_cleanup() {
47+
tempdir=$1
48+
49+
echo "removing wget"
50+
apk del wget
51+
}
52+
# try to use either wget or curl if one of them already installer
53+
if type curl >/dev/null 2>&1; then
54+
downloader=curl
55+
elif type wget >/dev/null 2>&1; then
56+
downloader=wget
57+
else
58+
downloader=""
59+
fi
60+
61+
# in case none of them is installed, install wget temporarly
62+
if [ -z $downloader ]; then
63+
if [ -x "/usr/bin/apt-get" ]; then
64+
_apt_get_install $tempdir
65+
elif [ -x "/sbin/apk" ]; then
66+
_apk_install $tempdir
67+
else
68+
echo "distro not supported"
69+
exit 1
70+
fi
71+
downloader="wget"
72+
downloader_installed="true"
73+
fi
74+
75+
if [ $downloader = "wget" ]; then
76+
wget -q $url -O $output_location
77+
else
78+
curl -sfL $url -o $output_location
79+
fi
80+
81+
# NOTE: the cleanup procedure was not implemented using `trap X RETURN` only because
82+
# alpine lack bash, and RETURN is not a valid signal under sh shell
83+
if ! [ -z $downloader_installed ]; then
84+
if [ -x "/usr/bin/apt-get" ]; then
85+
_apt_get_cleanup $tempdir
86+
elif [ -x "/sbin/apk" ]; then
87+
_apk_cleanup $tempdir
88+
else
89+
echo "distro not supported"
90+
exit 1
91+
fi
92+
fi
93+
94+
}
95+
96+
ensure_nanolayer() {
97+
# Ensure existance of the nanolayer cli program
98+
local variable_name=$1
99+
100+
local required_version=$2
101+
# normalize version
102+
if ! [[ $required_version == v* ]]; then
103+
required_version=v$required_version
104+
fi
105+
106+
local nanolayer_location=""
107+
108+
# If possible - try to use an already installed nanolayer
109+
if [[ -z "${NANOLAYER_FORCE_CLI_INSTALLATION}" ]]; then
110+
if [[ -z "${NANOLAYER_CLI_LOCATION}" ]]; then
111+
if type nanolayer >/dev/null 2>&1; then
112+
echo "Found a pre-existing nanolayer in PATH"
113+
nanolayer_location=nanolayer
114+
fi
115+
elif [ -f "${NANOLAYER_CLI_LOCATION}" ] && [ -x "${NANOLAYER_CLI_LOCATION}" ]; then
116+
nanolayer_location=${NANOLAYER_CLI_LOCATION}
117+
echo "Found a pre-existing nanolayer which were given in env variable: $nanolayer_location"
118+
fi
119+
120+
# make sure its of the required version
121+
if ! [[ -z "${nanolayer_location}" ]]; then
122+
local current_version
123+
current_version=$($nanolayer_location --version)
124+
if ! [[ $current_version == v* ]]; then
125+
current_version=v$current_version
126+
fi
127+
128+
if ! [ $current_version == $required_version ]; then
129+
echo "skipping usage of pre-existing nanolayer. (required version $required_version does not match existing version $current_version)"
130+
nanolayer_location=""
131+
fi
132+
fi
133+
134+
fi
135+
136+
# If not previuse installation found, download it temporarly and delete at the end of the script
137+
if [[ -z "${nanolayer_location}" ]]; then
138+
139+
if [ "$(uname -sm)" == "Linux x86_64" ] || [ "$(uname -sm)" == "Linux aarch64" ]; then
140+
tmp_dir=$(mktemp -d -t nanolayer-XXXXXXXXXX)
141+
142+
clean_up() {
143+
ARG=$?
144+
rm -rf $tmp_dir
145+
exit $ARG
146+
}
147+
trap clean_up EXIT
148+
149+
if [ -x "/sbin/apk" ]; then
150+
clib_type=musl
151+
else
152+
clib_type=gnu
153+
fi
154+
155+
tar_filename=nanolayer-"$(uname -m)"-unknown-linux-$clib_type.tgz
156+
157+
# clean download will minimize leftover in case a downloaderlike wget or curl need to be installed
158+
clean_download https://github.com/devcontainers-extra/nanolayer/releases/download/$required_version/$tar_filename $tmp_dir/$tar_filename
159+
160+
tar xfzv $tmp_dir/$tar_filename -C "$tmp_dir"
161+
chmod a+x $tmp_dir/nanolayer
162+
nanolayer_location=$tmp_dir/nanolayer
163+
164+
else
165+
echo "No binaries compiled for non-x86-linux architectures yet: $(uname -m)"
166+
exit 1
167+
fi
168+
fi
169+
170+
# Expose outside the resolved location
171+
declare -g ${variable_name}=$nanolayer_location
172+
173+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
source dev-container-features-test-lib
6+
7+
check "httpie is installed" http --version
8+
9+
reportResults
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
source dev-container-features-test-lib
6+
7+
check "mkdocs is installed" mkdocs --version
8+
check "mkdocs-material theme is available" sh -c "cd $(uv tool dir)/mkdocs && uv pip list | grep mkdocs-material"
9+
check "mkdocs-minify-plugin is available" sh -c "cd $(uv tool dir)/mkdocs && uv pip list | grep mkdocs-minify-plugin"
10+
11+
reportResults
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
source dev-container-features-test-lib
6+
7+
check "uv tool version" sh -c "uv --version | grep 0.9.6"
8+
check "httpie is installed" http --version
9+
10+
reportResults
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
source dev-container-features-test-lib
6+
7+
check "mkdocs-material theme is available" sh -c "cd $(uv tool dir)/mkdocs && uv pip list | grep mkdocs-material"
8+
9+
reportResults
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
source dev-container-features-test-lib
6+
7+
check "ansible is installed" ansible --version
8+
check "ansible-core executables are available" ansible-playbook --version
9+
check "ansible-lint is available" ansible-lint --version
10+
11+
check "ansible uses Python 3.13" sh -c "ansible --version | grep 'python version = 3.13'"
12+
13+
reportResults

0 commit comments

Comments
 (0)