Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .icons/python.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added registry/attractivetoad/.images/avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions registry/attractivetoad/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
display_name: AttractiveToad
bio: Community modules for Coder workspaces.
github: AttractiveToad
avatar: ./.images/avatar.png
status: community
---

# AttractiveToad

Community modules for Coder workspaces.

## Modules

- **python**: Install and manage a configurable Python version with pyenv on Debian/Ubuntu workspaces.
49 changes: 49 additions & 0 deletions registry/attractivetoad/modules/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
display_name: Python
description: Install and manage a configurable Python version with pyenv
icon: ../../../../.icons/python.svg
maintainer_github: AttractiveToad
verified: false
tags: [helper, python]
---

# Python

Installs pyenv and uses it to build and select a configurable Python version on Debian/Ubuntu workspaces. The module installs the required build dependencies with `apt-get` and skips Python builds that already exist.

```tf
module "python" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/attractivetoad/python/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
}
```

## Examples

Install and globally select a different Python version:

```tf
module "python" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/attractivetoad/python/coder"
version = "1.0.0"
agent_id = coder_agent.example.id

python_version = "3.12.11"
}
```

Skip the package index update when your image already has a fresh apt cache:

```tf
module "python" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/attractivetoad/python/coder"
version = "1.0.0"
agent_id = coder_agent.example.id

update_packages = false
}
```
10 changes: 10 additions & 0 deletions registry/attractivetoad/modules/python/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "~test";

describe("python", async () => {
await runTerraformInit(import.meta.dir);

testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
});
96 changes: 96 additions & 0 deletions registry/attractivetoad/modules/python/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
terraform {
required_version = ">= 1.0"

required_providers {
coder = {
source = "coder/coder"
version = ">= 2.13"
}
}
}

variable "agent_id" {
description = "The ID of a Coder agent."
type = string
}

variable "python_version" {
description = "Python version to install and select globally with pyenv."
type = string
default = "3.13.5"

validation {
condition = can(regex("^\\d+\\.\\d+\\.\\d+$", var.python_version))
error_message = "python_version must be an exact semantic version such as 3.13.5."
}
}

variable "build_packages" {
description = "APT packages required to build Python with pyenv."
type = list(string)
default = [
"build-essential",
"curl",
"git",
"libbz2-dev",
"libffi-dev",
"liblzma-dev",
"libncursesw5-dev",
"libreadline-dev",
"libsqlite3-dev",
"libssl-dev",
"libxml2-dev",
"libxmlsec1-dev",
"tk-dev",
"xz-utils",
"zlib1g-dev",
]
}

variable "pyenv_git_ref" {
description = "Git branch or tag of pyenv to install."
type = string
default = "master"

validation {
condition = can(regex("^[0-9A-Za-z][0-9A-Za-z._/-]*$", var.pyenv_git_ref))
error_message = "pyenv_git_ref must be a valid Git branch or tag name."
}
}

variable "icon" {
description = "Icon to use for the Python install scripts."
type = string
default = "/icon/python.svg"
}

variable "update_packages" {
description = "Run apt-get update before installing missing packages."
type = bool
default = true
}

locals {
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_BUILD_PACKAGES_B64 = base64encode(join("\n", var.build_packages))
ARG_PYTHON_VERSION = var.python_version
ARG_PYENV_GIT_REF = var.pyenv_git_ref
ARG_UPDATE_PACKAGES = tostring(var.update_packages)
})
}

module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"

agent_id = var.agent_id
module_directory = "$HOME/.coder-modules/attractivetoad/python"
display_name_prefix = "Python"
icon = var.icon
install_script = local.install_script
}

output "scripts" {
description = "Ordered list of coder exp sync names produced by this module, in run order."
value = module.coder_utils.scripts
}
Comment thread
AttractiveToad marked this conversation as resolved.
44 changes: 44 additions & 0 deletions registry/attractivetoad/modules/python/main.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
mock_provider "coder" {}

