diff --git a/doc/source/python-roles.rst b/doc/source/python-roles.rst
index 66a4c8e89..eeb8c1bc0 100644
--- a/doc/source/python-roles.rst
+++ b/doc/source/python-roles.rst
@@ -7,6 +7,7 @@ Python Roles
 .. zuul:autorole:: ensure-if-python
 .. zuul:autorole:: ensure-nox
 .. zuul:autorole:: ensure-pip
+.. zuul:autorole:: ensure-python-command
 .. zuul:autorole:: ensure-poetry
 .. zuul:autorole:: ensure-pyproject-build
 .. zuul:autorole:: ensure-python
diff --git a/roles/ensure-nox/defaults/main.yaml b/roles/ensure-nox/defaults/main.yaml
index e54288fa3..4ae6845b8 100644
--- a/roles/ensure-nox/defaults/main.yaml
+++ b/roles/ensure-nox/defaults/main.yaml
@@ -1,3 +1,2 @@
-nox_executable: nox
 ensure_nox_version: ''
 nox_venv_path: '{{ ansible_user_dir }}/.local/nox'
diff --git a/roles/ensure-nox/tasks/main.yaml b/roles/ensure-nox/tasks/main.yaml
index de8aa9b97..2849f8fd8 100644
--- a/roles/ensure-nox/tasks/main.yaml
+++ b/roles/ensure-nox/tasks/main.yaml
@@ -1,34 +1,14 @@
-- name: Install pip
+- name: Check and install nox if necessary
   include_role:
-    name: ensure-pip
+    name: ensure-python-command
+  vars:
+    ensure_python_command_name: nox
+    ensure_python_command_version: "{{ ensure_nox_version }}"
+    ensure_python_command_existing: "{{ nox_executable | default('') }}"
+    ensure_python_command_venv_path: "{{ nox_venv_path }}"
+    ensure_python_command_global_symlink: false  # not supported
 
-- name: Check if nox is installed
-  shell: |
-    command -v {{ nox_executable }} {{ nox_venv_path }}/bin/nox || exit 1
-  args:
-    executable: /bin/bash
-  register: nox_preinstalled
-  failed_when: false
-
-- name: Export preinstalled nox_exectuable
+- name: Export nox_executable path
   set_fact:
-    nox_executable: '{{ nox_preinstalled.stdout_lines[0] }}'
+    nox_executable: "{{ ensure_python_command_executable }}"
     cacheable: true
-  when: nox_preinstalled.rc == 0
-
-- name: Install nox to local env
-  when: nox_preinstalled.rc != 0
-  block:
-    - name: Create local venv
-      command: '{{ ensure_pip_virtualenv_command }} {{ nox_venv_path }}'
-
-    - name: Install nox to local venv
-      command: '{{ nox_venv_path }}/bin/pip install nox{{ ensure_nox_version }}'
-
-    - name: Export installed nox_executable path
-      set_fact:
-        nox_executable: '{{ nox_venv_path }}/bin/nox'
-        cacheable: true
-
-- name: Output nox version
-  command: "{{ nox_executable }} --version"
diff --git a/roles/ensure-poetry/defaults/main.yaml b/roles/ensure-poetry/defaults/main.yaml
index 9ce9c23f4..ba3d6739b 100644
--- a/roles/ensure-poetry/defaults/main.yaml
+++ b/roles/ensure-poetry/defaults/main.yaml
@@ -1,4 +1,3 @@
 ensure_poetry_global_symlink: false
 ensure_poetry_version: ""
-ensure_poetry_executable: poetry
 ensure_poetry_venv_path: "{{ ansible_user_dir }}/.local/poetry"
diff --git a/roles/ensure-poetry/tasks/main.yaml b/roles/ensure-poetry/tasks/main.yaml
index 011104910..504fd9fd2 100644
--- a/roles/ensure-poetry/tasks/main.yaml
+++ b/roles/ensure-poetry/tasks/main.yaml
@@ -1,44 +1,14 @@
-- name: Install pip
+- name: Check and install poetry if necessary
   include_role:
