Skip to content

Commit 3d94e19

Browse files
msyycCopilot
andauthored
Fix breaking-changes checker for packages absent from Azure Artifacts feed (#48152)
* Fix breaking-changes checker for packages absent from Azure Artifacts feed The breaking-changes detector resolves the previous released version via PyPIClient. In CI, PIP_INDEX_URL points at the curated Azure Artifacts feed, which is not a full mirror of public PyPI. A package released on public PyPI (e.g. azure-mgmt-datatransfer) but absent from that feed caused get_ordered_versions to raise KeyError, crashing changelog generation. Add a force_pypi option to PyPIClient so callers can always query public PyPI, and use it in _resolve_pypi_version so version resolution targets pypi.org. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Assert breaking-change checker resolves versions with force_pypi=True Pin that _resolve_pypi_version constructs PyPIClient(force_pypi=True) so a revert of that line is caught by the test suite instead of silently restoring the original CI failure. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 4d8d055 commit 3d94e19

4 files changed

Lines changed: 72 additions & 5 deletions

File tree

eng/tools/azure-sdk-tools/pypi_tools/pypi.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,19 @@ class PyPIClient:
2020
By default, reads ``PIP_INDEX_URL`` to decide the backend:
2121
* If the URL contains ``pkgs.dev.azure.com`` → Azure Artifacts REST API.
2222
* Otherwise → PyPI JSON API (``https://pypi.org``).
23+
24+
Pass ``force_pypi=True`` to ignore ``PIP_INDEX_URL`` and select the PyPI
25+
JSON backend. Requests use ``host`` (public PyPI by default), so an
26+
explicitly supplied host is still honored.
2327
"""
2428

25-
def __init__(self, host="https://pypi.org"):
29+
def __init__(self, host="https://pypi.org", force_pypi=False):
2630
index_url = os.environ.get("PIP_INDEX_URL", "")
2731

2832
# Lazy import to avoid circular deps at module level.
2933
from pypi_tools.azdo import parse_pip_index_url, AzureArtifactsClient
3034

31-
azdo_cfg = parse_pip_index_url(index_url) if index_url else None
35+
azdo_cfg = None if force_pypi else (parse_pip_index_url(index_url) if index_url else None)
3236

3337
if azdo_cfg is not None:
3438
self._backend = "azdo"

eng/tools/azure-sdk-tools/tests/test_pypi_client.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,55 @@ def test_filter_packages_for_compatibility(self, mock_sys):
286286
filtered = client.get_ordered_versions(WELL_KNOWN_PACKAGE, True)
287287
unfiltered = client.get_ordered_versions(WELL_KNOWN_PACKAGE, False)
288288
assert len(filtered) < len(unfiltered)
289+
290+
291+
# ---------------------------------------------------------------------------
292+
# Backend selection — force_pypi bypasses the Azure Artifacts feed
293+
# ---------------------------------------------------------------------------
294+
295+
296+
class TestBackendSelection:
297+
"""The AzDO feed is curated and not a full PyPI mirror, so callers that need
298+
public PyPI (e.g. the breaking-change checker) pass force_pypi=True.
299+
"""
300+
301+
def _with_index_url(self, index_url, **kwargs):
302+
old = os.environ.get("PIP_INDEX_URL")
303+
try:
304+
os.environ["PIP_INDEX_URL"] = index_url
305+
return PyPIClient(**kwargs)
306+
finally:
307+
if old is not None:
308+
os.environ["PIP_INDEX_URL"] = old
309+
elif "PIP_INDEX_URL" in os.environ:
310+
del os.environ["PIP_INDEX_URL"]
311+
312+
def test_azdo_index_url_selects_azdo_backend_by_default(self):
313+
client = self._with_index_url(AZDO_FEED_URL)
314+
assert client._backend == "azdo"
315+
316+
def test_force_pypi_overrides_azdo_index_url(self):
317+
client = self._with_index_url(AZDO_FEED_URL, force_pypi=True)
318+
assert client._backend == "pypi"
319+
320+
def test_force_pypi_get_ordered_versions_queries_pypi_json(self):
321+
# A package present on public PyPI but absent from the curated feed must
322+
# still resolve when force_pypi=True, because get_ordered_versions goes
323+
# through project() (pypi.org JSON API) rather than the AzDO feed.
324+
project_data = {
325+
"info": {"version": "1.0.0b1"},
326+
"releases": {
327+
"1.0.0b1": [
328+
{
329+
"packagetype": "sdist",
330+
"url": "https://example.test/pkg-1.0.0b1.tar.gz",
331+
}
332+
],
333+
},
334+
}
335+
client = self._with_index_url(AZDO_FEED_URL, force_pypi=True)
336+
337+
with patch.object(PyPIClient, "project", return_value=project_data):
338+
versions = client.get_ordered_versions("azure-mgmt-datatransfer")
339+
340+
assert versions == [Version("1.0.0b1")]

scripts/breaking_changes_checker/detect_breaking_changes.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,12 +735,18 @@ def _resolve_pypi_version(package_name: str, latest_pypi_version: bool) -> str:
735735
"""
736736
from pypi_tools.pypi import PyPIClient
737737

738-
client = PyPIClient()
738+
# Force the public PyPI backend: in CI ``PIP_INDEX_URL`` points at the curated
739+
# Azure Artifacts feed, which is not a full mirror of PyPI. A package can be
740+
# released on public PyPI while absent from that feed, so query pypi.org
741+
# directly to resolve the version to compare against.
742+
client = PyPIClient(force_pypi=True)
739743
try:
740744
if latest_pypi_version:
741745
return str(client.get_ordered_versions(package_name)[-1])
742746
return str(client.get_relevant_versions(package_name)[1])
743-
except IndexError:
747+
except (IndexError, KeyError):
748+
# IndexError: the package exists but has no relevant/previous version.
749+
# KeyError: the package is brand new and not published on PyPI yet.
744750
_LOGGER.warning(f"No relevant version for {package_name} on PyPi. Exiting...")
745751
exit(0)
746752

scripts/breaking_changes_checker/tests/test_code_report_changelog.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ def test_use_apistub_changelog_resolves_stable_from_pypi_and_current_from_local(
433433
checker.report_changes.return_value = ""
434434
checker.breaking_changes = []
435435

436-
with mock.patch("pypi_tools.pypi.PyPIClient", return_value=pypi_client), mock.patch.object(
436+
with mock.patch("pypi_tools.pypi.PyPIClient", return_value=pypi_client) as pypi_client_cls, mock.patch.object(
437437
detect_breaking_changes, "build_report_from_apistub", return_value={}
438438
) as build_report, mock.patch.object(
439439
detect_breaking_changes, "compare_report_dicts", return_value=checker
@@ -454,6 +454,11 @@ def test_use_apistub_changelog_resolves_stable_from_pypi_and_current_from_local(
454454

455455
assert build_report.call_count == 2, "Expected separate apistub reports for current and stable"
456456

457+
# The resolver must force the public PyPI backend: in CI PIP_INDEX_URL points
458+
# at the curated Azure Artifacts feed, which is not a full mirror of PyPI.
459+
# Reverting force_pypi=True would restore the original CI failure, so pin it.
460+
pypi_client_cls.assert_called_once_with(force_pypi=True)
461+
457462
calls_by_label = {call.kwargs["label"]: call for call in build_report.call_args_list}
458463
assert set(calls_by_label) == {"current", "stable"}
459464

0 commit comments

Comments
 (0)