Skip to content

Commit 56da647

Browse files
CopilotJennyPng
andauthored
Add --md flag to azpysdk apistub to generate api.md (#45759)
* Initial plan * Add --md flag to azpysdk apistub to generate api.md Co-authored-by: JennyPng <63012604+JennyPng@users.noreply.github.com> * minor * use abspath for dest dir * clarify error output * minor and add tests * revert apistub's chdir in test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JennyPng <63012604+JennyPng@users.noreply.github.com>
1 parent 5fe509b commit 56da647

2 files changed

Lines changed: 223 additions & 3 deletions

File tree

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sys
44

55
from typing import Optional, List
6-
from subprocess import CalledProcessError
6+
from subprocess import CalledProcessError, run
77

88
from .Check import Check
99
from ci_tools.functions import install_into_venv, find_whl
@@ -63,6 +63,13 @@ def register(
6363
default=None,
6464
help="Destination directory for generated API stub token files.",
6565
)
66+
p.add_argument(
67+
"--md",
68+
dest="generate_md",
69+
default=False,
70+
action="store_true",
71+
help="Generate api.md from the JSON token file using Export-APIViewMarkdown.ps1. Output directory for api.md is the same as the generated token file.",
72+
)
6673
p.set_defaults(func=self.run)
6774

6875
def run(self, args: argparse.Namespace) -> int:
@@ -125,7 +132,7 @@ def run(self, args: argparse.Namespace) -> int:
125132

126133
dest_dir = getattr(args, "dest_dir", None)
127134
if dest_dir:
128-
out_token_path = os.path.join(dest_dir, package_name)
135+
out_token_path = os.path.join(os.path.abspath(dest_dir), package_name)
129136
os.makedirs(out_token_path, exist_ok=True)
130137
else:
131138
out_token_path = os.path.abspath(staging_directory)
@@ -146,8 +153,32 @@ def run(self, args: argparse.Namespace) -> int:
146153

147154
try:
148155
self.run_venv_command(executable, cmds, cwd=staging_directory, check=True, immediately_dump=True)
156+
if getattr(args, "generate_md", False):
157+
token_json_path = os.path.join(out_token_path, f"{package_name}_python.json")
158+
md_script = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1")
159+
logger.info(f"Generating api.md for {package_name}")
160+
try:
161+
result = run(
162+
["pwsh", md_script, "-TokenJsonPath", token_json_path, "-OutputPath", out_token_path],
163+
check=True,
164+
capture_output=True,
165+
text=True,
166+
)
167+
# pwsh script logs the api.md location
168+
if result.stdout:
169+
logger.info(result.stdout)
170+
except FileNotFoundError:
171+
logger.error("Failed to generate api.md: pwsh (PowerShell) is not installed or not on PATH.")
172+
results.append(1)
173+
except CalledProcessError as e:
174+
logger.error(f"Failed to generate api.md (exit code {e.returncode}):")
175+
if e.stderr:
176+
logger.error(e.stderr)
177+
if e.stdout:
178+
logger.error(e.stdout)
179+
results.append(1)
149180
except CalledProcessError as e:
150-
logger.error(f"{package_name} exited with error {e.returncode}")
181+
logger.error(f"{package_name} exited with error {e.returncode}: {e}")
151182
results.append(e.returncode)
152183

153184
return max(results) if results else 0
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import argparse
2+
import os
3+
import sys
4+
import pytest
5+
6+
from unittest.mock import patch, MagicMock
7+
8+
from azpysdk.apistub import apistub, get_package_wheel_path, get_cross_language_mapping_path
9+
10+
11+
# ── get_package_wheel_path() ─────────────────────────────────────────────
12+
13+
14+
class TestGetPackageWheelPath:
15+
"""Test the prebuilt-wheel lookup, wheel-in-source-dir, and fallback logic."""
16+
17+
@patch("azpysdk.apistub.ParsedSetup")
18+
@patch("azpysdk.apistub.find_whl")
19+
def test_prebuilt_dir_returns_wheel(self, mock_find_whl, mock_parsed, tmp_path, monkeypatch):
20+
"""When PREBUILT_WHEEL_DIR is set and a wheel is found there, return its full path."""
21+
prebuilt = str(tmp_path / "prebuilt")
22+
os.makedirs(prebuilt, exist_ok=True)
23+
monkeypatch.setenv("PREBUILT_WHEEL_DIR", prebuilt)
24+
25+
mock_parsed.from_path.return_value = MagicMock(name="azure-core", version="1.0.0")
26+
mock_find_whl.return_value = "azure_core-1.0.0-py3-none-any.whl"
27+
28+
result = get_package_wheel_path("/some/pkg")
29+
assert result == os.path.join(prebuilt, "azure_core-1.0.0-py3-none-any.whl")
30+
31+
@patch("azpysdk.apistub.ParsedSetup")
32+
@patch("azpysdk.apistub.find_whl")
33+
def test_prebuilt_dir_raises_when_no_wheel(self, mock_find_whl, mock_parsed, tmp_path, monkeypatch):
34+
"""When PREBUILT_WHEEL_DIR is set but no matching wheel is found, raise FileNotFoundError."""
35+
prebuilt = str(tmp_path / "prebuilt")
36+
os.makedirs(prebuilt, exist_ok=True)
37+
monkeypatch.setenv("PREBUILT_WHEEL_DIR", prebuilt)
38+
39+
mock_parsed.from_path.return_value = MagicMock(name="azure-core", version="1.0.0")
40+
mock_find_whl.return_value = None
41+
42+
with pytest.raises(FileNotFoundError, match="No prebuilt wheel found"):
43+
get_package_wheel_path("/some/pkg")
44+
45+
@patch("azpysdk.apistub.ParsedSetup")
46+
@patch("azpysdk.apistub.find_whl")
47+
def test_no_prebuilt_dir_returns_found_whl(self, mock_find_whl, mock_parsed, monkeypatch):
48+
"""Without PREBUILT_WHEEL_DIR, return wheel found in pkg_root."""
49+
monkeypatch.delenv("PREBUILT_WHEEL_DIR", raising=False)
50+
51+
mock_parsed.from_path.return_value = MagicMock(name="azure-core", version="1.0.0")
52+
mock_find_whl.return_value = "azure_core-1.0.0-py3-none-any.whl"
53+
54+
result = get_package_wheel_path("/my/pkg")
55+
assert result == "azure_core-1.0.0-py3-none-any.whl"
56+
57+
@patch("azpysdk.apistub.ParsedSetup")
58+
@patch("azpysdk.apistub.find_whl")
59+
def test_no_prebuilt_dir_falls_back_to_pkg_root(self, mock_find_whl, mock_parsed, monkeypatch):
60+
"""Without PREBUILT_WHEEL_DIR and no wheel found, fall back to pkg_root path."""
61+
monkeypatch.delenv("PREBUILT_WHEEL_DIR", raising=False)
62+
63+
mock_parsed.from_path.return_value = MagicMock(name="azure-core", version="1.0.0")
64+
mock_find_whl.return_value = None
65+
66+
result = get_package_wheel_path("/my/pkg")
67+
assert result == "/my/pkg"
68+
69+
70+
# ── run() output directory logic ─────────────────────────────────────────
71+
72+
73+
class TestRunOutputDirectory:
74+
"""Verify that dest_dir controls where the output token path ends up."""
75+
76+
def _make_args(self, dest_dir=None, generate_md=False):
77+
return argparse.Namespace(
78+
target=".",
79+
isolate=False,
80+
command="apistub",
81+
service=None,
82+
dest_dir=dest_dir,
83+
generate_md=generate_md,
84+
)
85+
86+
@patch(
87+
"azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
88+
)
89+
@patch("azpysdk.apistub.MAX_PYTHON_VERSION", (99, 99))
90+
@patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None)
91+
@patch("azpysdk.apistub.get_package_wheel_path", return_value="/fake/pkg.whl")
92+
@patch("azpysdk.apistub.create_package_and_install")
93+
@patch("azpysdk.apistub.install_into_venv")
94+
@patch("azpysdk.apistub.set_envvar_defaults")
95+
def test_dest_dir_creates_package_subfolder(
96+
self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch
97+
):
98+
"""When --dest-dir is given, output should go to <dest_dir>/<package_name>/."""
99+
monkeypatch.chdir(os.getcwd())
100+
dest = tmp_path / "output"
101+
dest.mkdir()
102+
103+
stub = apistub()
104+
staging = str(tmp_path / "staging")
105+
os.makedirs(staging, exist_ok=True)
106+
fake_parsed = MagicMock()
107+
fake_parsed.folder = str(tmp_path)
108+
fake_parsed.name = "azure-core"
109+
110+
def fake_apistub_run(exe, cmds, **kwargs):
111+
# Simulate apistub generating the token JSON
112+
out_idx = cmds.index("--out-path")
113+
out_dir = cmds[out_idx + 1]
114+
os.makedirs(out_dir, exist_ok=True)
115+
open(os.path.join(out_dir, "azure-core_python.json"), "w").close()
116+
117+
def fake_pwsh(cmd, **kwargs):
118+
# Simulate pwsh generating api.md
119+
out_idx = cmd.index("-OutputPath")
120+
out_dir = cmd[out_idx + 1]
121+
open(os.path.join(out_dir, "api.md"), "w").close()
122+
return MagicMock(returncode=0)
123+
124+
with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object(
125+
stub, "get_executable", return_value=(sys.executable, staging)
126+
), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object(
127+
stub, "run_venv_command", side_effect=fake_apistub_run
128+
), patch(
129+
"azpysdk.apistub.run", side_effect=fake_pwsh
130+
):
131+
132+
stub.run(self._make_args(dest_dir=str(dest), generate_md=True))
133+
134+
expected_out = os.path.join(str(dest), "azure-core")
135+
assert os.path.isdir(expected_out)
136+
assert os.path.exists(os.path.join(expected_out, "api.md"))
137+
assert os.path.exists(os.path.join(expected_out, "azure-core_python.json"))
138+
139+
@patch(
140+
"azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
141+
)
142+
@patch("azpysdk.apistub.MAX_PYTHON_VERSION", (99, 99))
143+
@patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None)
144+
@patch("azpysdk.apistub.get_package_wheel_path", return_value="/fake/pkg.whl")
145+
@patch("azpysdk.apistub.create_package_and_install")
146+
@patch("azpysdk.apistub.install_into_venv")
147+
@patch("azpysdk.apistub.set_envvar_defaults")
148+
def test_no_dest_dir_uses_staging(self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch):
149+
"""When --dest-dir is not given, output path should be the staging directory."""
150+
monkeypatch.chdir(os.getcwd())
151+
stub = apistub()
152+
staging = str(tmp_path / "staging")
153+
os.makedirs(staging, exist_ok=True)
154+
fake_parsed = MagicMock()
155+
fake_parsed.folder = str(tmp_path)
156+
fake_parsed.name = "azure-core"
157+
158+
captured_cmds = []
159+
160+
def fake_apistub_run(exe, cmds, **kwargs):
161+
captured_cmds.append(cmds)
162+
# Simulate apistub generating the token JSON
163+
out_idx = cmds.index("--out-path")
164+
out_dir = cmds[out_idx + 1]
165+
open(os.path.join(out_dir, "azure-core_python.json"), "w").close()
166+
167+
def fake_pwsh(cmd, **kwargs):
168+
out_idx = cmd.index("-OutputPath")
169+
out_dir = cmd[out_idx + 1]
170+
open(os.path.join(out_dir, "api.md"), "w").close()
171+
return MagicMock(returncode=0)
172+
173+
with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object(
174+
stub, "get_executable", return_value=(sys.executable, staging)
175+
), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object(
176+
stub, "run_venv_command", side_effect=fake_apistub_run
177+
), patch(
178+
"azpysdk.apistub.run", side_effect=fake_pwsh
179+
):
180+
181+
stub.run(self._make_args(dest_dir=None, generate_md=True))
182+
183+
# The --out-path passed to apistub should be the staging directory
184+
assert len(captured_cmds) == 1
185+
cmds = captured_cmds[0]
186+
out_idx = cmds.index("--out-path")
187+
assert cmds[out_idx + 1] == os.path.abspath(staging)
188+
assert os.path.exists(os.path.join(staging, "api.md"))
189+
assert os.path.exists(os.path.join(staging, "azure-core_python.json"))

0 commit comments

Comments
 (0)