-    name: ensure-pip
+    name: ensure-python-command
+  vars:
+    ensure_python_command_name: poetry
+    ensure_python_command_version: "{{ ensure_poetry_version }}"
+    ensure_python_command_existing: "{{ ensure_poetry_executable | default('') }}"
+    ensure_python_command_venv_path: "{{ ensure_poetry_venv_path }}"
+    ensure_python_command_global_symlink: "{{ ensure_poetry_global_symlink }}"
 
-- name: Check if poetry is installed
-  shell: |
-    command -v {{ ensure_poetry_executable }} {{ ensure_poetry_venv_path }}/bin/poetry || exit 1
-  args:
-    executable: /bin/bash
-  register: poetry_preinstalled
-  failed_when: false
-
-- name: Export preinstalled ensure_poetry_executable
+- name: Export ensure_poetry_executable path
   set_fact:
-    ensure_poetry_executable: "{{ poetry_preinstalled.stdout_lines[0] }}"
+    ensure_poetry_executable: "{{ ensure_python_command_executable }}"
     cacheable: true
-  when: poetry_preinstalled.rc == 0
-
-- name: Install poetry to local env
-  when: poetry_preinstalled.rc != 0
-  block:
-    - name: Create local venv
-      command: "{{ ensure_pip_virtualenv_command }} {{ ensure_poetry_venv_path }}"
-
-    - name: Install poetry to local venv
-      command: "{{ ensure_poetry_venv_path }}/bin/pip install poetry{{ ensure_poetry_version }}"
-
-    - name: Export installed ensure_poetry_executable path
-      set_fact:
-        ensure_poetry_executable: "{{ ensure_poetry_venv_path }}/bin/poetry"
-        cacheable: true
-
-- name: Output poetry version
-  command: "{{ ensure_poetry_executable }} --version"
-
-- name: Make global symlink
-  when:
-    - ensure_poetry_global_symlink
-    - ensure_poetry_executable != '/usr/local/bin/poetry'
-  file:
-    state: link
-    src: "{{ ensure_poetry_executable }}"
-    dest: /usr/local/bin/poetry
-  become: yes
diff --git a/roles/ensure-pyproject-build/defaults/main.yaml b/roles/ensure-pyproject-build/defaults/main.yaml
index d944d4035..1375912a3 100644
--- a/roles/ensure-pyproject-build/defaults/main.yaml
+++ b/roles/ensure-pyproject-build/defaults/main.yaml
@@ -1,4 +1,3 @@
 ensure_pyproject_build_global_symlink: false
 ensure_pyproject_build_version: ""
-ensure_pyproject_build_executable: pyproject-build
 ensure_pyproject_build_venv_path: "{{ ansible_user_dir }}/.local/pyproject-build"
diff --git a/roles/ensure-pyproject-build/tasks/main.yaml b/roles/ensure-pyproject-build/tasks/main.yaml
index f46d4c799..8131e4647 100644
--- a/roles/ensure-pyproject-build/tasks/main.yaml
+++ b/roles/ensure-pyproject-build/tasks/main.yaml
@@ -1,44 +1,15 @@
-- name: Install pip
+- name: Check and install pyproject-build if necessary
   include_role:
-    name: ensure-pip
+    name: ensure-python-command
+  vars:
+    ensure_python_command_name: pyproject-build
+    ensure_python_command_package: build
+    ensure_python_command_version: "{{ ensure_pyproject_build_version }}"
+    ensure_python_command_existing: "{{ ensure_pyproject_build_executable | default('') }}"
+    ensure_python_command_venv_path: "{{ ensure_pyproject_build_venv_path }}"
+    ensure_python_command_global_symlink: "{{ ensure_pyproject_build_global_symlink }}"
 
