Skip to content

Commit e18edb6

Browse files
scbeddCopilot
andauthored
Swap CI to CFS (Azure#45995)
* update all CI pipelines to consume solely from our public feed public/azure-sdk-for-python * adjust pins, requirements to accounts for now visible packages through upstream * update dependency_resolution to handle going to an azdo feed instead of pypi.org only --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c60b9b2 commit e18edb6

15 files changed

Lines changed: 400 additions & 79 deletions

File tree

eng/pipelines/templates/jobs/ci.tests.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,6 @@ jobs:
9696
Paths:
9797
- '**'
9898

99-
# Authenticate to Azure Artifacts feed immediately after checkout to prevent 401 errors
100-
# Public feeds have upstream sources enabled and require authentication for passthrough to pypi.org
101-
- template: /eng/pipelines/templates/steps/auth-dev-feed.yml
102-
10399
- template: /eng/pipelines/templates/steps/download-package-artifacts.yml
104100

105101
- template: /eng/pipelines/templates/steps/resolve-package-targeting.yml

eng/pipelines/templates/jobs/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ jobs:
285285
inputs:
286286
versionSpec: '3.12'
287287
- template: /eng/pipelines/templates/steps/use-venv.yml
288+
- template: ../steps/auth-dev-feed.yml
289+
parameters:
290+
DevFeedName: ${{ parameters.DevFeedName }}
288291
- template: /eng/common/pipelines/templates/steps/save-package-properties.yml
289292
parameters:
290293
ServiceDirectory: ${{parameters.ServiceDirectory}}
@@ -323,6 +326,11 @@ jobs:
323326
inputs:
324327
versionSpec: '3.12'
325328
- template: /eng/pipelines/templates/steps/use-venv.yml
329+
330+
- template: ../steps/auth-dev-feed.yml
331+
parameters:
332+
DevFeedName: ${{ parameters.DevFeedName }}
333+
326334
- pwsh: |
327335
$ErrorActionPreference = 'Stop'
328336
$PSNativeCommandUseErrorActionPreference = $true

eng/pipelines/templates/steps/auth-dev-feed.yml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,17 @@ steps:
2525
artifactFeeds: $(DevFeedName)
2626
onlyAddExtraIndex: false
2727

28-
# Disabled UV authentication step - for future enablement when UV fully supports Azure Artifacts
29-
# To enable: uncomment this step and ensure UV_INDEX_URL is properly configured
30-
# The PipAuthenticate task above creates a .pypirc file in the home directory that UV can use
3128
- pwsh: |
32-
# This step configures UV to use the same authentication as pip
33-
# UV will read credentials from ~/.pypirc created by PipAuthenticate task
3429
if ($env:PIP_INDEX_URL) {
35-
Write-Host "PIP Index URL detected: $env:PIP_INDEX_URL"
36-
# Uncomment the next line to enable UV authentication
37-
# Write-Host "##vso[task.setvariable variable=UV_INDEX_URL]$($env:PIP_INDEX_URL)"
38-
Write-Host "UV authentication is currently disabled. To enable, uncomment the variable assignment above."
30+
# UV_DEFAULT_INDEX is the canonical replacement for the deprecated UV_INDEX_URL (uv 0.4.23+).
31+
# PIP_INDEX_URL is set by PipAuthenticate@1 and contains embedded credentials, which uv
32+
# will use for Basic auth against the ADO feed (and its PyPI upstream) per astral-sh/uv#12651.
33+
Write-Host "##vso[task.setvariable variable=UV_DEFAULT_INDEX]azure-sdk=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/"
34+
# Disable keyring so uv uses the URL-embedded credentials directly.
35+
Write-Host "##vso[task.setvariable variable=UV_KEYRING_PROVIDER]disabled"
36+
Write-Host "##vso[task.setvariable variable=UV_INDEX_AZURE_SDK_USERNAME]x"
37+
Write-Host "##vso[task.setvariable variable=UV_INDEX_AZURE_SDK_PASSWORD]$(System.AccessToken)"
38+
} else {
39+
Write-Host "##[warning]PIP_INDEX_URL not set - uv will fall back to public PyPI."
3940
}
40-
displayName: 'Configure UV Authentication (Disabled)'
41-
condition: false # Explicitly disabled - change to 'true' or remove to enable
41+
displayName: 'Configure UV Authentication'

eng/pipelines/templates/steps/build-test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ steps:
2828

2929
- template: /eng/pipelines/templates/steps/use-venv.yml
3030

31+
# Authenticate to Azure Artifacts feed immediately after checkout to prevent 401 errors
32+
# Public feeds have upstream sources enabled and require authentication for passthrough to pypi.org
33+
- template: /eng/pipelines/templates/steps/auth-dev-feed.yml
34+
parameters:
35+
DevFeedName: ${{ parameters.DevFeedName }}
36+
3137
- template: /eng/common/pipelines/templates/steps/set-test-pipeline-version.yml
3238
parameters:
3339
PackageName: "azure-template"

eng/scripts/Language-Settings.ps1

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ function Get-AllPackageInfoFromRepo ($serviceDirectory)
166166
# Use ‘uv pip install’ if uv is on PATH, otherwise fall back to python -m pip
167167
if (Get-Command uv -ErrorAction SilentlyContinue) {
168168
Write-Host "Using uv pip install"
169-
$null = uv pip install "$pathToBuild"
169+
$installerOutput = uv pip install "$pathToBuild"
170+
Write-Host $installerOutput
170171
$freezeOutput = uv pip freeze
171172
Write-Host "Pip freeze output: $freezeOutput"
172173
} else {

eng/tools/azure-sdk-tools/ci_tools/scenario/dependency_resolution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
},
8787
}
8888

