Skip to content

Commit 8b331ee

Browse files
msyycCopilotCopilot
authored
Add --generate-from-pypi option to apistub check (#47741)
* new flat --pypi-version * Rename apistub PyPI flag Co-authored-by: msyyc <70930885+msyyc@users.noreply.github.com> * Remove --pypi-version alias, keep only --generate-from-pypi Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename apistub flag to --from-pypi Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Match apistub dest to --from-pypi flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use --generate-from-pypi with matching dest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply black formatting to test_apistub.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ab16a16 commit 8b331ee

2 files changed

Lines changed: 136 additions & 13 deletions

File tree

eng/tools/azure-sdk-tools/azpysdk/apistub.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ def register(
6969
default=None,
7070
help="Destination directory for generated API stub files.",
7171
)
72+
p.add_argument(
73+
"--generate-from-pypi",
74+
dest="generate_from_pypi",
75+
default=None,
76+
help="Generate the stub from this released PyPI version instead of local source code.",
77+
)
7278
p.add_argument(
7379
"--install-deps",
7480
dest="install_deps",
@@ -95,6 +101,31 @@ def ensure_apistub_dependencies(self, executable: str, package_dir: str, staging
95101
package_dir,
96102
)
97103

104+
def download_pypi_wheel(self, executable: str, package_name: str, version: str, staging_directory: str) -> str:
105+
"""Download a released wheel from PyPI into the staging directory and return its path."""
106+
logger.info(f"Downloading {package_name}=={version} from PyPI.")
107+
self.run_venv_command(
108+
executable,
109+
[
110+
"-m",
111+
"pip",
112+
"download",
113+
f"{package_name}=={version}",
114+
"--no-deps",
115+
"--only-binary=:all:",
116+
"-d",
117+
staging_directory,
118+
],
119+
cwd=staging_directory,
120+
check=True,
121+
)
122+
found_whl = find_whl(staging_directory, package_name, version)
123+
if not found_whl:
124+
raise FileNotFoundError(
125+
f"No wheel found for package {package_name} version {version} after downloading from PyPI."
126+
)
127+
return os.path.join(staging_directory, found_whl)
128+
98129
def run(self, args: argparse.Namespace) -> int:
99130
"""Run the apistub check command."""
100131
logger.info("Running apistub check...")
@@ -133,23 +164,28 @@ def run(self, args: argparse.Namespace) -> int:
133164
logger.error(f"Failed to install APIView dependencies: {e}")
134165
return getattr(e, "returncode", 1)
135166

136-
if not os.getenv("PREBUILT_WHEEL_DIR"):
137-
create_package_and_install(
138-
distribution_directory=staging_directory,
139-
target_setup=package_dir,
140-
skip_install=True,
141-
cache_dir=None,
142-
work_dir=staging_directory,
143-
force_create=False,
144-
package_type="wheel",
145-
pre_download_disabled=False,
146-
python_executable=executable,
147-
)
167+
generate_from_pypi = getattr(args, "generate_from_pypi", None)
168+
169+
if generate_from_pypi:
170+
pkg_path = self.download_pypi_wheel(executable, package_name, generate_from_pypi, staging_directory)
171+
else:
172+
if not os.getenv("PREBUILT_WHEEL_DIR"):
173+
create_package_and_install(
174+
distribution_directory=staging_directory,
175+
target_setup=package_dir,
176+
skip_install=True,
177+
cache_dir=None,
178+
work_dir=staging_directory,
179+
force_create=False,
180+
package_type="wheel",
181+
pre_download_disabled=False,
182+
python_executable=executable,
183+
)
184+
pkg_path = get_package_wheel_path(package_dir)
148185

149186
if install_deps:
150187
self.pip_freeze(executable)
151188

152-
pkg_path = get_package_wheel_path(package_dir)
153189
pkg_path = os.path.abspath(pkg_path)
154190

155191
out_token_path = os.path.abspath(getattr(args, "dest_dir", None) or package_dir)

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@
88

99
from azpysdk.apistub import apistub, get_package_wheel_path, get_cross_language_mapping_path
1010

11+
12+
def _build_parser():
13+
parser = argparse.ArgumentParser(prog="azpysdk")
14+
subparsers = parser.add_subparsers(title="commands", dest="command")
15+
apistub().register(subparsers)
16+
return parser
17+
18+
19+
class TestApistubRegistration:
20+
def test_generate_from_pypi_flag_sets_version(self):
21+
parser = _build_parser()
22+
23+
args = parser.parse_args(["apistub", "--generate-from-pypi", "1.0.0"])
24+
25+
assert args.command == "apistub"
26+
assert args.generate_from_pypi == "1.0.0"
27+
28+
1129
# ── get_package_wheel_path() ─────────────────────────────────────────────
1230

1331

@@ -79,6 +97,7 @@ def _make_args(
7997
isolate=False,
8098
install_deps=False,
8199
dest_dir=None,
100+
generate_from_pypi=None,
82101
):
83102
return argparse.Namespace(
84103
target=".",
@@ -88,6 +107,7 @@ def _make_args(
88107
token_file=token_file,
89108
install_deps=install_deps,
90109
dest_dir=dest_dir,
110+
generate_from_pypi=generate_from_pypi,
91111
)
92112

93113
@patch(
@@ -418,3 +438,70 @@ def fake_apistub_run(exe, cmds, **kwargs):
418438
assert "--skip-pylint" not in captured_cmds[0]
419439
assert os.path.exists(os.path.join(str(tmp_path), "azure-core_python.json"))
420440
pwsh_run.assert_not_called()
441+
442+
@patch(
443+
"azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
444+
)
445+
@patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None)
446+
@patch("azpysdk.apistub.create_package_and_install")
447+
@patch("azpysdk.apistub.install_into_venv")
448+
@patch("azpysdk.apistub.set_envvar_defaults")
449+
def test_pypi_version_downloads_wheel_instead_of_building(
450+
self, _env, _install, create_package_and_install, _get_mapping, tmp_path, monkeypatch
451+
):
452+
"""When a PyPI version is passed, the wheel is downloaded from PyPI and local build is skipped."""
453+
monkeypatch.chdir(os.getcwd())
454+
stub = apistub()
455+
staging = str(tmp_path / "staging")
456+
os.makedirs(staging, exist_ok=True)
457+
fake_parsed = MagicMock()
458+
fake_parsed.folder = str(tmp_path)
459+
fake_parsed.name = "azure-core"
460+
461+
captured_cmds = []
462+
463+
def fake_apistub_run(exe, cmds, **kwargs):
464+
captured_cmds.append(cmds)
465+
out_idx = cmds.index("--out-path")
466+
out_dir = cmds[out_idx + 1]
467+
open(os.path.join(out_dir, "azure-core_python.json"), "w").close()
468+
469+
with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object(
470+
stub, "get_executable", return_value=(sys.executable, staging)
471+
), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object(
472+
stub, "ensure_apistub_dependencies"
473+
), patch.object(
474+
stub, "download_pypi_wheel", return_value="/fake/azure_core-1.0.0-py3-none-any.whl"
475+
) as download_pypi_wheel, patch.object(
476+
stub, "run_venv_command", side_effect=fake_apistub_run
477+
):
478+
stub.run(self._make_args(token_file=True, generate_from_pypi="1.0.0"))
479+
480+
download_pypi_wheel.assert_called_once_with(sys.executable, "azure-core", "1.0.0", staging)
481+
create_package_and_install.assert_not_called()
482+
assert len(captured_cmds) == 1
483+
pkg_idx = captured_cmds[0].index("--pkg-path")
484+
assert captured_cmds[0][pkg_idx + 1] == os.path.abspath("/fake/azure_core-1.0.0-py3-none-any.whl")
485+
486+
@patch("azpysdk.apistub.find_whl", return_value="azure_core-1.0.0-py3-none-any.whl")
487+
def test_download_pypi_wheel_runs_pip_download(self, _find_whl, tmp_path):
488+
"""download_pypi_wheel should pip download the wheel and return its path."""
489+
stub = apistub()
490+
staging = str(tmp_path)
491+
492+
with patch.object(stub, "run_venv_command") as run_venv_command:
493+
result = stub.download_pypi_wheel(sys.executable, "azure-core", "1.0.0", staging)
494+
495+
run_venv_command.assert_called_once()
496+
cmds = run_venv_command.call_args.args[1]
497+
assert cmds[0:4] == ["-m", "pip", "download", "azure-core==1.0.0"]
498+
assert "--no-deps" in cmds
499+
assert result == os.path.join(staging, "azure_core-1.0.0-py3-none-any.whl")
500+
501+
@patch("azpysdk.apistub.find_whl", return_value=None)
502+
def test_download_pypi_wheel_raises_when_no_wheel(self, _find_whl, tmp_path):
503+
"""download_pypi_wheel should raise FileNotFoundError when no wheel is downloaded."""
504+
stub = apistub()
505+
with patch.object(stub, "run_venv_command"):
506+
with pytest.raises(FileNotFoundError, match="No wheel found"):
507+
stub.download_pypi_wheel(sys.executable, "azure-core", "1.0.0", str(tmp_path))

0 commit comments

Comments
 (0)