-- name: Check if pyproject-build is installed
-  shell: |
-    command -v {{ ensure_pyproject_build_executable }} {{ ensure_pyproject_build_venv_path }}/bin/pyproject-build || exit 1
-  args:
-    executable: /bin/bash
-  register: pyproject_build_preinstalled
-  failed_when: false
-
-- name: Export preinstalled ensure_pyproject_build_executable
+- name: Export ensure_pyproject_build_executable path
   set_fact:
-    ensure_pyproject_build_executable: "{{ pyproject_build_preinstalled.stdout_lines[0] }}"
+    ensure_pyproject_build_executable: "{{ ensure_python_command_executable }}"
     cacheable: true
-  when: pyproject_build_preinstalled.rc == 0
-
-- name: Install pyproject-build to local env
-  when: pyproject_build_preinstalled.rc != 0
-  block:
-    - name: Create local venv
-      command: "{{ ensure_pip_virtualenv_command }} {{ ensure_pyproject_build_venv_path }}"
-
-    - name: Install pyproject-build to local venv
-      command: "{{ ensure_pyproject_build_venv_path }}/bin/pip install build{{ ensure_pyproject_build_version }}"
-
-    - name: Export installed ensure_pyproject_build_executable path
-      set_fact:
-        ensure_pyproject_build_executable: "{{ ensure_pyproject_build_venv_path }}/bin/pyproject-build"
-        cacheable: true
-
-- name: Output pyproject-build version
-  command: "{{ ensure_pyproject_build_executable }} --version"
-
-- name: Make global symlink
-  when:
-    - ensure_pyproject_build_global_symlink
-    - ensure_pyproject_build_executable != '/usr/local/bin/pyproject-build'
-  file:
-    state: link
-    src: "{{ ensure_pyproject_build_executable }}"
-    dest: /usr/local/bin/pyproject-build
-  become: yes
diff --git a/roles/ensure-python-command/README.rst b/roles/ensure-python-command/README.rst
new file mode 100644
index 000000000..a73d697a2
--- /dev/null
+++ b/roles/ensure-python-command/README.rst
@@ -0,0 +1,71 @@
+Ensure a pip-installed command is available
+
+This role checks for the specified command, and if not found, installs
+it via ``pip`` into a virtual environment for the current user.
+
+The minimal required input is the command name. Additionally, you can
+specify a version or a path to a local venv, among other things.
+
+Example:
+
+.. code-block:: yaml
+
+   - role: ensure-python-command
+     vars:
+       ensure_python_command_name: poetry
+       ensure_python_command_version: ==1.8.5  # omit to install latest
+
+In this case, if the ``poetry`` command is not already available, pip
+will install it in a new venv. Either way, after running this role, the
+``ensure_python_command_executable`` variable will hold the full path to
+the command.
+
+**Role Variables**
+
+.. zuul:rolevar:: ensure_python_command_name
+
+   Required. The name of the command to ensure is available.
+
+.. zuul:rolevar:: ensure_python_command_package
+   :default: {{ ensure_python_command_name }}
+
+   The name of the Python package that provides the desired command.
+   Defaults to the command name, since this is usually the case.
+   Set this variable when they differ.
+
+.. zuul:rolevar:: ensure_python_command_version
+   :default: ''
+
+   The version specifier to select the version of the package to install.
+   If omitted, the latest version will be installed.
+
+.. zuul:rolevar:: ensure_python_command_existing
+   :default: ''
+
+   Look for an existing command at this specific path. For example, if your base
+   image pre-installs the command in an out-of-path environment, set this so the
+   role does not attempt to install the command again.
+
+.. zuul:rolevar:: ensure_python_command_venv_path
+   :default: {{ ansible_user_dir }}/.local/{{ ensure_python_command_package }}
+
+   Directory for the Python venv where the package should be installed.
+
+.. zuul:rolevar:: ensure_python_command_global_symlink
+   :default: False
+
+   Install a symlink to the command executable into ``/usr/local/bin/``.
+
+   This can be useful when scripts need to be run that expect to find the
+   command in a more standard location and plumbing through the value
+   of ``ensure_python_command_executable`` would be onerous.
+
+   Setting this requires root access, so should only be done in
+   circumstances where root access is available.
+
+**Output Variables**
+
+.. zuul:rolevar:: ensure_python_command_executable
+
+   The full path to the command executable, whether it was detected or
+   installed by the role.
diff --git a/roles/ensure-python-command/defaults/main.yaml b/roles/ensure-python-command/defaults/main.yaml
new file mode 100644
index 000000000..52b75fffc
--- /dev/null
+++ b/roles/ensure-python-command/defaults/main.yaml
@@ -0,0 +1,5 @@
+ensure_python_command_global_symlink: false
+ensure_python_command_version: ""
+ensure_python_command_existing: ""
+ensure_python_command_package: "{{ ensure_python_command_name }}"
+ensure_python_command_venv_path: "{{ ansible_user_dir }}/.local/{{ ensure_python_command_package }}"
diff --git a/roles/ensure-python-command/tasks/main.yaml b/roles/ensure-python-command/tasks/main.yaml
new file mode 100644
index 000000000..86446a3c1
--- /dev/null
+++ b/roles/ensure-python-command/tasks/main.yaml
@@ -0,0 +1,64 @@
+- name: Install pip
+  include_role:
+    name: ensure-pip
+
+- name: Check if the command name is set and non-empty
+  fail:
+    msg: Required variable ensure_python_command_name is not set or empty
+  when: ensure_python_command_name is not defined or not ensure_python_command_name.strip()
+
+# Part 1
+# Check if the command is already available. If not, pip-install it to a local venv.
+# Either way, save the command's full path to the output variable.
+
+- name: Check if {{ ensure_python_command_name }} is available
+  # 1. Full path optionally informed by the role caller
+  # 2. Ansible user $PATH (bare command name)
+  # 3. Local venv (installed from a previous run for this role)
+  shell: |
+    command -v \
+    {{ ensure_python_command_existing }} \
+    {{ ensure_python_command_name | quote }} \
+    {{ ensure_python_command_venv_executable | quote }} \
+    || exit 1
+  args:
+    executable: /bin/bash
+  register: ensure_python_command_preinstalled
+  failed_when: false
+
+- name: Export preinstalled ensure_python_command_executable
+  set_fact:
+    ensure_python_command_executable: "{{ ensure_python_command_preinstalled.stdout_lines[0] }}"
+    cacheable: true
+  when: ensure_python_command_preinstalled.rc == 0
+
+- name: Install {{ ensure_python_command_package }} to local env
+  when: ensure_python_command_preinstalled.rc != 0
+  block:
+    - name: Create local venv
+      command: "{{ ensure_pip_virtualenv_command }} {{ ensure_python_command_venv_path }}"
+
+    - name: Install {{ ensure_python_command_package }} to local venv
+      command: "{{ ensure_python_command_venv_path }}/bin/pip install {{ ensure_python_command_package }}{{ ensure_python_command_version }}"
+
+    - name: Export installed ensure_python_command_executable path
+      set_fact:
+        ensure_python_command_executable: "{{ ensure_python_command_venv_executable }}"
+        cacheable: true
+
+# Part 2
+# Try to show the command's version and maybe also create a symlink to it in /usr/local/bin
+
+- name: Output {{ ensure_python_command_name }} version
+  command: "{{ ensure_python_command_executable }} --version"
+  failed_when: false
+
+- name: Make global symlink
+  when:
+    - ensure_python_command_global_symlink
+    - ensure_python_command_executable != ensure_python_command_global_symlink_path
+  file:
+    state: link
+    src: "{{ ensure_python_command_executable }}"
+    dest: "{{ ensure_python_command_global_symlink_path }}"
+  become: yes
diff --git a/roles/ensure-python-command/vars/main.yaml b/roles/ensure-python-command/vars/main.yaml
new file mode 100644
index 000000000..d3fd7f665
--- /dev/null
+++ b/roles/ensure-python-command/vars/main.yaml
@@ -0,0 +1,2 @@
+ensure_python_command_global_symlink_path: /usr/local/bin/{{ ensure_python_command_name }}
+ensure_python_command_venv_executable: "{{ ensure_python_command_venv_path }}/bin/{{ ensure_python_command_name }}"
diff --git a/roles/ensure-twine/defaults/main.yaml b/roles/ensure-twine/defaults/main.yaml
index 160d497ac..1554904a9 100644
--- a/roles/ensure-twine/defaults/main.yaml
+++ b/roles/ensure-twine/defaults/main.yaml
@@ -1,5 +1,4 @@
 ensure_twine_global_symlink: false
 # version 6.1.0 is breaking test-release-openstack CI job
 ensure_twine_version: ">1.12.0,!=6.1.0"
