Python Packaging: Testing ADO PyPI Feed Access from Terminal

2026/02/21

BUILDazure

Testing Azure DevOps PyPI Feed Access From Your Terminal (And Finding the Feed You Already Had)

I was setting up a centralized PyPI registry for our team — Azure DevOps Artifacts feed as the source of truth, with Nexus proxies in each cluster pulling from it. The architecture was clear. The part where I actually got access to create a feed was not.

Rather than waiting for permissions to arrive and hoping everything would work, I wanted to verify access from the terminal. Can I authenticate? Can I read packages? Does the upstream proxy work? Here is how to check all of that without installing anything or modifying your environment.


Getting an OAuth Token

Azure DevOps has a well-known resource ID for its APIs. You can get a token for it using the Azure CLI:

TOKEN=$(az account get-access-token \
  --resource 499b84ac-1321-427f-aa17-267ca6975798 \
  --query accessToken -o tsv)

The GUID 499b84ac-1321-427f-aa17-267ca6975798 is the Azure DevOps resource ID. It is the same for every Azure DevOps organization — you do not need to look it up or configure anything.

One thing that surprised me: the username in the URL does not matter. Azure DevOps only checks the token. You can use any, user, foo, your actual email — it makes no difference. The token carries your identity.


Testing Read Access With dry-run

The safest way to test read access is pip install --dry-run. It resolves packages, downloads wheels, and checks dependencies — but does not install anything. Nothing changes on your system.

pip install --dry-run \
  --index-url "https://any:${TOKEN}@pkgs.dev.azure.com/{org}/{project}/_packaging/{feed}/pypi/simple/" \
  requests

If this succeeds, you have read access to the feed. If it fails, the HTTP status code tells you what went wrong:

Status Meaning
401 Unauthorized Token is invalid or expired
403 Forbidden Token works but you lack read permission
404 Not Found Feed URL is wrong (check org/project/feed name)

Verifying the Upstream Proxy

Most ADO feeds have an upstream source configured — typically pypi.org. When you install a public package like requests, the feed fetches it from pypi.org, caches it, and serves it to you.

You can tell the upstream proxy is working by looking at the download URLs in pip’s output. If requests resolves from pkgs.dev.azure.com/... instead of files.pythonhosted.org, ADO is proxying pypi.org for you. The feed fetched the package, cached it locally, and served it through its own URL.

If the upstream is not configured or not working, public packages will fail with Could not find a version that satisfies the requirement.

What About Publish Access?

Read access is safe to test because --dry-run is truly non-destructive. Publish access is different — twine upload actually pushes a package. There is no dry-run for publishing.

If you need to verify publish access, build a minimal test package and upload it:

twine upload \
  --repository-url "https://pkgs.dev.azure.com/{org}/{project}/_packaging/{feed}/pypi/upload/" \
  -u any -p $TOKEN \
  dist/*

I did not test this because I did not have publish permissions yet. More on that shortly.

Quick Note: macOS and PEP 668

If you are on macOS with Homebrew Python, you will hit an “externally managed environment” error if you try to install anything outside a virtual environment. This is PEP 668 — Homebrew marks its Python as managed and refuses pip install outside a venv.

The --dry-run flag sidesteps this because it does not actually install. But if you need to build a test package for publish testing, create a temp venv first:

python3 -m venv /tmp/test-venv
source /tmp/test-venv/bin/activate
pip install build twine

Verification Summary

Here is the full picture of what you can verify from the terminal and how:

Claim How to verify Failure signal
Auth works OAuth token accepted by feed URL 401 Unauthorized
Can read packages pip install --dry-run succeeds 401 / 403 / 404
Upstream proxy works Public packages resolve via ADO URL Could not find a version
Can publish twine upload with test package 403 Forbidden

The first three are non-destructive and safe to run anytime. Publishing is the only one that requires care.


The Part Where I Could Not Create a Feed

With read access verified, I moved on to the real goal: creating a dedicated PyPI feed for our team. This is where things got interesting.

I used the Azure DevOps REST API to create a feed:

POST https://feeds.dev.azure.com/{org}/{project}/_apis/packaging/feeds?api-version=7.1

Permission denied. CreateFeed permission required.

So I tried the org-scoped endpoint (no project in the URL). Same error. Tried every project our team has access to — MAI_DPS, MAI Security, AISP, several others. All denied.

This is not unusual in a large organization. Feed creation is a privileged operation, and most developers do not have it. But I did not want to file a ticket and wait days without knowing if the rest of my plan would work.


Pivoting: What Already Exists?

Instead of pushing for permissions to create something new, I asked a different question: does a feed that meets our needs already exist?

The ADO REST API can list all feeds in a project:

GET https://feeds.dev.azure.com/{org}/{project}/_apis/packaging/feeds?api-version=7.1

Our project had 88 feeds. I filtered for feeds with a PyPI upstream source configured — the upstream is important because we need public packages to resolve through the feed, not just private ones.

37 feeds had a PyPI upstream. One stood out: Falcon_PublicPackages. It already had 141 cached PyPI packages and a configured pypi.org upstream source. This was not some abandoned experiment — it was actively used.

I ran the read access test against it:

pip install --dry-run \
  --index-url "https://any:${TOKEN}@pkgs.dev.azure.com/msasg/Falcon/_packaging/Falcon_PublicPackages/pypi/simple/" \
  requests

It worked. Public packages resolved through the ADO URL. The upstream proxy was functioning. I had read access right now, without filing any tickets.


The Lesson

The architecture I was designing called for a centralized ADO feed proxied by Nexus instances in each cluster. The feed URL format for Nexus configuration:

https://pkgs.dev.azure.com/{org}/{project}/_packaging/{feed-name}/pypi/simple/

I spent time trying to create new infrastructure when existing infrastructure already met the requirements. The gap was not technical — it was discovery. In a large Azure DevOps organization with dozens of projects and hundreds of feeds, finding the right one is its own problem.

What I ended up doing: sent a Slack message to the team channel asking for contributor access to the existing feed. That is a much simpler request than “please grant me CreateFeed permissions so I can build new infrastructure that duplicates something we already have.”


Takeaways

Test access before waiting for permissions. The dry-run approach lets you verify your setup is correct before you have all the access you need. When the permissions do arrive, you know everything else works.

Check what exists before creating something new. This sounds obvious in retrospect. It was not obvious when I was heads-down on the architecture. I had a plan, the plan said “create feed,” so I tried to create a feed. The plan did not say “check if someone already created one.”

The REST API is your friend for discovery. The ADO web UI does not make it easy to search across feeds or filter by capability. The API does. A few API calls found 37 PyPI-capable feeds that the UI would have taken an hour of clicking to discover.

Permission walls are information. Getting denied CreateFeed was frustrating in the moment. But it forced the pivot to discovery, which led to a better outcome — using an established, maintained feed instead of creating a new one that I would need to maintain myself.