Table of Contents

Introduction

I love the new uv Python package/project manager and am beginning to adopt it at work. However, getting it working well with Azure Artifacts and Pipelines took a bit of fiddling so I wanted to write up my findings to supplement the official documentation.

2025-03-12 update: Since I wrote this article, the official documentation has been dramatically improved and covers most of the same content.

Setup

Before creating the virtual environment, ensure you have the keyring tool installed with the artifacts-keyring package. This will require a .NET 8+ runtime.

1uv tool install keyring --with artifacts-keyring

Now, in the pyproject.toml file, setup the following:

 1[tool.uv]
 2# tells uv to use subprocess mode for this keyring provider
 3keyring-provider = "subprocess"
 4
 5[[tool.uv.index]]
 6# name is arbitrary
 7name = "azure-devops"
 8# replace items in curly braces with your actual org/project/feed names
 9# the username must be provided in the URL and be "VssSessionToken" exactly
10url = "https://VssSessionToken@pkgs.dev.azure.com/{organization}/{project}/_packaging/{feed}/pypi/simple/"
11# if you want to publish a package as well, set the following URL too
12publish-url = "https://pkgs.dev.azure.com/{organization}/{project}/_packaging/{feed}/pypi/upload/"
13# setup Azure Artifacts as the default package index
14default = true

Now, uv sync or other commands which create the virtual environment should work.

To use this in an Azure Pipeline takes a little more configuration. You now need to provide uv with a valid access token since it can’t obtain one interactively. Additionally, the keyring provider needs to be disabled so that the provided username and password is used instead. In the Pipeline YAML file, this can be done like so:

1env:
2  # https://docs.astral.sh/uv/configuration/environment/#uv_index_name_password
3  # https://docs.astral.sh/uv/configuration/indexes/#providing-credentials
4  # This environment variable must match the name you gave the index,
5  # except capitalized and the non-alphanumeric characters replaced with underscores
6  UV_INDEX_AZURE_DEVOPS_USERNAME: x # username is arbitrary
7  UV_INDEX_AZURE_DEVOPS_PASSWORD: $(System.AccessToken)
8  UV_KEYRING_PROVIDER: disabled

Lastly, there are a few options for installing uv on the runner. pipx is generally the easiest, and natively cross-platform, but tends to be somewhat slow as it creates a Python virtual environment for a single executable.

1- script: pipx install uv
2  displayName: Install uv

Another option is to use the official install script, which is much faster, but you need to tell the runner about the new PATH location. Additionally, Windows vs Unix path differences makes this a bit more complicated:

 1# Linux
 2- bash: |
 3    curl -LsSf https://astral.sh/uv/install.sh | sh
 4
 5    # uv install directory
 6    UV_BIN_PATH="$HOME/.local/bin"
 7
 8    # if `cygpath` command exists, use it to convert
 9    # path to a Windows-style path
10    if command -v cygpath >/dev/null 2>&1; then
11        UV_BIN_PATH=$(cygpath -w "$UV_BIN_PATH")
12    fi
13
14    # tell the runner about the path
15    echo "##vso[task.prependpath]$UV_BIN_PATH"
16  displayName: Install uv

Here is a full example of installing uv, persisting the cache, and installing packages. Use the installation method you prefer.

 1variables:
 2  # These will be set as global environment variables
 3  UV_INDEX_AZURE_DEVOPS_USERNAME: x
 4  UV_KEYRING_PROVIDER: disabled
 5  # This environment variable keeps the cache in a predictable location
 6  UV_CACHE_DIR: $(Agent.TempDirectory)/cache/uv
 7
 8steps:
 9  - bash: |
10      curl -LsSf https://astral.sh/uv/install.sh | sh
11      UV_BIN_PATH="$HOME/.local/bin"
12      if command -v cygpath >/dev/null 2>&1; then
13          UV_BIN_PATH=$(cygpath -w "$UV_BIN_PATH")
14      fi
15      echo "##vso[task.prependpath]$UV_BIN_PATH"
16    displayName: Install uv
17
18  - task: Cache@2
19    displayName: Cache uv data
20    inputs:
21      key: '"uv-cache" | "$(Agent.OS)" | .python-version | uv.lock'
22      restoreKeys: |
23        "uv-cache" | "$(Agent.OS)" | .python-version
24        "uv-cache" | "$(Agent.OS)"
25        "uv-cache"
26      path: $(UV_CACHE_DIR)
27
28  - script: uv sync
29    displayName: Install dependencies
30    env:
31      # This needs to be explicitly mapped as it contains a secret.
32      UV_INDEX_AZURE_DEVOPS_PASSWORD: $(System.AccessToken)

If you want to publish a package using uv, replace the uv sync step with these instead:

 1- script: uv build
 2  displayName: Build package
 3  env:
 4    # This is still required, as uv will fetch packages required by the build system
 5    UV_INDEX_AZURE_DEVOPS_PASSWORD: $(System.AccessToken)
 6
 7# Ensure the Pipeline has permission to publish packages
 8# https://learn.microsoft.com/en-us/azure/devops/artifacts/feeds/feed-permissions?view=azure-devops#pipelines-permissions
 9- script: uv publish --index azure-devops
10  displayName: Publish package
11  env:
12    # The environment variable used for publishing packages is different
13    UV_PUBLISH_TOKEN: $(System.AccessToken)

Lastly, while the documentation recommends running uv cache prune --ci at the end to remove built wheel files, Azure Artifacts is pretty horrifically slow, and it’s usually faster to use as much cache as possible, rather than re-download files. I would recommend omitting this.

References