-pypi_twine_executable: twine
 ensure_twine_venv_path: "{{ ansible_user_dir }}/.local/twine"
diff --git a/roles/ensure-twine/tasks/main.yaml b/roles/ensure-twine/tasks/main.yaml
index 3727fc4ab..636ac4922 100644
--- a/roles/ensure-twine/tasks/main.yaml
+++ b/roles/ensure-twine/tasks/main.yaml
@@ -1,44 +1,14 @@
-- name: Install pip
+- name: Check and install twine if necessary
   include_role:
-    name: ensure-pip
+    name: ensure-python-command
+  vars:
+    ensure_python_command_name: twine
+    ensure_python_command_version: "{{ ensure_twine_version }}"
+    ensure_python_command_existing: "{{ pypi_twine_executable | default('') }}"
+    ensure_python_command_venv_path: "{{ ensure_twine_venv_path }}"
+    ensure_python_command_global_symlink: "{{ ensure_twine_global_symlink }}"
 
-- name: Check if twine is installed
-  shell: |
-    command -v {{ pypi_twine_executable }} {{ ensure_twine_venv_path }}/bin/twine || exit 1
-  args:
-    executable: /bin/bash
-  register: twine_preinstalled
-  failed_when: false
-
-- name: Export preinstalled pypi_twine_executable
+- name: Export pypi_twine_executable path
   set_fact:
