From d9e40e48a4981fd3b20c2df7affe925a6d0dbc52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:53:38 +0000 Subject: [PATCH 1/2] Initial plan From 245151245a065101685d24bf6f792ff619576c54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:59:54 +0000 Subject: [PATCH 2/2] Add build_tests and create_samples skills to azpysdk CLI Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/6c11cb61-4032-4ed0-9cef-91775d2e9434 --- .../azure-sdk-tools/azpysdk/build_tests.py | 77 ++++++++ .../azure-sdk-tools/azpysdk/create_samples.py | 165 ++++++++++++++++++ eng/tools/azure-sdk-tools/azpysdk/main.py | 4 + .../tests/test_build_interactions.py | 114 +++++++++++- 4 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 eng/tools/azure-sdk-tools/azpysdk/build_tests.py create mode 100644 eng/tools/azure-sdk-tools/azpysdk/create_samples.py diff --git a/eng/tools/azure-sdk-tools/azpysdk/build_tests.py b/eng/tools/azure-sdk-tools/azpysdk/build_tests.py new file mode 100644 index 000000000000..f6f03adac5f0 --- /dev/null +++ b/eng/tools/azure-sdk-tools/azpysdk/build_tests.py @@ -0,0 +1,77 @@ +import argparse +from typing import List, Optional + +from .install_and_test import InstallAndTest +from ci_tools.logging import logger + + +class build_tests(InstallAndTest): + """Build and install a package's test environment without running pytest. + + This check installs packaging tools, test tools (from eng/test_tools.txt), + dev requirements, and builds/installs the package as a wheel — but does NOT + invoke pytest or coverage. Useful for pre-validating the test environment in + isolation (e.g. to surface dependency or build errors before running tests). + """ + + def __init__(self) -> None: + super().__init__( + package_type="wheel", + proxy_url=None, + display_name="build-tests", + coverage_enabled=False, + ) + + def register( + self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None + ) -> None: + """Register the ``build-tests`` subcommand. + + The ``build-tests`` check installs packaging tools, test tools, dev + requirements, and builds/installs the target package as a wheel without + running pytest or coverage. + """ + parents = parent_parsers or [] + p = subparsers.add_parser( + "build-tests", + parents=parents, + help=( + "Build the test environment for a package (installs deps, builds wheel) " + "without running pytest. Useful for pre-validating the test environment." + ), + ) + p.set_defaults(func=self.run) + + def run(self, args: argparse.Namespace) -> int: + """Install requirements and build/install the package; skip pytest and coverage.""" + import os + import sys + + logger.info("Running build-tests check...") + + targeted = self.get_targeted_directories(args) + if not targeted: + logger.warning("No target packages discovered for build-tests check.") + return 0 + + results: List[int] = [] + + for parsed in targeted: + if os.getcwd() != parsed.folder: + os.chdir(parsed.folder) + package_dir = parsed.folder + package_name = parsed.name + + executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir) + logger.info(f"Processing {package_name} using interpreter {executable}") + + install_result = self.install_all_requirements( + executable, staging_directory, package_name, package_dir, args + ) + if install_result != 0: + logger.error(f"build-tests FAILED for {package_name} (exit code {install_result}).") + results.append(install_result) + else: + logger.info(f"build-tests SUCCEEDED for {package_name}.") + + return max(results) if results else 0 diff --git a/eng/tools/azure-sdk-tools/azpysdk/create_samples.py b/eng/tools/azure-sdk-tools/azpysdk/create_samples.py new file mode 100644 index 000000000000..a78b57fa91e6 --- /dev/null +++ b/eng/tools/azure-sdk-tools/azpysdk/create_samples.py @@ -0,0 +1,165 @@ +import argparse +import os +from typing import List, Optional + +from .Check import Check +from ci_tools.logging import logger + +_README_TEMPLATE = """\ +# {package_name} Samples + +This directory contains samples for the `{package_name}` package. + +## Getting started + +Install the package and its dependencies: + +```bash +pip install {package_name} +``` + +## Running the samples + +```bash +python sample_hello_world.py +``` + +## Advanced code generation + +To generate a fuller set of samples and SDK code from a TypeSpec or Swagger +spec, use the `azpysdk generate` command: + +```bash +azpysdk generate +``` + +See `azpysdk generate --help` for more information. +""" + +_HELLO_WORLD_TEMPLATE = """\ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +\"\"\" +FILE: sample_hello_world.py + +DESCRIPTION: + A minimal sample for the {package_name} package. + + To learn how to generate a fuller set of samples from a TypeSpec or + Swagger spec, run: + + azpysdk generate +\"\"\" + + +def main(): + # TODO: replace with real usage of {package_name} + print("Hello from {package_name}!") + + +if __name__ == "__main__": + main() +""" + + +class create_samples(Check): + """Generate a starter samples scaffold for a targeted package. + + For each targeted package this check: + + * Creates a ``samples/`` directory if one does not already exist. + * Generates a starter ``README.md`` inside ``samples/`` (if absent). + * Generates a starter ``sample_hello_world.py`` inside ``samples/`` (if absent). + + For full SDK code generation (including samples) from a TypeSpec or Swagger + spec, use the existing ``azpysdk generate`` command:: + + azpysdk generate + """ + + def __init__(self) -> None: + super().__init__() + + def register( + self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None + ) -> None: + """Register the ``create-samples`` subcommand. + + Creates a starter ``samples/`` scaffold (README + hello-world sample) + for each targeted package. For full SDK code generation from a + TypeSpec/Swagger spec use ``azpysdk generate ``. + """ + parents = parent_parsers or [] + p = subparsers.add_parser( + "create-samples", + parents=parents, + help=( + "Generate a starter samples scaffold (README.md, sample_hello_world.py) " + "for each targeted package. For full code generation from a TypeSpec or " + "Swagger spec, use: azpysdk generate " + ), + ) + p.set_defaults(func=self.run) + p.add_argument( + "--output-dir", + default=None, + help=( + "Directory to write the samples scaffold into. " + "Defaults to /samples." + ), + ) + + def run(self, args: argparse.Namespace) -> int: + """Create a samples scaffold for each targeted package.""" + logger.info("Running create-samples check...") + + targeted = self.get_targeted_directories(args) + if not targeted: + logger.warning("No target packages discovered for create-samples check.") + return 0 + + results: List[int] = [] + + for parsed in targeted: + package_dir = parsed.folder + package_name = parsed.name + + output_dir = getattr(args, "output_dir", None) or os.path.join(package_dir, "samples") + + try: + self._create_scaffold(package_name, output_dir) + logger.info( + f"create-samples SUCCEEDED for {package_name}. " + f"Samples scaffold written to: {output_dir}" + ) + logger.info( + "Tip: for full SDK code generation from a TypeSpec or Swagger spec, run: " + f"azpysdk generate {package_dir}" + ) + except Exception as exc: + logger.error(f"create-samples FAILED for {package_name}: {exc}") + results.append(1) + + return max(results) if results else 0 + + def _create_scaffold(self, package_name: str, output_dir: str) -> None: + """Create the samples directory and starter files.""" + os.makedirs(output_dir, exist_ok=True) + + readme_path = os.path.join(output_dir, "README.md") + if not os.path.exists(readme_path): + with open(readme_path, "w", encoding="utf-8") as f: + f.write(_README_TEMPLATE.format(package_name=package_name)) + logger.info(f"Created {readme_path}") + else: + logger.info(f"Skipping {readme_path} (already exists)") + + hello_world_path = os.path.join(output_dir, "sample_hello_world.py") + if not os.path.exists(hello_world_path): + with open(hello_world_path, "w", encoding="utf-8") as f: + f.write(_HELLO_WORLD_TEMPLATE.format(package_name=package_name)) + logger.info(f"Created {hello_world_path}") + else: + logger.info(f"Skipping {hello_world_path} (already exists)") diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index af770f932e21..af9d16305ee8 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -31,6 +31,7 @@ from .sdist import sdist from .whl_no_aio import whl_no_aio from .verify_whl import verify_whl +from .build_tests import build_tests from .bandit import bandit from .verify_keywords import verify_keywords from .generate import generate @@ -38,6 +39,7 @@ from .mindependency import mindependency from .latestdependency import latestdependency from .samples import samples +from .create_samples import create_samples from .devtest import devtest from .optional import optional from .update_snippet import update_snippet @@ -107,6 +109,7 @@ def build_parser() -> argparse.ArgumentParser: sdist().register(subparsers, [common]) whl_no_aio().register(subparsers, [common]) verify_whl().register(subparsers, [common]) + build_tests().register(subparsers, [common]) bandit().register(subparsers, [common]) verify_keywords().register(subparsers, [common]) generate().register(subparsers, [common]) @@ -114,6 +117,7 @@ def build_parser() -> argparse.ArgumentParser: mindependency().register(subparsers, [common]) latestdependency().register(subparsers, [common]) samples().register(subparsers, [common]) + create_samples().register(subparsers, [common]) devtest().register(subparsers, [common]) optional().register(subparsers, [common]) update_snippet().register(subparsers, [common]) diff --git a/eng/tools/azure-sdk-tools/tests/test_build_interactions.py b/eng/tools/azure-sdk-tools/tests/test_build_interactions.py index 25e16dd5aa03..2f8fe4110894 100644 --- a/eng/tools/azure-sdk-tools/tests/test_build_interactions.py +++ b/eng/tools/azure-sdk-tools/tests/test_build_interactions.py @@ -1,7 +1,14 @@ -import os, tempfile, shutil +import os +import tempfile +import shutil +from unittest.mock import patch from ci_tools.build import discover_targeted_packages, build_packages, build +from azpysdk.main import build_parser +from azpysdk.build_tests import build_tests +from azpysdk.create_samples import create_samples + repo_root = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") integration_folder = os.path.join(os.path.dirname(__file__), "integration") pyproject_folder = os.path.join(integration_folder, "scenarios", "pyproject_build_config") @@ -9,12 +16,111 @@ def test_build_core(): - pass + assert callable(build) def test_discover_targeted_packages(): - pass + assert callable(discover_targeted_packages) def test_build_packages(): - pass + assert callable(build_packages) + + +# --------------------------------------------------------------------------- +# build_tests subcommand tests +# --------------------------------------------------------------------------- + +def test_build_tests_registers_subcommand(): + parser = build_parser() + # verify that "build-tests" is a recognised subcommand + args = parser.parse_args(["build-tests"]) + assert args.command == "build-tests" + + +def test_build_tests_no_op_on_missing_target(): + checker = build_tests() + with patch.object(checker, "get_targeted_directories", return_value=[]): + import argparse + args = argparse.Namespace(command="build-tests", isolate=False, target="**", service=None) + result = checker.run(args) + assert result == 0 + + +# --------------------------------------------------------------------------- +# create_samples subcommand tests +# --------------------------------------------------------------------------- + +def test_create_samples_registers_subcommand(): + parser = build_parser() + args = parser.parse_args(["create-samples"]) + assert args.command == "create-samples" + + +def _make_fake_parsed_setup(tmp_dir: str): + """Return a minimal object that looks like a ParsedSetup.""" + class _FakeParsed: + folder = tmp_dir + name = "azure-fake-package" + return _FakeParsed() + + +def test_create_samples_creates_scaffold(): + tmp_dir = tempfile.mkdtemp() + try: + checker = create_samples() + fake = _make_fake_parsed_setup(tmp_dir) + with patch.object(checker, "get_targeted_directories", return_value=[fake]): + import argparse + args = argparse.Namespace( + command="create-samples", + isolate=False, + target="**", + service=None, + output_dir=None, + ) + result = checker.run(args) + + assert result == 0 + samples_dir = os.path.join(tmp_dir, "samples") + assert os.path.isdir(samples_dir) + assert os.path.isfile(os.path.join(samples_dir, "README.md")) + assert os.path.isfile(os.path.join(samples_dir, "sample_hello_world.py")) + + with open(os.path.join(samples_dir, "README.md")) as f: + content = f.read() + assert "azure-fake-package" in content + assert "azpysdk generate" in content + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def test_create_samples_skips_existing_files(): + tmp_dir = tempfile.mkdtemp() + try: + samples_dir = os.path.join(tmp_dir, "samples") + os.makedirs(samples_dir, exist_ok=True) + readme_path = os.path.join(samples_dir, "README.md") + original_content = "# My custom README\n" + with open(readme_path, "w") as f: + f.write(original_content) + + checker = create_samples() + fake = _make_fake_parsed_setup(tmp_dir) + with patch.object(checker, "get_targeted_directories", return_value=[fake]): + import argparse + args = argparse.Namespace( + command="create-samples", + isolate=False, + target="**", + service=None, + output_dir=None, + ) + result = checker.run(args) + + assert result == 0 + with open(readme_path) as f: + assert f.read() == original_content + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) +