diff --git a/.github/skills/generate-api-markdown/SKILL.md b/.github/skills/generate-api-markdown/SKILL.md index dbf28fcbb551..5d55e93748cc 100644 --- a/.github/skills/generate-api-markdown/SKILL.md +++ b/.github/skills/generate-api-markdown/SKILL.md @@ -19,6 +19,7 @@ description: Generate an API markdown file and token file using ApiView. Use thi 1. Navigate to the desired package directory 2. Run the command: ```bash - azpysdk apistub --md --extract-metadata --install-deps --dest-dir . . + azpysdk apistub . ``` -3. The command outputs the location of the generated markdown file. Provide this file to the user for review. \ No newline at end of file +3. The command generates `api.md` and `api.metadata.yml` in the package directory, which are the files needed to pass the API consistency check. Provide these files to the user for review. +4. If the user explicitly asks for the raw APIView token file, run `azpysdk apistub . --token-file` instead. \ No newline at end of file diff --git a/.github/workflows/src/api-md-consistency/adapters/python.js b/.github/workflows/src/api-md-consistency/adapters/python.js index aace3e8fbea9..f828db5d9248 100644 --- a/.github/workflows/src/api-md-consistency/adapters/python.js +++ b/.github/workflows/src/api-md-consistency/adapters/python.js @@ -131,7 +131,7 @@ function generateApiForPackage({ const pythonExecutable = runtimeExecutable || process.env.RUNTIME_EXECUTABLE; run( pythonExecutable, - ["-m", "azpysdk.main", "apistub", "--md", "--extract-metadata", "--dest-dir", packageDir, packageName], + ["-m", "azpysdk.main", "apistub", packageName], { cwd: repoRoot, check: true, @@ -141,7 +141,7 @@ function generateApiForPackage({ return; } - run("azpysdk", ["apistub", "--md", "--extract-metadata", "--dest-dir", packageDir, packageName], { + run("azpysdk", ["apistub", packageName], { cwd: repoRoot, check: true, logger: activeLogger, diff --git a/.github/workflows/src/api-md-consistency/api-md-consistency.js b/.github/workflows/src/api-md-consistency/api-md-consistency.js index e7635e68b199..5350c61f0b0e 100644 --- a/.github/workflows/src/api-md-consistency/api-md-consistency.js +++ b/.github/workflows/src/api-md-consistency/api-md-consistency.js @@ -47,8 +47,8 @@ function formatIssueSection(title, apiFiles) { lines.push(styleLog(`PACKAGE: ${packageName}`, ANSI.bold, ANSI.cyan)); lines.push(`PATH: ${packageDir}`); lines.push(`API FILE: ${apiFile}`); - lines.push(styleLog("Regenerate from the repository root:", ANSI.bold, ANSI.yellow)); - lines.push(styleLog(` azpysdk apistub --md --extract-metadata ${packageName} --dest-dir .`, ANSI.bold, ANSI.yellow)); + lines.push(styleLog(`Regenerate from the ${packageName} package root:`, ANSI.bold, ANSI.yellow)); + lines.push(styleLog(` azpysdk apistub .`, ANSI.bold, ANSI.yellow)); lines.push("============================================================"); } lines.push(""); @@ -97,7 +97,7 @@ export default async function apiMdConsistency({ core }) { "", formatIssueSection("Mismatched packages:", mismatches), formatIssueSection("Missing required API files:", missing), - "To regenerate api.md locally, run the command shown for each package from the repository root.", + "To regenerate api.md and api.metadata.yml locally, run the command shown for each package from the repository root.", ].filter((part) => part !== ""); core.setFailed(messageParts.join("\n")); diff --git a/doc/dev/tests.md b/doc/dev/tests.md index 14caabaa25dc..4e3bb6bfcf47 100644 --- a/doc/dev/tests.md +++ b/doc/dev/tests.md @@ -147,7 +147,7 @@ A quick description of the commands above: - verifywhl: verifies the wheel contents and manifest - verifysdist: verifies the sdist contents and manifest - samples: runs all of the samples in the `samples` directory and verifies they are working correctly -- apistub: runs the [apistubgenerator](https://github.com/Azure/azure-sdk-tools/tree/main/packages/python-packages/apiview-stub-generator) tool on your code +- apistub: runs the [apistubgenerator](https://github.com/Azure/azure-sdk-tools/tree/main/packages/python-packages/apiview-stub-generator) tool on your code. By default, generates `api.md` and `api.metadata.yml` in the package directory, which are the files needed to pass the API consistency check. Use `--token-file` to generate only the raw APIView token file. ## The `devtools_testutils` package diff --git a/doc/tool_usage_guide.md b/doc/tool_usage_guide.md index f9aed99aad07..e36b59f6b7f5 100644 --- a/doc/tool_usage_guide.md +++ b/doc/tool_usage_guide.md @@ -21,7 +21,7 @@ The following checks are available via the `azpysdk` entrypoint. |`pyright`| Runs `pyright` checks or `next-pyright` checks. (based on presence of `--next` argument) | `azpysdk pyright .` | |`black`| Runs `black` checks. | `azpysdk black .` | |`verifytypes`| Runs `verifytypes` checks. | `azpysdk verifytypes .` | -|`apistub`| Generates an api stub for the package. | `azpysdk apistub .` | +|`apistub`| Generates `api.md`, API metadata, or APIView token files for the package. | `azpysdk apistub .` | |`bandit`| Runs `bandit` checks, which detect common security issues. | `azpysdk bandit .` | |`verifywhl`| Verifies that the root directory in whl is azure, and verifies manifest so that all directories in source are included in sdist. | `azpysdk verifywhl .` | |`verifysdist`| Verify directories included in sdist and contents in manifest file. Also ensures that py.typed configuration is correct within the setup.py. | `azpysdk verifysdist .` | diff --git a/eng/tools/azure-sdk-tools/azpysdk/apistub.py b/eng/tools/azure-sdk-tools/azpysdk/apistub.py index e52e665f0fb7..f707ab449a8f 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/apistub.py +++ b/eng/tools/azure-sdk-tools/azpysdk/apistub.py @@ -57,24 +57,11 @@ def register( "apistub", parents=parents, help="Run the apistub check to generate an API stub for a package" ) p.add_argument( - "--dest-dir", - dest="dest_dir", - default=None, - help="Destination directory for generated API stub token files.", - ) - p.add_argument( - "--md", - dest="generate_md", - default=False, - action="store_true", - 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.", - ) - p.add_argument( - "--extract-metadata", - dest="extract_metadata", + "--token-file", + dest="token_file", default=False, action="store_true", - help="Extract language-specific metadata from generated api.md into api.metadata.yml and remove metadata header from api.md.", + help="Generate only the raw APIView token file.", ) p.add_argument( "--install-deps", @@ -106,9 +93,8 @@ def run(self, args: argparse.Namespace) -> int: """Run the apistub check command.""" logger.info("Running apistub check...") - if getattr(args, "extract_metadata", False) and not getattr(args, "generate_md", False): - logger.error("--extract-metadata requires --md.") - return 1 + token_file = getattr(args, "token_file", False) + generate_markdown = not token_file set_envvar_defaults() targeted = self.get_targeted_directories(args) @@ -160,12 +146,8 @@ def run(self, args: argparse.Namespace) -> int: pkg_path = get_package_wheel_path(package_dir) pkg_path = os.path.abspath(pkg_path) - dest_dir = getattr(args, "dest_dir", None) - if dest_dir: - out_token_path = os.path.abspath(dest_dir) - os.makedirs(out_token_path, exist_ok=True) - else: - out_token_path = os.path.abspath(staging_directory) + out_token_path = os.path.abspath(package_dir) + os.makedirs(out_token_path, exist_ok=True) cross_language_mapping_path = get_cross_language_mapping_path(package_dir) @@ -178,15 +160,21 @@ def run(self, args: argparse.Namespace) -> int: cmds.extend(["--out-path", out_token_path]) if cross_language_mapping_path: cmds.extend(["--mapping-path", cross_language_mapping_path]) - if getattr(args, "generate_md", False): + if generate_markdown: cmds.append("--skip-pylint") logger.info("Running apistub {}.".format(cmds)) try: self.run_venv_command(executable, cmds, cwd=staging_directory, check=True, immediately_dump=True) - if getattr(args, "generate_md", False): - token_json_path = os.path.join(out_token_path, f"{package_name}_python.json") + token_json_path = os.path.join(out_token_path, f"{package_name}_python.json") + if token_file: + if os.path.exists(token_json_path): + logger.info(f"Generated APIView token file: {token_json_path}") + else: + logger.error(f"Expected APIView token file was not generated: {token_json_path}") + results.append(1) + else: md_script = os.path.join(REPO_ROOT, "eng", "common", "scripts", "Export-APIViewMarkdown.ps1") metadata_script = os.path.join(REPO_ROOT, "eng", "scripts", "Extract-APIViewMetadata-Python.ps1") logger.info(f"Generating api.md for {package_name}") @@ -201,16 +189,15 @@ def run(self, args: argparse.Namespace) -> int: if result.stdout: logger.info(result.stdout) - if getattr(args, "extract_metadata", False): - logger.info(f"Extracting API metadata for {package_name}") - metadata_result = run( - ["pwsh", metadata_script, "-OutputPath", out_token_path], - check=True, - capture_output=True, - text=True, - ) - if metadata_result.stdout: - logger.info(metadata_result.stdout) + logger.info(f"Extracting API metadata for {package_name}") + metadata_result = run( + ["pwsh", metadata_script, "-OutputPath", out_token_path], + check=True, + capture_output=True, + text=True, + ) + if metadata_result.stdout: + logger.info(metadata_result.stdout) except FileNotFoundError: logger.error("Failed to generate api.md: pwsh (PowerShell) is not installed or not on PATH.") results.append(1) diff --git a/eng/tools/azure-sdk-tools/ci_tools/functions.py b/eng/tools/azure-sdk-tools/ci_tools/functions.py index 417f6c1fe724..c2cc9cdcb216 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/functions.py +++ b/eng/tools/azure-sdk-tools/ci_tools/functions.py @@ -182,15 +182,19 @@ def glob_packages(glob_string: str, target_root_dir: str) -> List[str]: collected_top_level_directories = [] for glob_string in individual_globs: - globbed = glob.glob(os.path.join(target_root_dir, glob_string, "setup.py"), recursive=True) + glob.glob( - os.path.join(target_root_dir, "sdk/*/", glob_string, "setup.py") + globbed = ( + glob.glob(os.path.join(target_root_dir, glob_string, "setup.py"), recursive=True) + + glob.glob(os.path.join(target_root_dir, "*/", glob_string, "setup.py")) + + glob.glob(os.path.join(target_root_dir, "sdk/*/", glob_string, "setup.py")) ) collected_top_level_directories.extend([os.path.dirname(p) for p in globbed]) # handle pyproject.toml separately, as we need to filter them by the presence of a `[project]` section for glob_string in individual_globs: - globbed = glob.glob(os.path.join(target_root_dir, glob_string, "pyproject.toml"), recursive=True) + glob.glob( - os.path.join(target_root_dir, "sdk/*/", glob_string, "pyproject.toml") + globbed = ( + glob.glob(os.path.join(target_root_dir, glob_string, "pyproject.toml"), recursive=True) + + glob.glob(os.path.join(target_root_dir, "*/", glob_string, "pyproject.toml")) + + glob.glob(os.path.join(target_root_dir, "sdk/*/", glob_string, "pyproject.toml")) ) for p in globbed: if get_pyproject(os.path.dirname(p)): diff --git a/eng/tools/azure-sdk-tools/packaging_tools/sdk_generator.py b/eng/tools/azure-sdk-tools/packaging_tools/sdk_generator.py index cb5c824a8dff..ab215cf27f44 100644 --- a/eng/tools/azure-sdk-tools/packaging_tools/sdk_generator.py +++ b/eng/tools/azure-sdk-tools/packaging_tools/sdk_generator.py @@ -335,11 +335,7 @@ def main(generate_input, generate_output): cmds = [ "azpysdk", "apistub", - "--md", - "--extract-metadata", package_name, - "--dest-dir", - package_path.absolute().as_posix(), ] _LOGGER.info(f"generate apiview file for package {package_name}") check_call( diff --git a/eng/tools/azure-sdk-tools/tests/integration/test_package_discovery.py b/eng/tools/azure-sdk-tools/tests/integration/test_package_discovery.py index feee5bf6ae74..7bcace04b9b7 100644 --- a/eng/tools/azure-sdk-tools/tests/integration/test_package_discovery.py +++ b/eng/tools/azure-sdk-tools/tests/integration/test_package_discovery.py @@ -3,7 +3,6 @@ from ci_tools.parsing import ParsedSetup from ci_tools.functions import discover_targeted_packages - repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")) sdk_root = os.path.join(repo_root, "sdk") core_service_root = os.path.join(sdk_root, "core") @@ -70,6 +69,22 @@ def test_discovery_single_package(): ] +def test_discovery_single_package_from_sdk_root(): + results = discover_targeted_packages("azure-template", sdk_root, filter_type="Build") + + assert [os.path.basename(result) for result in results] == [ + "azure-template", + ] + + +def test_discovery_single_package_from_repo_root(): + results = discover_targeted_packages("azure-template", repo_root, filter_type="Build") + + assert [os.path.basename(result) for result in results] == [ + "azure-template", + ] + + def test_discovery_omit_regression(): results = discover_targeted_packages("*", core_service_root, filter_type="Regression") diff --git a/eng/tools/azure-sdk-tools/tests/test_apistub.py b/eng/tools/azure-sdk-tools/tests/test_apistub.py index 21d85998eb2b..dcc3cd040ba8 100644 --- a/eng/tools/azure-sdk-tools/tests/test_apistub.py +++ b/eng/tools/azure-sdk-tools/tests/test_apistub.py @@ -71,16 +71,20 @@ def test_no_prebuilt_dir_falls_back_to_pkg_root(self, mock_find_whl, mock_parsed class TestRunOutputDirectory: - """Verify that dest_dir controls where the output token path ends up.""" + """Verify apistub output directory behavior.""" - def _make_args(self, dest_dir=None, generate_md=False, isolate=False, install_deps=False): + def _make_args( + self, + token_file=False, + isolate=False, + install_deps=False, + ): return argparse.Namespace( target=".", isolate=isolate, command="apistub", service=None, - dest_dir=dest_dir, - generate_md=generate_md, + token_file=token_file, install_deps=install_deps, ) @@ -111,7 +115,7 @@ def test_isolate_does_not_install_dependencies( ) as pip_freeze, patch.object( stub, "run_venv_command" ): - stub.run(self._make_args(isolate=True)) + stub.run(self._make_args(isolate=True, token_file=True)) install_dev_reqs.assert_not_called() install_into_venv.assert_not_called() @@ -144,7 +148,7 @@ def test_install_deps_installs_dependencies( ) as pip_freeze, patch.object( stub, "run_venv_command" ): - args = self._make_args(install_deps=True) + args = self._make_args(install_deps=True, token_file=True) stub.run(args) install_dev_reqs.assert_called_once_with(sys.executable, args, str(tmp_path)) @@ -219,62 +223,10 @@ def test_missing_apistub_installs_apiview_requirements(self, install_into_venv, @patch("azpysdk.apistub.create_package_and_install") @patch("azpysdk.apistub.install_into_venv") @patch("azpysdk.apistub.set_envvar_defaults") - def test_dest_dir_uses_destination_directory( + def test_outputs_use_package_directory( self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch ): - """When --dest-dir is given, output should go directly to /.""" - monkeypatch.chdir(os.getcwd()) - dest = tmp_path / "output" - dest.mkdir() - - stub = apistub() - staging = str(tmp_path / "staging") - os.makedirs(staging, exist_ok=True) - fake_parsed = MagicMock() - fake_parsed.folder = str(tmp_path) - fake_parsed.name = "azure-core" - - def fake_apistub_run(exe, cmds, **kwargs): - # Simulate apistub generating the token JSON - out_idx = cmds.index("--out-path") - out_dir = cmds[out_idx + 1] - os.makedirs(out_dir, exist_ok=True) - open(os.path.join(out_dir, "azure-core_python.json"), "w").close() - - def fake_pwsh(cmd, **kwargs): - # Simulate pwsh generating api.md - out_idx = cmd.index("-OutputPath") - out_dir = cmd[out_idx + 1] - open(os.path.join(out_dir, "api.md"), "w").close() - return MagicMock(returncode=0) - - with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( - stub, "get_executable", return_value=(sys.executable, staging) - ), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object( - stub, "ensure_apistub_dependencies" - ), patch.object( - stub, "run_venv_command", side_effect=fake_apistub_run - ), patch( - "azpysdk.apistub.run", side_effect=fake_pwsh - ): - - stub.run(self._make_args(dest_dir=str(dest), generate_md=True)) - - expected_out = str(dest) - assert os.path.isdir(expected_out) - assert os.path.exists(os.path.join(expected_out, "api.md")) - assert os.path.exists(os.path.join(expected_out, "azure-core_python.json")) - - @patch( - "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) - ) - @patch("azpysdk.apistub.get_cross_language_mapping_path", return_value=None) - @patch("azpysdk.apistub.get_package_wheel_path", return_value="/fake/pkg.whl") - @patch("azpysdk.apistub.create_package_and_install") - @patch("azpysdk.apistub.install_into_venv") - @patch("azpysdk.apistub.set_envvar_defaults") - def test_no_dest_dir_uses_staging(self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch): - """When --dest-dir is not given, output path should be the staging directory.""" + """Output should go to the package directory.""" monkeypatch.chdir(os.getcwd()) stub = apistub() staging = str(tmp_path / "staging") @@ -287,7 +239,6 @@ def test_no_dest_dir_uses_staging(self, _env, _install, _create, _get_whl, _get_ def fake_apistub_run(exe, cmds, **kwargs): captured_cmds.append(cmds) - # Simulate apistub generating the token JSON out_idx = cmds.index("--out-path") out_dir = cmds[out_idx + 1] open(os.path.join(out_dir, "azure-core_python.json"), "w").close() @@ -295,7 +246,10 @@ def fake_apistub_run(exe, cmds, **kwargs): def fake_pwsh(cmd, **kwargs): out_idx = cmd.index("-OutputPath") out_dir = cmd[out_idx + 1] - open(os.path.join(out_dir, "api.md"), "w").close() + if "Extract-APIViewMetadata-Python.ps1" in cmd[1]: + open(os.path.join(out_dir, "api.metadata.yml"), "w").close() + else: + open(os.path.join(out_dir, "api.md"), "w").close() return MagicMock(returncode=0) with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( @@ -308,15 +262,16 @@ def fake_pwsh(cmd, **kwargs): "azpysdk.apistub.run", side_effect=fake_pwsh ): - stub.run(self._make_args(dest_dir=None, generate_md=True)) + stub.run(self._make_args()) - # The --out-path passed to apistub should be the staging directory + # The --out-path passed to apistub should be the package directory assert len(captured_cmds) == 1 cmds = captured_cmds[0] out_idx = cmds.index("--out-path") - assert cmds[out_idx + 1] == os.path.abspath(staging) - assert os.path.exists(os.path.join(staging, "api.md")) - assert os.path.exists(os.path.join(staging, "azure-core_python.json")) + assert cmds[out_idx + 1] == os.path.abspath(str(tmp_path)) + assert os.path.exists(os.path.join(str(tmp_path), "api.md")) + assert os.path.exists(os.path.join(str(tmp_path), "api.metadata.yml")) + assert os.path.exists(os.path.join(str(tmp_path), "azure-core_python.json")) @patch( "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) @@ -326,8 +281,10 @@ def fake_pwsh(cmd, **kwargs): @patch("azpysdk.apistub.create_package_and_install") @patch("azpysdk.apistub.install_into_venv") @patch("azpysdk.apistub.set_envvar_defaults") - def test_generate_md_adds_skip_pylint(self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch): - """When --md is passed (generate_md=True), --skip-pylint must be in the cmds.""" + def test_default_generation_adds_skip_pylint( + self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch + ): + """By default, markdown generation should add --skip-pylint to the cmds.""" monkeypatch.chdir(os.getcwd()) stub = apistub() staging = str(tmp_path / "staging") @@ -358,7 +315,7 @@ def fake_pwsh(cmd, **kwargs): ), patch( "azpysdk.apistub.run", side_effect=fake_pwsh ): - stub.run(self._make_args(generate_md=True)) + stub.run(self._make_args()) assert len(captured_cmds) == 1 assert "--skip-pylint" in captured_cmds[0] @@ -371,10 +328,10 @@ def fake_pwsh(cmd, **kwargs): @patch("azpysdk.apistub.create_package_and_install") @patch("azpysdk.apistub.install_into_venv") @patch("azpysdk.apistub.set_envvar_defaults") - def test_no_generate_md_omits_skip_pylint( + def test_token_file_omits_skip_pylint_and_markdown_generation( self, _env, _install, _create, _get_whl, _get_mapping, tmp_path, monkeypatch ): - """When --md is not passed (generate_md=False), --skip-pylint must not be in the cmds.""" + """When --token-file is passed, only the raw token file should be generated.""" monkeypatch.chdir(os.getcwd()) stub = apistub() staging = str(tmp_path / "staging") @@ -387,6 +344,9 @@ def test_no_generate_md_omits_skip_pylint( def fake_apistub_run(exe, cmds, **kwargs): captured_cmds.append(cmds) + out_idx = cmds.index("--out-path") + out_dir = cmds[out_idx + 1] + open(os.path.join(out_dir, "azure-core_python.json"), "w").close() with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( stub, "get_executable", return_value=(sys.executable, staging) @@ -394,8 +354,12 @@ def fake_apistub_run(exe, cmds, **kwargs): stub, "ensure_apistub_dependencies" ), patch.object( stub, "run_venv_command", side_effect=fake_apistub_run - ): - stub.run(self._make_args(generate_md=False)) + ), patch( + "azpysdk.apistub.run" + ) as pwsh_run: + stub.run(self._make_args(token_file=True)) assert len(captured_cmds) == 1 assert "--skip-pylint" not in captured_cmds[0] + assert os.path.exists(os.path.join(str(tmp_path), "azure-core_python.json")) + pwsh_run.assert_not_called() diff --git a/scripts/api_md_workflow/create_api_review_pr.py b/scripts/api_md_workflow/create_api_review_pr.py index 057fd406a30d..d946e835c511 100644 --- a/scripts/api_md_workflow/create_api_review_pr.py +++ b/scripts/api_md_workflow/create_api_review_pr.py @@ -521,8 +521,6 @@ def generate_api_for_package( "-m", "azpysdk.main", "apistub", - "--md", - "--extract-metadata", "--dest-dir", str(package_dir), package_name, @@ -535,8 +533,6 @@ def generate_api_for_package( [ "azpysdk", "apistub", - "--md", - "--extract-metadata", "--dest-dir", str(package_dir), package_name,