-    pypi_twine_executable: "{{ twine_preinstalled.stdout_lines[0] }}"
+    pypi_twine_executable: "{{ ensure_python_command_executable }}"
     cacheable: true
-  when: twine_preinstalled.rc == 0
-
-- name: Install twine to local env
-  when: twine_preinstalled.rc != 0
-  block:
-    - name: Create local venv
-      command: "{{ ensure_pip_virtualenv_command }} {{ ensure_twine_venv_path }}"
-
-    - name: Install twine to local venv
-      command: "{{ ensure_twine_venv_path }}/bin/pip install twine{{ ensure_twine_version }}"
-
-    - name: Export installed pypi_twine_executable path
-      set_fact:
-        pypi_twine_executable: "{{ ensure_twine_venv_path }}/bin/twine"
-        cacheable: true
-
-- name: Output twine version
-  command: "{{ pypi_twine_executable }} --version"
-
-- name: Make global symlink
-  when:
-    - ensure_twine_global_symlink
-    - pypi_twine_executable != '/usr/local/bin/twine'
-  file:
-    state: link
-    src: "{{ pypi_twine_executable }}"
-    dest: /usr/local/bin/twine
-  become: yes
diff --git a/roles/ensure-uv/defaults/main.yaml b/roles/ensure-uv/defaults/main.yaml
index a6060f028..9863f17ff 100644
--- a/roles/ensure-uv/defaults/main.yaml
+++ b/roles/ensure-uv/defaults/main.yaml
@@ -1,4 +1,3 @@
 ensure_uv_global_symlink: false
 ensure_uv_version: ""
-ensure_uv_executable: uv
 ensure_uv_venv_path: "{{ ansible_user_dir }}/.local/uv"
diff --git a/roles/ensure-uv/tasks/main.yaml b/roles/ensure-uv/tasks/main.yaml
index 87dd44e8a..b56f1a86d 100644
--- a/roles/ensure-uv/tasks/main.yaml
+++ b/roles/ensure-uv/tasks/main.yaml
@@ -1,44 +1,14 @@
-- name: Install pip
+- name: Check and install uv if necessary
   include_role:
-    name: ensure-pip
+    name: ensure-python-command
+  vars:
+    ensure_python_command_name: uv
+    ensure_python_command_version: "{{ ensure_uv_version }}"
+    ensure_python_command_existing: "{{ ensure_uv_executable | default('') }}"
+    ensure_python_command_venv_path: "{{ ensure_uv_venv_path }}"
+    ensure_python_command_global_symlink: "{{ ensure_uv_global_symlink }}"
 
-- name: Check if uv is installed
-  shell: |
-    command -v {{ ensure_uv_executable }} {{ ensure_uv_venv_path }}/bin/uv || exit 1
-  args:
-    executable: /bin/bash
-  register: uv_preinstalled
-  failed_when: false
-
-- name: Export preinstalled ensure_uv_executable
+- name: Export ensure_uv_executable path
   set_fact:
-    ensure_uv_executable: "{{ uv_preinstalled.stdout_lines[0] }}"
+    ensure_uv_executable: "{{ ensure_python_command_executable }}"
     cacheable: true
-  when: uv_preinstalled.rc == 0
-
-- name: Install uv to local env
-  when: uv_preinstalled.rc != 0
-  block:
-    - name: Create local venv
-      command: "{{ ensure_pip_virtualenv_command }} {{ ensure_uv_venv_path }}"
-
-    - name: Install uv to local venv
-      command: "{{ ensure_uv_venv_path }}/bin/pip install uv{{ ensure_uv_version }}"
-
-    - name: Export installed ensure_uv_executable path
-      set_fact:
-        ensure_uv_executable: "{{ ensure_uv_venv_path }}/bin/uv"
-        cacheable: true
-
-- name: Output uv version
-  command: "{{ ensure_uv_executable }} --version"
-
-- name: Make global symlink
-  when:
-    - ensure_uv_global_symlink
-    - ensure_uv_executable != '/usr/local/bin/uv'
-  file:
-    state: link
-    src: "{{ ensure_uv_executable }}"
-    dest: /usr/local/bin/uv
-  become: yes
diff --git a/zuul-tests.d/python-jobs.yaml b/zuul-tests.d/python-jobs.yaml
index a4ee1d420..8a96f9aa3 100644
--- a/zuul-tests.d/python-jobs.yaml
+++ b/zuul-tests.d/python-jobs.yaml
@@ -3,6 +3,7 @@
     description: Test the ensure-nox role
     files:
       - roles/ensure-nox/.*
+      - roles/ensure-python-command/.*
       - test-playbooks/ensure-nox.yaml
     run: test-playbooks/ensure-nox.yaml
     tags: all-platforms
@@ -149,6 +150,7 @@
     description: Test the ensure-poetry role
     files:
       - roles/ensure-poetry/.*
+      - roles/ensure-python-command/.*
       - test-playbooks/ensure-poetry.yaml
     run: test-playbooks/ensure-poetry.yaml
     tags: all-platforms
@@ -218,6 +220,7 @@
     description: Test the ensure-pyproject-build role
     files:
       - roles/ensure-pyproject-build/.*
+      - roles/ensure-python-command/.*
       - test-playbooks/ensure-pyproject-build.yaml
     run: test-playbooks/ensure-pyproject-build.yaml
     tags: all-platforms
@@ -287,6 +290,7 @@
     description: Test the ensure-twine role
     files:
       - roles/ensure-twine/.*
+      - roles/ensure-python-command/.*
       - test-playbooks/ensure-twine.yaml
     run: test-playbooks/ensure-twine.yaml
     tags: all-platforms
@@ -434,6 +438,7 @@
     description: Test the ensure-uv role
     files:
       - roles/ensure-uv/.*
+      - roles/ensure-python-command/.*
       - test-playbooks/ensure-uv.yaml
     run: test-playbooks/ensure-uv.yaml
     tags: all-platforms