|
| 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