89-
PLATFORM_SPECIFIC_MAXIMUM_OVERRIDES = {}
89+
PLATFORM_SPECIFIC_MAXIMUM_OVERRIDES = {"<3.10.0": {"requests": "2.32.5"}}
9090

9191
# This is used to actively _add_ requirements to the install set. These are used to actively inject
9292
# a new requirement specifier to the set of packages being installed.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import base64
2+
import json
3+
import logging
4+
import os
5+
import re
6+
from dataclasses import dataclass
7+
from typing import Any, Dict, List, Optional
8+
from urllib.parse import urlparse
9+
10+
from packaging.version import Version, InvalidVersion, parse
11+
from urllib3 import PoolManager, Retry
12+
13+
14+
def pep503_normalize(name: str) -> str:
15+
return re.sub(r"[-_.]+", "-", name).lower()
16+
17+
18+
@dataclass(frozen=True)
19+
class AzureArtifactsFeedConfig:
20+
organization: str
21+
project: Optional[str] # None if the feed is organization-scoped
22+
feed: str # feed name or GUID
23+
api_version: str = "7.1"
24+
25+
bearer_token: Optional[str] = None
26+
pat: Optional[str] = None
27+
28+
29+
# Pattern: https://pkgs.dev.azure.com/{org}/{project}/_packaging/{feed}/pypi/simple/
30+
# or org-scoped: https://pkgs.dev.azure.com/{org}/_packaging/{feed}/pypi/simple/
31+
_AZDO_FEED_RE = re.compile(
32+
r"/(?P<org>[^/]+)/(?:(?P<project>[^/_][^/]*)/)?" r"_packaging/(?P<feed>[^/]+)/pypi/simple/?$"
33+
)
34+
35+
36+
def parse_pip_index_url(url: str) -> Optional[AzureArtifactsFeedConfig]:
37+
"""If *url* points to an Azure Artifacts PyPI feed, return a config; else None."""
38+
parsed = urlparse(url)
39+
if "pkgs.dev.azure.com" not in parsed.hostname:
40+
return None
41+
42+
m = _AZDO_FEED_RE.search(parsed.path)
43+
if not m:
44+
return None
45+
46+
# Embedded credentials from PipAuthenticate@1
47+
pat = None
48+
if parsed.password:
49+
pat = parsed.password
50+
51+
return AzureArtifactsFeedConfig(
52+
organization=m.group("org"),
53+
project=m.group("project"),
54+
feed=m.group("feed"),
55+
pat=pat or os.environ.get("AZDO_PAT"),
56+
)
57+
58+
59+
class AzureArtifactsClient:
60+
"""
61+
Minimal client to list package versions from an Azure Artifacts feed
62+
via Azure DevOps Artifacts REST API.
63+
"""
64+
65+
def __init__(self, cfg: AzureArtifactsFeedConfig, base_url: str = "https://feeds.dev.azure.com"):
66+
self._cfg = cfg
67+
self._base_url = base_url.rstrip("/")
68+
self._http = PoolManager(
69+
retries=Retry(total=3, raise_on_status=True),
70+
ca_certs=os.getenv("REQUESTS_CA_BUNDLE", None),
71+
)
72+
73+
def _auth_header(self) -> Dict[str, str]:
74+
if self._cfg.bearer_token:
75+
return {"Authorization": f"Bearer {self._cfg.bearer_token}"}
76+
77+
if self._cfg.pat:
78+
# Azure DevOps PATs can be used via HTTP Basic by base64-encoding ":<PAT>".
79+
token = base64.b64encode(f":{self._cfg.pat}".encode("utf-8")).decode("ascii")
80+
return {"Authorization": f"Basic {token}"}
81+
82+
return {}
83+
84+
def _path_prefix(self) -> str:
85+
# If project-scoped feed: /{org}/{project}/...
86+
# If org-scoped feed: /{org}/...
87+
if self._cfg.project:
88+
return f"{self._cfg.organization}/{self._cfg.project}"
89+
return self._cfg.organization
90+
91+
def _get_json(self, url: str, params: Dict[str, Any]) -> Any:
92+
headers = {"Accept": "application/json", **self._auth_header()}
93+
r = self._http.request("GET", url, fields=params, headers=headers)
94+
return json.loads(r.data.decode("utf-8"))
95+
96+
def list_feeds(self) -> List[Dict[str, Any]]:
97+
url = f"{self._base_url}/{self._path_prefix()}/_apis/packaging/feeds"
98+
data = self._get_json(url, {"api-version": self._cfg.api_version})
99+
# Many Azure DevOps APIs return {"count": n, "value": [...]}; be tolerant.
100+
return data["value"] if isinstance(data, dict) and "value" in data else data
101+
102+
def resolve_feed_id(self) -> str:
103+
feed = self._cfg.feed
104+
if re.fullmatch(r"[0-9a-fA-F-]{36}", feed):
105+
return feed
106+
107+
for f in self.list_feeds():
108+
if f.get("name") == feed:
109+
return f["id"]
110+
111+
raise KeyError(f"Feed not found: {feed!r}")
112+
113+
def get_package_record(self, package_name: str, include_deleted: bool = False) -> Dict[str, Any]:
114+
feed_id = self.resolve_feed_id()
115+
url = f"{self._base_url}/{self._path_prefix()}/_apis/packaging/Feeds/{feed_id}/packages"
116+
117+
params = {
118+
"api-version": self._cfg.api_version,
119+
"protocolType": "pypi",
120+
"packageNameQuery": package_name,
121+
"includeAllVersions": "true",
122+
"includeDeleted": "true" if include_deleted else "false",
123+
}
124+
125+
data = self._get_json(url, params)
126+
packages = data["value"] if isinstance(data, dict) and "value" in data else data
127+
128+
# packageNameQuery is "contains string", so choose best match.
129+
target = pep503_normalize(package_name)
130+
for pkg in packages:
131+
if pep503_normalize(pkg.get("normalizedName", pkg.get("name", ""))) == target:
132+
return pkg
133+
for pkg in packages:
134+
if pep503_normalize(pkg.get("name", "")) == target:
135+
return pkg
136+
137+
raise KeyError(f"Package not found in feed: {package_name!r}")
138+
139+
def get_ordered_versions(self, package_name: str, include_deleted: bool = False) -> List[Version]:
140+
pkg = self.get_package_record(package_name, include_deleted=include_deleted)
141+
142+
out: List[Version] = []
143+
for v in pkg.get("versions", []):
144+
if (not include_deleted) and v.get("isDeleted", False):
145+
continue
146+
147+
raw = v.get("version")
148+
if not raw:
149+
continue
150+
151+
try:
152+
out.append(parse(raw))
153+
except InvalidVersion:
154+
logging.warning("Invalid version %r for package %s (feed=%s)", raw, package_name, self._cfg.feed)
155+
156+
out.sort()
157+
return out

0 commit comments

Comments
 (0)