run "plan_with_defaults" {
command = plan

variables {
agent_id = "example-agent-id"
}

assert {
condition = var.python_version == "3.13.5"
error_message = "Expected default Python version."
}

assert {
condition = var.pyenv_git_ref == "master"
error_message = "Expected pyenv to install from its master branch by default."
}

assert {
condition = var.update_packages == true
error_message = "Expected package index updates to be enabled by default."
}

assert {
condition = var.icon == "/icon/python.svg"
error_message = "Expected default icon."
}

assert {
condition = output.scripts == ["attractivetoad-python-install_script"]
error_message = "Expected scripts output to expose only the install script by default."
}

assert {
condition = strcontains(local.install_script, "run_apt_get update")
error_message = "Expected apt-get update to retry package-manager lock conflicts."
}

assert {
condition = strcontains(local.install_script, "pyenv install --skip-existing")
error_message = "Expected Python to be installed with pyenv."
}
}
106 changes: 106 additions & 0 deletions registry/attractivetoad/modules/python/scripts/install.sh.tftpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail

ARG_BUILD_PACKAGES_B64='${ARG_BUILD_PACKAGES_B64}'
ARG_PYTHON_VERSION='${ARG_PYTHON_VERSION}'
ARG_PYENV_GIT_REF='${ARG_PYENV_GIT_REF}'
ARG_UPDATE_PACKAGES='${ARG_UPDATE_PACKAGES}'

if ! command -v apt-get >/dev/null 2>&1; then
echo "apt-get not found; this module supports Debian/Ubuntu workspaces only."
exit 1
fi

if ! command -v dpkg-query >/dev/null 2>&1; then
echo "dpkg-query not found; cannot determine installed packages."
exit 1
fi

run_apt_get() {
local attempt=1
local max_attempts=60
local output
local status

while true; do
set +e
output=$(sudo env DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 "$@" 2>&1)
status=$?
set -e
printf '%s\n' "$${output}"

if [ "$${status}" -eq 0 ]; then
return 0
fi

if ! grep -Eq "Could not get lock|Unable to (acquire|lock)" <<< "$${output}"; then
return "$${status}"
fi

if [ "$${attempt}" -ge "$${max_attempts}" ]; then
echo "Timed out waiting for apt locks after $${max_attempts} attempts."
return "$${status}"
fi

echo "Another process is using apt; retrying in 5 seconds ($${attempt}/$${max_attempts})..."
attempt=$((attempt + 1))
sleep 5
done
}

mapfile -t build_packages < <(echo -n "$${ARG_BUILD_PACKAGES_B64}" | base64 -d)

missing_packages=()
for package in "$${build_packages[@]}"; do
if ! dpkg-query -W -f='$${Status}' "$${package}" 2>/dev/null | grep -q "install ok installed"; then
missing_packages+=("$${package}")
fi
done

if [ "$${#missing_packages[@]}" -eq 0 ]; then
echo "All packages required to build Python are already installed."
else
if [ "$${ARG_UPDATE_PACKAGES}" = "true" ]; then
echo "Updating apt package index..."
run_apt_get update
else
echo "Skipping apt-get update because update_packages=false."
fi

echo "Installing packages required to build Python: $${missing_packages[*]}"
run_apt_get install -y "$${missing_packages[@]}"
fi

export PYENV_ROOT="$${HOME}/.pyenv"
export PATH="$${PYENV_ROOT}/bin:$${PATH}"

if [ -d "$${PYENV_ROOT}/.git" ]; then
echo "Updating pyenv..."
git -C "$${PYENV_ROOT}" fetch --tags origin
git -C "$${PYENV_ROOT}" checkout "$${ARG_PYENV_GIT_REF}"
git -C "$${PYENV_ROOT}" pull --ff-only origin "$${ARG_PYENV_GIT_REF}" || true
else
echo "Installing pyenv..."
git clone --branch "$${ARG_PYENV_GIT_REF}" --depth 1 https://github.com/pyenv/pyenv.git "$${PYENV_ROOT}"
fi

eval "$(pyenv init -)"

echo "Installing Python $${ARG_PYTHON_VERSION}..."
pyenv install --skip-existing "$${ARG_PYTHON_VERSION}"
pyenv global "$${ARG_PYTHON_VERSION}"

profile_block='
export PYENV_ROOT="$HOME/.pyenv"
[[ -d "$PYENV_ROOT/bin" ]] && export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
'

for profile in "$${HOME}/.profile" "$${HOME}/.bashrc" "$${HOME}/.zshrc"; do
if ! grep -Fq 'export PYENV_ROOT="$HOME/.pyenv"' "$${profile}" 2>/dev/null; then
printf '\n%s\n' "$${profile_block}" >> "$${profile}"
fi
done

python --version
pip --version