From dadd9d636cb210dea093ef1da9e1647d88b31605 Mon Sep 17 00:00:00 2001 From: Laurent Mazuel Date: Thu, 21 Aug 2025 15:19:11 -0700 Subject: [PATCH 01/11] Add option to run uv easily if wanted --- .gitignore | 3 + eng/tools/azure-sdk-tools/azpysdk/__init__.py | 0 eng/tools/azure-sdk-tools/azpysdk/main.py | 93 +++++++++++++++++++ eng/tools/azure-sdk-tools/pyproject.toml | 64 +++++++++++++ eng/tools/azure-sdk-tools/setup.py | 64 ------------- .../azure-keyvault-keys/pyproject.toml | 17 ++++ 6 files changed, 177 insertions(+), 64 deletions(-) create mode 100644 eng/tools/azure-sdk-tools/azpysdk/__init__.py create mode 100644 eng/tools/azure-sdk-tools/azpysdk/main.py delete mode 100644 eng/tools/azure-sdk-tools/setup.py diff --git a/.gitignore b/.gitignore index 0e05986885a9..23a9c757ffda 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,6 @@ component-detection-pip-report.json **/.pydevproject **/.settings .github/prompts/copilot-instructions.md + +# No uv lock for now +uv.lock \ No newline at end of file diff --git a/eng/tools/azure-sdk-tools/azpysdk/__init__.py b/eng/tools/azure-sdk-tools/azpysdk/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py new file mode 100644 index 000000000000..e834b4f335f4 --- /dev/null +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -0,0 +1,93 @@ +"""azpysdk CLI + +A minimal command-line interface using argparse. This file provides a +`main()` entrypoint so the package can be invoked as a module +(e.g. `python -m azpysdk.main`) or installed as a console script. +""" + +from __future__ import annotations + +import argparse +import sys +from typing import Sequence, Optional + +__all__ = ["main", "build_parser"] +__version__ = "0.0.0" + + +def _cmd_greet(args: argparse.Namespace) -> int: + """Simple greet command: prints a greeting.""" + name = args.name or "world" + print(f"Hello, {name}!") + return 0 + + +def _cmd_echo(args: argparse.Namespace) -> int: + """Echo command: prints back the provided message.""" + print(args.message) + return 0 + + +def _cmd_run(args: argparse.Namespace) -> int: + """Run command: placeholder for running a task or pipeline.""" + print(f"Running task: {args.task}") + # TODO: implement real behaviour + return 0 + + +def build_parser() -> argparse.ArgumentParser: + """Create and return the top-level ArgumentParser for the CLI.""" + parser = argparse.ArgumentParser( + prog="azpysdk", description="Azure SDK Python tools (minimal CLI)" + ) + parser.add_argument("-V", "--version", action="version", version=__version__) + + subparsers = parser.add_subparsers(title="commands", dest="command") + + # greet + p = subparsers.add_parser("greet", help="Greet someone") + p.add_argument("-n", "--name", help="Name to greet") + p.set_defaults(func=_cmd_greet) + + # echo + p = subparsers.add_parser("echo", help="Echo a message") + p.add_argument("message", help="Message to echo") + p.set_defaults(func=_cmd_echo) + + # run + p = subparsers.add_parser("run", help="Run a placeholder task") + p.add_argument("-t", "--task", default="default", help="Task name to run") + p.set_defaults(func=_cmd_run) + + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + """CLI entrypoint. + + Args: + argv: Optional list of arguments to parse (defaults to sys.argv[1:]). + + Returns: + Exit code to return to the OS. + """ + parser = build_parser() + args = parser.parse_args(argv) + + if not hasattr(args, "func"): + parser.print_help() + return 1 + + try: + result = args.func(args) + return int(result or 0) + except KeyboardInterrupt: + print("Interrupted by user", file=sys.stderr) + return 130 + except Exception as exc: # pragma: no cover - simple top-level error handling + print(f"Error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eng/tools/azure-sdk-tools/pyproject.toml b/eng/tools/azure-sdk-tools/pyproject.toml index 56c3ff3bf7f1..fcb4bccc9ad2 100644 --- a/eng/tools/azure-sdk-tools/pyproject.toml +++ b/eng/tools/azure-sdk-tools/pyproject.toml @@ -1,3 +1,67 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "azure-sdk-tools" +version = "0.0.0" +description = "Build and test tooling for the Azure SDK for Python" +readme = "README.md" +authors = [ + { name = "Microsoft Corporation", email = "azpysdkhelp@microsoft.com" } +] +urls = { "Homepage" = "https://github.com/Azure/azure-sdk-for-python" } + +dependencies = [ + "packaging", + "wheel", + "Jinja2", + "json-delta>=2.0", + "pytest-cov", + "pytest>=3.5.1", + "python-dotenv", + "PyYAML", + "urllib3", + "tomli-w==1.0.0", + "azure-core", + "ConfigArgParse>=0.12.0", + "pytest-asyncio>=0.9.0; python_version >= '3.5'", + "tomli; python_version < '3.11'", +] + +[project.scripts] +generate_package = "packaging_tools.generate_package:generate_main" +generate_sdk = "packaging_tools.generate_sdk:generate_main" +generate_client = "packaging_tools.generate_client:generate_main" +multiapi_combiner = "packaging_tools.multiapi_combiner:combine" +perfstress = "devtools_testutils.perfstress_tests:run_perfstress_cmd" +perfstressdebug = "devtools_testutils.perfstress_tests:run_perfstress_debug_cmd" +sdk_generator = "packaging_tools.sdk_generator:generate_main" +sdk_build = "ci_tools.build:build" +sdk_build_package = "ci_tools.build:build_package" +sdk_build_conda = "ci_tools.conda:entrypoint" +sdk_set_dev_version = "ci_tools.versioning.version_set_dev:version_set_dev_main" +sdk_set_version = "ci_tools.versioning.version_set:version_set_main" +sdk_increment_version = "ci_tools.versioning.version_increment:version_increment_main" +sdk_analyze_deps = "ci_tools.dependency_analysis:analyze_dependencies" +sdk_find_invalid_versions = "ci_tools.versioning.find_invalid_versions:find_invalid_versions_main" +sdk_verify_keywords = "ci_tools.keywords_verify:entrypoint" +systemperf = "devtools_testutils.perfstress_tests:run_system_perfstress_tests_cmd" +azpysdk = "azpysdk.main:main" + +[project.optional-dependencies] +build = ["six", "setuptools", "pyparsing", "certifi", "cibuildwheel", "pkginfo", "build"] +conda = ["beautifulsoup4"] +systemperf = ["aiohttp>=3.0", "requests>=2.0", "tornado==6.0.3", "httpx>=0.21", "azure-core"] +ghtools = ["GitPython", "PyGithub>=1.59.0", "requests>=2.0"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +exclude = ["tests*"] + [tool.black] line-length=120 exclude="packaging_tools/templates/setup.py" diff --git a/eng/tools/azure-sdk-tools/setup.py b/eng/tools/azure-sdk-tools/setup.py deleted file mode 100644 index 5c12ef5d9f0b..000000000000 --- a/eng/tools/azure-sdk-tools/setup.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -from setuptools import setup, find_packages - -# This is a "fake" package, meaning it's not supposed to be released but used -# locally with "pip install -e" - -DEPENDENCIES = [ - # Packaging - "packaging", - "wheel", - "Jinja2", - "json-delta>=2.0", - # Tests - "pytest-cov", - "pytest>=3.5.1", - "python-dotenv", - "PyYAML", - "urllib3", - "tomli-w==1.0.0", - "azure-core", - # Perf/Build - "ConfigArgParse>=0.12.0", -] - -setup( - name="azure-sdk-tools", - version="0.0.0", - author="Microsoft Corporation", - author_email="azpysdkhelp@microsoft.com", - url="https://github.com/Azure/azure-sdk-for-python", - packages=find_packages(), - long_description="Build and test tooling for the Azure SDK for Python", - install_requires=DEPENDENCIES, - include_package_data=True, - entry_points={ - "console_scripts": [ - "generate_package=packaging_tools.generate_package:generate_main", - "generate_sdk=packaging_tools.generate_sdk:generate_main", - "generate_client=packaging_tools.generate_client:generate_main", - "multiapi_combiner=packaging_tools.multiapi_combiner:combine", - "perfstress=devtools_testutils.perfstress_tests:run_perfstress_cmd", - "perfstressdebug=devtools_testutils.perfstress_tests:run_perfstress_debug_cmd", - "sdk_generator=packaging_tools.sdk_generator:generate_main", - "sdk_build=ci_tools.build:build", - "sdk_build_package=ci_tools.build:build_package", - "sdk_build_conda=ci_tools.conda:entrypoint", - "sdk_set_dev_version=ci_tools.versioning.version_set_dev:version_set_dev_main", - "sdk_set_version=ci_tools.versioning.version_set:version_set_main", - "sdk_increment_version=ci_tools.versioning.version_increment:version_increment_main", - "sdk_analyze_deps=ci_tools.dependency_analysis:analyze_dependencies", - "sdk_find_invalid_versions=ci_tools.versioning.find_invalid_versions:find_invalid_versions_main", - "sdk_verify_keywords=ci_tools.keywords_verify:entrypoint", - "systemperf=devtools_testutils.perfstress_tests:run_system_perfstress_tests_cmd", - ], - }, - extras_require={ - ":python_version>='3.5'": ["pytest-asyncio>=0.9.0"], - ":python_version<'3.11'": ["tomli"], - "build": ["six", "setuptools", "pyparsing", "certifi", "cibuildwheel", "pkginfo", "build"], - "conda": ["beautifulsoup4"], - "systemperf": ["aiohttp>=3.0", "requests>=2.0", "tornado==6.0.3", "httpx>=0.21", "azure-core"], - "ghtools": ["GitPython", "PyGithub>=1.59.0", "requests>=2.0"], - }, -) diff --git a/sdk/keyvault/azure-keyvault-keys/pyproject.toml b/sdk/keyvault/azure-keyvault-keys/pyproject.toml index 4b192dcb2eb6..eec044c8763e 100644 --- a/sdk/keyvault/azure-keyvault-keys/pyproject.toml +++ b/sdk/keyvault/azure-keyvault-keys/pyproject.toml @@ -46,3 +46,20 @@ pytyped = ["py.typed"] [tool.azure-sdk-build] pyright = false + +[tool.uv.sources] +azure-core = { path = "../../core/azure-core" } +azure-keyvault-nspkg = { path = "../../nspkg/azure-keyvault-nspkg" } +azure-sdk-tools = { path = "../../../eng/tools/azure-sdk-tools" } + +[dependency-groups] +dev = [ + "aiohttp>=3.0", + "azure-core", + "azure-identity>=1.24.0", + "azure-keyvault-nspkg", + "azure-mgmt-keyvault==10.1.0", + "azure-sdk-tools", + "parameterized>=0.7.3", + "python-dateutil>=2.8.0", +] From a9373f25208e1a3de50675de8946587b69767641 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Thu, 21 Aug 2025 23:22:13 +0000 Subject: [PATCH 02/11] fix tests --- .../tests/test_parse_functionality.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py b/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py index b7328e76c0d5..1405850a0285 100644 --- a/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py +++ b/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py @@ -11,6 +11,7 @@ ) scenarios_folder = os.path.join(os.path.dirname(__file__), "integration", "scenarios") metapackage_scenario = os.path.join(scenarios_folder, "setup_py_metapackage") +setup_project_scenario = os.path.join(scenarios_folder, "setup_py_project_def") pyproject_scenario = os.path.join(scenarios_folder, "pyproject_project_def") pyproject_extension_scenario = os.path.join(scenarios_folder, "pyproject_project_def_with_extension") @@ -110,22 +111,21 @@ def test_sdk_sample_setup(test_patch): ) """ - result = ParsedSetup.from_path(package_root) + result = ParsedSetup.from_path(setup_project_scenario) assert result.name == "azure-core" assert result.version == "1.21.0" assert result.python_requires == ">=3.7" assert result.requires == ["requests>=2.18.4", "six>=1.11.0", "typing-extensions>=4.0.1"] assert result.is_new_sdk == True - assert result.setup_filename == os.path.join(package_root, "setup.py") + assert result.setup_filename == os.path.join(setup_project_scenario, "setup.py") assert "pytyped" in result.package_data assert result.include_package_data == True - assert result.folder == package_root + assert result.folder == setup_project_scenario assert len(result.classifiers) > 0 assert result.classifiers[0] == "Development Status :: 5 - Production/Stable" assert result.classifiers[5] == "Programming Language :: Python :: 3.8" assert result.keywords[0] == "azure sdk" - assert result.is_metapackage == False assert len(result.keywords) == 2 @@ -191,24 +191,23 @@ def test_parse_recognizes_extensions(test_patch): ) """ - result = ParsedSetup.from_path(package_root) + result = ParsedSetup.from_path(setup_project_scenario) assert result.name == "azure-storage-extensions" assert result.version == "1.21.0" assert result.python_requires == ">=3.7" assert result.requires == ["requests>=2.18.4", "six>=1.11.0", "typing-extensions>=4.0.1"] # todo resolve this conflict assert result.is_new_sdk == True - assert result.setup_filename == os.path.join(package_root, "setup.py") + assert result.setup_filename == os.path.join(setup_project_scenario, "setup.py") assert "pytyped" in result.package_data assert result.include_package_data == True - assert result.folder == package_root + assert result.folder == setup_project_scenario assert len(result.classifiers) > 0 assert result.classifiers[0] == "Development Status :: 5 - Production/Stable" assert result.classifiers[5] == "Programming Language :: Python :: 3.8" assert result.ext_package == "azure.storage.extensions" assert result.ext_modules is not None assert result.is_pyproject == False - assert result.is_metapackage == False assert len(result.ext_modules) == 1 assert str(type(result.ext_modules[0])) == "" From 254b3a0e5f8ab1648940c731a4cb68fdab43d290 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 22 Aug 2025 00:44:17 +0000 Subject: [PATCH 03/11] lotsa small changes --- eng/tools/azure-sdk-tools/azpysdk/Check.py | 41 ++++++++++ eng/tools/azure-sdk-tools/azpysdk/main.py | 47 +++-------- eng/tools/azure-sdk-tools/azpysdk/whl.py | 77 +++++++++++++++++++ .../azure-sdk-tools/ci_tools/functions.py | 25 +++++- .../ci_tools/scenario/generation.py | 5 +- .../azure-sdk-tools/ci_tools/variables.py | 33 +++++++- scripts/devops_tasks/common_tasks.py | 17 ---- scripts/devops_tasks/tox_harness.py | 3 +- .../azure-sdk-tools/tests/test_versioning.py | 0 9 files changed, 190 insertions(+), 58 deletions(-) create mode 100644 eng/tools/azure-sdk-tools/azpysdk/Check.py create mode 100644 eng/tools/azure-sdk-tools/azpysdk/whl.py create mode 100644 tools/azure-sdk-tools/tests/test_versioning.py diff --git a/eng/tools/azure-sdk-tools/azpysdk/Check.py b/eng/tools/azure-sdk-tools/azpysdk/Check.py new file mode 100644 index 000000000000..3dc5aa6bba93 --- /dev/null +++ b/eng/tools/azure-sdk-tools/azpysdk/Check.py @@ -0,0 +1,41 @@ +import abc +import argparse +from typing import Sequence, Optional, List, Any + +class Check(abc.ABC): + """ + Base class for checks. + + Subclasses must implement register() to add a subparser for the check. + Provide: + - name: str - command name for the check + - specifications: Sequence[str] - requirements or local files describing spec + """ + + # Command name used in the CLI + name: str = "unnamed" + # Specifications that this check can use to decide applicability + specifications: Sequence[str] = () + + def __init__(self) -> None: + pass + + @abc.abstractmethod + def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: + """ + Register this check with the CLI subparsers. + + `subparsers` is the object returned by ArgumentParser.add_subparsers(). + `parent_parsers` can be a list of ArgumentParser objects to be used as parents. + Subclasses MUST implement this method. + """ + raise NotImplementedError + + def run(self, args: argparse.Namespace) -> int: + """Run the check command. + + Subclasses can override this to perform the actual work. + """ + print(f"Running check: {self.name} ...") + print(args) + return 0 \ No newline at end of file diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index e834b4f335f4..3fa8edf751d6 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -11,30 +11,11 @@ import sys from typing import Sequence, Optional +from .whl import whl + __all__ = ["main", "build_parser"] __version__ = "0.0.0" - -def _cmd_greet(args: argparse.Namespace) -> int: - """Simple greet command: prints a greeting.""" - name = args.name or "world" - print(f"Hello, {name}!") - return 0 - - -def _cmd_echo(args: argparse.Namespace) -> int: - """Echo command: prints back the provided message.""" - print(args.message) - return 0 - - -def _cmd_run(args: argparse.Namespace) -> int: - """Run command: placeholder for running a task or pipeline.""" - print(f"Running task: {args.task}") - # TODO: implement real behaviour - return 0 - - def build_parser() -> argparse.ArgumentParser: """Create and return the top-level ArgumentParser for the CLI.""" parser = argparse.ArgumentParser( @@ -42,22 +23,18 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("-V", "--version", action="version", version=__version__) - subparsers = parser.add_subparsers(title="commands", dest="command") - - # greet - p = subparsers.add_parser("greet", help="Greet someone") - p.add_argument("-n", "--name", help="Name to greet") - p.set_defaults(func=_cmd_greet) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "target", + nargs="?", + default=".", + help="Glob pattern for packages. Defaults to '.', but will match patterns below CWD." + ) - # echo - p = subparsers.add_parser("echo", help="Echo a message") - p.add_argument("message", help="Message to echo") - p.set_defaults(func=_cmd_echo) + subparsers = parser.add_subparsers(title="commands", dest="command") - # run - p = subparsers.add_parser("run", help="Run a placeholder task") - p.add_argument("-t", "--task", default="default", help="Task name to run") - p.set_defaults(func=_cmd_run) + # register the whl check + whl().register(subparsers, [common]) return parser diff --git a/eng/tools/azure-sdk-tools/azpysdk/whl.py b/eng/tools/azure-sdk-tools/azpysdk/whl.py new file mode 100644 index 000000000000..c0845900fa03 --- /dev/null +++ b/eng/tools/azure-sdk-tools/azpysdk/whl.py @@ -0,0 +1,77 @@ +import argparse +import logging +import tempfile +import os +from typing import Optional, List, Any +from pytest import pytest_main +import sys + +from .Check import Check + +from ci_tools.functions import discover_targeted_packages, is_error_code_5_allowed, pip_install +from ci_tools.variables import set_envvar_defaults +from ci_tools.scenario.generation import create_package_and_install + + +class whl(Check): + def __init__(self) -> None: + super().__init__() + + def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: + """Register the `whl` check. + + `subparsers` is the object returned by ArgumentParser.add_subparsers(). + `parent_parsers` is an optional list of parent ArgumentParser objects + that contain common arguments. Avoid mutating default arguments. + """ + parents = parent_parsers or [] + p = subparsers.add_parser("whl", parents=parents, help="Run the whl check") + p.set_defaults(func=self.run) + # Add any additional arguments specific to WhlCheck here (do not re-add common args) + + # todo: figure out venv abstraction mechanism + def run(self, args: argparse.Namespace) -> int: + """Run the whl check command.""" + print("Running whl check...") + + set_envvar_defaults() + + target_dir = os.getcwd() + targeted = discover_targeted_packages(args.target, target_dir) + results = [] + + for pkg in targeted: + dev_requirements = os.path.join(pkg, "dev_requirements.txt") + + if os.path.exists(dev_requirements): + pip_install([f"-r {dev_requirements}"], sys.executable) + + staging_area = tempfile.mkdtemp() + + create_package_and_install( + distribution_directory=staging_area, + target_setup=pkg, + skip_install=False, + cache_dir=None, + work_dir=staging_area, + force_create=False, + package_type="wheel", + pre_download_disabled=False, + ) + + # todo, come up with a good pattern for passing all the additional args after -- to pytest + logging.info(f"Invoke pytest for {pkg}") + + exit_code = pytest_main( + [pkg] + ) + + if exit_code != 0: + if exit_code == 5 and is_error_code_5_allowed(): + logging.info("Exit code 5 is allowed, continuing execution.") + else: + logging.info(f"pytest failed with exit code {exit_code}.") + results.append(exit_code) + + # final result is the worst case of all the results + return max(results) diff --git a/eng/tools/azure-sdk-tools/ci_tools/functions.py b/eng/tools/azure-sdk-tools/ci_tools/functions.py index 08cef76625c4..bba314d0a6eb 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/functions.py +++ b/eng/tools/azure-sdk-tools/ci_tools/functions.py @@ -444,9 +444,9 @@ def pip_install( Attempts to invoke an install operation using the invoking python's pip. Empty requirements are auto-success. """ - exe = python_executable or sys.executable + exe = get_pip_command(python_executable) - command = [exe, "-m", "pip", "install"] + command = exe + ["install"] if requirements: command.extend([req.strip() for req in requirements]) @@ -929,6 +929,7 @@ def verify_package_classifiers(package_name: str, package_version: str, package_ return False, f"{package_name} has version {package_version} and is a GA release, but had development status '{c}'. Expecting a development classifier that is equal or greater than 'Development Status :: 5 - Production/Stable'." return True, None + def get_pip_command(python_exe: Optional[str] = None) -> List[str]: """ Determine whether to use 'uv pip' or regular 'pip' based on environment. @@ -941,7 +942,27 @@ def get_pip_command(python_exe: Optional[str] = None) -> List[str]: # Check TOX_PIP_IMPL environment variable (aligns with tox.ini configuration) pip_impl = os.environ.get('TOX_PIP_IMPL', 'pip').lower() + # soon we will change this to default to uv if pip_impl == 'uv': return ["uv", "pip"] else: return [python_exe if python_exe else sys.executable, "-m", "pip"] + + +def is_error_code_5_allowed(target_pkg: str, pkg_name: str): + """ + Determine if error code 5 (no pytests run) is allowed for the given package. + """ + if ( + all( + map( + lambda x: any([pkg_id in x for pkg_id in MANAGEMENT_PACKAGE_IDENTIFIERS]), + [target_pkg], + ) + ) + or pkg_name in MANAGEMENT_PACKAGE_IDENTIFIERS + or pkg_name in NO_TESTS_ALLOWED + ): + return True + else: + return False \ No newline at end of file diff --git a/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py b/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py index 82932ac754ee..a5c80d82b23f 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py +++ b/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py @@ -112,7 +112,10 @@ def create_package_and_install( ) pip_cmd = get_pip_command(python_exe) - download_command = pip_cmd + [ + download_command = [ + sys.executable, + "-m", + "pip", # uv pip doesn't have a download command, so we use the system pip "download", "-d", tmp_dl_folder, diff --git a/eng/tools/azure-sdk-tools/ci_tools/variables.py b/eng/tools/azure-sdk-tools/ci_tools/variables.py index 0b84dae8b2b8..ee7fd969d2e8 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/variables.py +++ b/eng/tools/azure-sdk-tools/ci_tools/variables.py @@ -1,5 +1,5 @@ import os -from typing import Optional +from typing import Optional, Dict def str_to_bool(input_string: str) -> bool: """ @@ -95,3 +95,34 @@ def in_analyze_weekly() -> int: DEV_BUILD_IDENTIFIER = os.getenv("SDK_DEV_BUILD_IDENTIFIER", "a") DEFAULT_BUILD_ID = os.getenv("GITHUB_RUN_ID", os.getenv("BUILD.BUILDID", os.getenv("SDK_BUILD_ID", "20220101.1"))) + +DEFAULT_ENVIRONMENT_VARIABLES = { + "SPHINX_APIDOC_OPTIONS": "members,undoc-members,inherited-members", + "PROXY_URL": "http://localhost:5000", + "VIRTUALENV_WHEEL": "0.45.1", + "VIRTUALENV_PIP": "24.0", + "VIRTUALENV_SETUPTOOLS": "75.3.2", + "PIP_EXTRA_INDEX_URL": "https://pypi.python.org/simple", + # I haven't spent much time looking to see if a variable exists when invoking uv run. there might be one already that we can depend + # on for get_pip_command adjustment. + "IN_UV": "1" +} + +def set_environment_from_dictionary(settings: Dict[str, str]) -> None: + """ + Sets default environment variables for any given process. + Args: + settings (Dict[str, str]): A dictionary of environment variable names and their default values. + """ + for key, value in settings.items(): + if key not in os.environ: + os.environ.setdefault(key, value) + + +def set_envvar_defaults() -> None: + """ + Sets default environment variables for any given process to our default dictionary. + Args: + settings (Dict[str, str]): A dictionary of environment variable names and their default values. + """ + set_environment_from_dictionary(DEFAULT_ENVIRONMENT_VARIABLES) diff --git a/scripts/devops_tasks/common_tasks.py b/scripts/devops_tasks/common_tasks.py index f92b0aaa324d..9bbbc6a6c5a1 100644 --- a/scripts/devops_tasks/common_tasks.py +++ b/scripts/devops_tasks/common_tasks.py @@ -114,23 +114,6 @@ def create_code_coverage_params(parsed_args: Namespace, package_path: str): return coverage_args -# This function returns if error code 5 is allowed for a given package -def is_error_code_5_allowed(target_pkg, pkg_name): - if ( - all( - map( - lambda x: any([pkg_id in x for pkg_id in MANAGEMENT_PACKAGE_IDENTIFIERS]), - [target_pkg], - ) - ) - or pkg_name in MANAGEMENT_PACKAGE_IDENTIFIERS - or pkg_name in NO_TESTS_ALLOWED - ): - return True - else: - return False - - # This method installs package from a pre-built whl def install_package_from_whl(package_whl_path, working_dir, python_sym_link=sys.executable): commands = [ diff --git a/scripts/devops_tasks/tox_harness.py b/scripts/devops_tasks/tox_harness.py index f5a2bca55e63..bc7eee345b19 100644 --- a/scripts/devops_tasks/tox_harness.py +++ b/scripts/devops_tasks/tox_harness.py @@ -11,7 +11,6 @@ from common_tasks import ( run_check_call, clean_coverage, - is_error_code_5_allowed, create_code_coverage_params, ) @@ -19,7 +18,7 @@ from ci_tools.environment_exclusions import filter_tox_environment_string from ci_tools.ci_interactions import output_ci_warning from ci_tools.scenario.generation import replace_dev_reqs -from ci_tools.functions import cleanup_directory +from ci_tools.functions import cleanup_directory, is_error_code_5_allowed from ci_tools.parsing import ParsedSetup from packaging.requirements import Requirement import logging diff --git a/tools/azure-sdk-tools/tests/test_versioning.py b/tools/azure-sdk-tools/tests/test_versioning.py new file mode 100644 index 000000000000..e69de29bb2d1 From 700ab3b2155151d1dc003f92d6087bbee3ea994e Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 22 Aug 2025 00:57:29 +0000 Subject: [PATCH 04/11] we are actually able to make this work now --- eng/tools/azure-sdk-tools/azpysdk/whl.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/whl.py b/eng/tools/azure-sdk-tools/azpysdk/whl.py index c0845900fa03..5b66801e6afb 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/whl.py +++ b/eng/tools/azure-sdk-tools/azpysdk/whl.py @@ -3,7 +3,7 @@ import tempfile import os from typing import Optional, List, Any -from pytest import pytest_main +from pytest import main as pytest_main import sys from .Check import Check @@ -36,15 +36,17 @@ def run(self, args: argparse.Namespace) -> int: set_envvar_defaults() - target_dir = os.getcwd() - targeted = discover_targeted_packages(args.target, target_dir) - results = [] + if args.target == ".": + targeted = [os.getcwd()] + else: + target_dir = os.getcwd() + targeted = discover_targeted_packages(args.target, target_dir) for pkg in targeted: dev_requirements = os.path.join(pkg, "dev_requirements.txt") if os.path.exists(dev_requirements): - pip_install([f"-r {dev_requirements}"], sys.executable) + pip_install([f"-r", f"{dev_requirements}"], sys.executable) staging_area = tempfile.mkdtemp() From 8a9254a12b37eeab9ac64732f07f9b8d459e0934 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 22 Aug 2025 01:02:51 +0000 Subject: [PATCH 05/11] more abstractions --- eng/tools/azure-sdk-tools/azpysdk/Check.py | 10 +----- eng/tools/azure-sdk-tools/azpysdk/depends.py | 38 ++++++++++++++++++++ eng/tools/azure-sdk-tools/azpysdk/main.py | 2 +- eng/tools/azure-sdk-tools/azpysdk/whl.py | 5 +-- 4 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 eng/tools/azure-sdk-tools/azpysdk/depends.py diff --git a/eng/tools/azure-sdk-tools/azpysdk/Check.py b/eng/tools/azure-sdk-tools/azpysdk/Check.py index 3dc5aa6bba93..6984a1aa751e 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/Check.py +++ b/eng/tools/azure-sdk-tools/azpysdk/Check.py @@ -7,18 +7,10 @@ class Check(abc.ABC): Base class for checks. Subclasses must implement register() to add a subparser for the check. - Provide: - - name: str - command name for the check - - specifications: Sequence[str] - requirements or local files describing spec """ - # Command name used in the CLI - name: str = "unnamed" - # Specifications that this check can use to decide applicability - specifications: Sequence[str] = () - def __init__(self) -> None: - pass + self.isolated_namespace = False @abc.abstractmethod def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: diff --git a/eng/tools/azure-sdk-tools/azpysdk/depends.py b/eng/tools/azure-sdk-tools/azpysdk/depends.py new file mode 100644 index 000000000000..e20886b8e481 --- /dev/null +++ b/eng/tools/azure-sdk-tools/azpysdk/depends.py @@ -0,0 +1,38 @@ +import argparse +import os +import sys + +from .Check import Check + +from ci_tools.functions import discover_targeted_packages + +class depends(Check): + def __init__(self) -> None: + super().__init__() + + def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: + """Register the `whl` check. + + `subparsers` is the object returned by ArgumentParser.add_subparsers(). + `parent_parsers` is an optional list of parent ArgumentParser objects + that contain common arguments. Avoid mutating default arguments. + """ + parents = parent_parsers or [] + p = subparsers.add_parser("whl", parents=parents, help="Run the whl check") + p.set_defaults(func=self.run) + # Add any additional arguments specific to WhlCheck here (do not re-add common args) + + # todo: figure out venv abstraction mechanism via override + def run(self, args: argparse.Namespace) -> int: + """Run the whl check command.""" + print("Running depends check in isolated venv...") + + # this is common. we should have an abstraction layer for this somehow + if args.target == ".": + targeted = [os.getcwd()] + else: + target_dir = os.getcwd() + targeted = discover_targeted_packages(args.target, target_dir) + + + print(targeted) diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index 3fa8edf751d6..4007b6dd5e0a 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -28,7 +28,7 @@ def build_parser() -> argparse.ArgumentParser: "target", nargs="?", default=".", - help="Glob pattern for packages. Defaults to '.', but will match patterns below CWD." + help="Glob pattern for packages. Defaults to '.', but will match patterns below CWD if a value is provided." ) subparsers = parser.add_subparsers(title="commands", dest="command") diff --git a/eng/tools/azure-sdk-tools/azpysdk/whl.py b/eng/tools/azure-sdk-tools/azpysdk/whl.py index 5b66801e6afb..125b3dedf2a8 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/whl.py +++ b/eng/tools/azure-sdk-tools/azpysdk/whl.py @@ -27,9 +27,9 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt parents = parent_parsers or [] p = subparsers.add_parser("whl", parents=parents, help="Run the whl check") p.set_defaults(func=self.run) - # Add any additional arguments specific to WhlCheck here (do not re-add common args) + # Add any additional arguments specific to WhlCheck here (do not re-add common handled by parents) - # todo: figure out venv abstraction mechanism + # todo: figure out venv abstraction mechanism via override def run(self, args: argparse.Namespace) -> int: """Run the whl check command.""" print("Running whl check...") @@ -41,6 +41,7 @@ def run(self, args: argparse.Namespace) -> int: else: target_dir = os.getcwd() targeted = discover_targeted_packages(args.target, target_dir) + results: List[int] = [] for pkg in targeted: dev_requirements = os.path.join(pkg, "dev_requirements.txt") From 4226d65253382a051eeaa72de7fa1b237bc7ffd9 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Fri, 22 Aug 2025 01:04:48 +0000 Subject: [PATCH 06/11] tiny comment adjusts --- eng/tools/azure-sdk-tools/azpysdk/depends.py | 7 ++++--- eng/tools/azure-sdk-tools/azpysdk/main.py | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/depends.py b/eng/tools/azure-sdk-tools/azpysdk/depends.py index e20886b8e481..fceee09e0bec 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/depends.py +++ b/eng/tools/azure-sdk-tools/azpysdk/depends.py @@ -2,6 +2,8 @@ import os import sys +from typing import Optional,List + from .Check import Check from ci_tools.functions import discover_targeted_packages @@ -11,7 +13,7 @@ def __init__(self) -> None: super().__init__() def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: - """Register the `whl` check. + """Register the `depends` check. `subparsers` is the object returned by ArgumentParser.add_subparsers(). `parent_parsers` is an optional list of parent ArgumentParser objects @@ -24,7 +26,7 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt # todo: figure out venv abstraction mechanism via override def run(self, args: argparse.Namespace) -> int: - """Run the whl check command.""" + """Run the depends check command.""" print("Running depends check in isolated venv...") # this is common. we should have an abstraction layer for this somehow @@ -34,5 +36,4 @@ def run(self, args: argparse.Namespace) -> int: target_dir = os.getcwd() targeted = discover_targeted_packages(args.target, target_dir) - print(targeted) diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index 4007b6dd5e0a..a35f986040f7 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -12,6 +12,7 @@ from typing import Sequence, Optional from .whl import whl +from .depends import depends __all__ = ["main", "build_parser"] __version__ = "0.0.0" @@ -33,8 +34,9 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(title="commands", dest="command") - # register the whl check + # register our checks with common params whl().register(subparsers, [common]) + depends().register(subparsers, [common]) return parser From 9ad3e75f31c139e223c5b6fdc20503120515abb4 Mon Sep 17 00:00:00 2001 From: Scott Beddall <45376673+scbedd@users.noreply.github.com> Date: Mon, 25 Aug 2025 09:26:29 -0700 Subject: [PATCH 07/11] Pulling in changes from fork (#42675) --------- Co-authored-by: Scott Beddall --- .github/workflows/azure-sdk-tools.yml | 25 +++ .gitignore | 4 +- doc/dev/test_proxy_troubleshooting.md | 165 ++++++++++++++---- eng/common/scripts/ChangeLog-Operations.ps1 | 15 +- .../scripts/Mark-ReleasePlanCompletion.ps1 | 67 ++++--- .../stages/archetype-python-release.yml | 9 +- eng/scripts/docs/py2docfx_requirements.txt | 2 +- eng/tools/azure-sdk-tools/azpysdk/depends.py | 59 ++++++- eng/tools/azure-sdk-tools/azpysdk/main.py | 60 ++++++- eng/tools/azure-sdk-tools/azpysdk/whl.py | 9 +- .../azure-sdk-tools/ci_tools/functions.py | 18 ++ .../ci_tools/scenario/__init__.py | 46 ++++- .../ci_tools/scenario/generation.py | 4 +- .../devtools_testutils/proxy_testcase.py | 8 +- eng/tools/azure-sdk-tools/pyproject.toml | 2 +- eng/tox/import_all.py | 2 +- scripts/dev_setup.py | 2 +- 17 files changed, 409 insertions(+), 88 deletions(-) diff --git a/.github/workflows/azure-sdk-tools.yml b/.github/workflows/azure-sdk-tools.yml index 302c46455681..3e11aff27473 100644 --- a/.github/workflows/azure-sdk-tools.yml +++ b/.github/workflows/azure-sdk-tools.yml @@ -6,6 +6,7 @@ on: branches: [ main ] paths: - "eng/tools/azure-sdk-tools/**" + - ".github/workflows/azure-sdk-tools.yml" jobs: build-and-test: @@ -29,3 +30,27 @@ jobs: pytest ./tests shell: bash working-directory: eng/tools/azure-sdk-tools + + dev-setup-and-import: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.13 + uses: actions/setup-python@v4 + with: + python-version: 3.13 + + - name: Run dev_setup to install specified packages + run: | + python scripts/dev_setup.py --packageList azure-storage-blob,azure-appconfiguration + shell: bash + + - name: Validate imports + run: | + python - <<'PY' + from azure.storage.blob import * + from azure.appconfiguration import * + print("Successful Imports") + PY + shell: bash diff --git a/.gitignore b/.gitignore index 23a9c757ffda..61bd68879079 100644 --- a/.gitignore +++ b/.gitignore @@ -107,8 +107,8 @@ src/build # [begoldsm] ignore virtual env if it exists. adlEnv/ -venv/ -.venv/ +venv* +.venv* code_reports # Azure Storage test credentials diff --git a/doc/dev/test_proxy_troubleshooting.md b/doc/dev/test_proxy_troubleshooting.md index 70d2ad5c0005..12f7bf543a85 100644 --- a/doc/dev/test_proxy_troubleshooting.md +++ b/doc/dev/test_proxy_troubleshooting.md @@ -7,21 +7,22 @@ GitHub repository, but this isn't necessary to read for Python testing. ## Table of contents -- [Guide for test proxy troubleshooting](#guide-for-test-proxy-troubleshooting) - - [Table of contents](#table-of-contents) - - [Debugging tip](#debugging-tip) - - [Test collection failure](#test-collection-failure) - - [Errors in tests using resource preparers](#errors-in-tests-using-resource-preparers) - - [Test failure during `record/start` or `playback/start` requests](#test-failure-during-recordstart-or-playbackstart-requests) - - [Playback failures from body matching errors](#playback-failures-from-body-matching-errors) - - [Playback failures from inconsistent line breaks](#playback-failures-from-inconsistent-line-breaks) - - [Playback failures from URL mismatches](#playback-failures-from-url-mismatches) - - [Recordings not being produced](#recordings-not-being-produced) - - [ConnectionError during tests](#connectionerror-during-tests) - - [Different error than expected when using proxy](#different-error-than-expected-when-using-proxy) - - [Test setup failure in test pipeline](#test-setup-failure-in-test-pipeline) - - [Fixture not found error](#fixture-not-found-error) - - [PermissionError during startup](#permissionerror-during-startup) +- [Debugging tip](#debugging-tip) +- [ServiceRequestError: Cannot connect to host](#servicerequesterror-cannot-connect-to-host) +- [ResourceNotFoundError: Playback failure](#resourcenotfounderror-playback-failure) +- [Test collection failure](#test-collection-failure) +- [Errors in tests using resource preparers](#errors-in-tests-using-resource-preparers) +- [Test failure during `record/start` or `playback/start` requests](#test-failure-during-recordstart-or-playbackstart-requests) +- [Playback failures from body matching errors](#playback-failures-from-body-matching-errors) +- [Playback failures from inconsistent line breaks](#playback-failures-from-inconsistent-line-breaks) +- [Playback failures from URL mismatches](#playback-failures-from-url-mismatches) +- [Playback failures from inconsistent test values](#playback-failures-from-inconsistent-test-values) +- [Recordings not being produced](#recordings-not-being-produced) +- [ConnectionError during tests](#connectionerror-during-tests) +- [Different error than expected when using proxy](#different-error-than-expected-when-using-proxy) +- [Test setup failure in test pipeline](#test-setup-failure-in-test-pipeline) +- [Fixture not found error](#fixture-not-found-error) +- [PermissionError during startup](#permissionerror-during-startup) ## Debugging tip @@ -39,6 +40,53 @@ containing the strings `test_delete` or `test_upload`. For more information about `pytest` invocations, refer to [Usage and Invocations][pytest_commands]. +## ServiceRequestError: Cannot connect to host + +Tests may fail during startup with the following exception: + +```text +azure.core.exceptions.ServiceRequestError: Cannot connect to host localhost:5001 +ssl:True [SSLCertVerificationError: (1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate +verify failed: self signed certificate (_ssl.c:1123)')] +``` + +This is caused by the test proxy's certificate being incorrectly configured. First, update your branch to include the +latest changes from `main` -- this ensures you have the latest certificate version (it needs to be occasionally +rotated). + +If tests continue to fail, this is likely due to an async-specific environment issue. The certificate is +[automatically configured][cert_setup] during proxy startup, but async environments can still nondeterministically fail. + +To work around this, set the following environment variable in your `.env` file: + +```text +PROXY_URL='http://localhost:5000' +``` + +This will target an HTTP endpoint for the test proxy that doesn't require certificates. Service requests will still be +sent securely from your client; this change only affects test proxy interactions. + +## ResourceNotFoundError: Playback failure + +Test playback errors typically raise with a message similar to the following: + +```text +FAILED test_client.py::TestClient::test_client_method - azure.core.exceptions.ResourceNotFoundError: +Playback failure -- for help resolving, see https://aka.ms/azsdk/python/test-proxy/troubleshoot. Error details: +Unable to find a record for the request POST https://fake_resource.service.azure.net?api-version=2025-09-01 +``` + +This means that the test recording didn't contain a match for the incoming playback request. This usually just means +that the test needs to be re-recorded to pick up library updates (e.g. a new service API version). + +If playback errors persist after re-recording, you may need to modify session sanitizers or matchers. The following +sections of this guide describe common scenarios: + +- [Playback failures from body matching errors](#playback-failures-from-body-matching-errors) +- [Playback failures from inconsistent line breaks](#playback-failures-from-inconsistent-line-breaks) +- [Playback failures from URL mismatches](#playback-failures-from-url-mismatches) +- [Playback failures from inconsistent test values](#playback-failures-from-inconsistent-test-values) + ## Test collection failure Make sure that all test class names begin with "Test", and that all test method names begin with "test_". For more @@ -111,9 +159,9 @@ Resource preparers need a management client to function, so test classes that us ## Test failure during `record/start` or `playback/start` requests -If your library uses out-of-repo recordings and tests fail during startup, logs might indicate that POST requests to -`record/start` or `playback/start` endpoints are returning 500 responses. In a stack trace, these errors might be raised -[here][record_request_failure] or [here][playback_request_failure], respectively. +If tests fail during startup, logs might indicate that POST requests to `record/start` or `playback/start` endpoints +are returning 500 responses. In a stack trace, these errors might be raised [here][record_request_failure] or +[here][playback_request_failure], respectively. This suggests that the test proxy failed to fetch recordings from the assets repository. This likely comes from a corrupted `git` configuration in `azure-sdk-for-python/.assets`. To resolve this: @@ -139,11 +187,9 @@ These folders will be freshly recreated the next time you run tests. ## Playback failures from body matching errors -In the old, `vcrpy`-based testing system, request and response bodies weren't compared in playback mode by default in -most packages. The test proxy system enables body matching by default, which can introduce failures for tests that -passed in the old system. For example, if a test sends a request that includes the current Unix time in its body, the -body will contain a new value when run in playback mode at a later time. This request might still match the recording if -body matching is disabled, but not if it's enabled. +The test proxy system enables body matching by default. For example, if a test sends a request that includes the +current Unix time in its body, the body will contain a new value when run in playback mode at a later time -- this +request won't match the recording if body matching is enabled. Body matching can be turned off with the test proxy by calling the `set_bodiless_matcher` method from [devtools_testutils/sanitizers.py][py_sanitizers] at the very start of a test method. This matcher applies only to the @@ -152,13 +198,12 @@ matching enabled by default. ## Playback failures from inconsistent line breaks -Some tests require recording content to completely match, including line breaks (for example, when sending the content of -a test file in a request body). Line breaks can vary between OSes and cause tests to fail on certain platforms, in which -case it can help to specify a particular format for test files by using [`.gitattributes`][gitattributes]. +Line breaks can vary between OSes and cause tests to fail on certain platforms, in which case it can help to specify a +particular format for test files by using [`.gitattributes`][gitattributes]. -A `.gitattributes` file can be placed at the root of a directory to apply git settings to each file under that directory. -If a test directory contains files that need to have consistent line breaks, for example LF breaks instead of CRLF ones, -you can create a `.gitattributes` file in the directory with the following content: +A `.gitattributes` file can be placed at the root of a directory to apply git settings to each file under that +directory. If a test directory contains files that need to have consistent line breaks, for example LF breaks instead +of CRLF ones, you can create a `.gitattributes` file in the directory with the following content: ```text # Force git to checkout text files with LF (line feed) as the ending (vs CRLF) @@ -209,11 +254,16 @@ that test, which is recommended. ### Sanitization impacting request URL/body/headers -In some cases, a value in a response body is used in the following request as part of the URL, body, or headers. If this value is sanitized, the recorded request might differ than what is expected during playback. Common culprits include sanitization of "name", "id", and "Location" fields. To resolve this, you can either opt out of specific sanitization or add another sanitizer to align with the sanitized value. +In some cases, a value in a response body is used in the following request as part of the URL, body, or headers. If +this value is sanitized, the recorded request might differ than what is expected during playback. Common culprits +include sanitization of "name", "id", and "Location" fields. To resolve this, you can either opt out of specific +sanitization or add another sanitizer to align with the sanitized value. #### Opt out -You can opt out of sanitization for the fields that are used for your requests by calling the `remove_batch_sanitizer` method from `devtools_testutils` with the [sanitizer IDs][test_proxy_sanitizers] to exclude. Generally, this is done in the `conftest.py` file, in the one of the session-scoped fixtures. Example: +You can opt out of sanitization for the fields that are used for your requests by calling the `remove_batch_sanitizer` +method from `devtools_testutils` with the [sanitizer IDs][test_proxy_sanitizers] to exclude. Generally, this is done in +the `conftest.py` file, in the one of the session-scoped fixtures. Example: ```python from devtools_testutils import remove_batch_sanitizers, test_proxy @@ -245,6 +295,48 @@ from devtools_testutils import add_uri_regex_sanitizer add_uri_regex_sanitizer(regex="(?<=https://.+/foo/bar/)(?[^/?\\.]+)", group_for_replace="id", value="Sanitized") ``` +## Playback failures from inconsistent test values + +To run recorded tests successfully when recorded values are inconsistent or random and can't be sanitized, the test +proxy provides a `variables` API. This makes it possible for a test to record the values of variables that were used +during recording and use the same values in playback mode without a sanitizer. + +For example, imagine that a test uses a randomized `table_uuid` variable when creating resources. The same random value +for `table_uuid` can be used in playback mode by using this `variables` API. + +There are two requirements for a test to use recorded variables. First, the test method should accept `**kwargs`. +Second, the test method should `return` a dictionary with any test variables that it wants to record. This dictionary +will be stored in the recording when the test is run live, and will be passed to the test as a `variables` keyword +argument when the test is run in playback. + +Below is a code example of how a test method could use recorded variables: + +```python +from devtools_testutils import AzureRecordedTestCase, recorded_by_proxy + +class TestExample(AzureRecordedTestCase): + + @recorded_by_proxy + def test_example(self, **kwargs): + # In live mode, variables is an empty dictionary + # In playback mode, the value of variables is {"table_uuid": "random-value"} + variables = kwargs.pop("variables", {}) + + # To fetch variable values, use the `setdefault` method to look for a key ("table_uuid") + # and set a real value for that key if it's not present ("random-value") + table_uuid = variables.setdefault("table_uuid", "random-value") + + # use variables["table_uuid"] when using the table UUID throughout the test + ... + + # return the variables at the end of the test to record them + return variables +``` + +> **Note:** `variables` will be passed as a named argument to any test that accepts `kwargs` by the test proxy. In +> environments that don't use the test proxy, though -- like live test pipelines -- `variables` won't be provided. +> To avoid a KeyError, providing an empty dictionary as the default value to `kwargs.pop` is recommended. + ## Recordings not being produced Ensure the environment variable `AZURE_SKIP_LIVE_RECORDING` **isn't** set to "true", and that `AZURE_TEST_RUN_LIVE` @@ -321,8 +413,8 @@ skipped, so the `TestProxy` parameter doesn't need to be set in `tests.yml`. Tests that aren't recorded should omit the `recorded_by_proxy` decorator. However, if these unrecorded tests accept parameters that are provided by a preparer like the `devtools_testutils` [EnvironmentVariableLoader][env_var_loader], -you may see a new test setup error after migrating to the test proxy. For example, imagine a test is decorated with a -preparer that provides a Key Vault URL as a `azure_keyvault_url` parameter: +you may see a test setup error. For example, imagine a test is decorated with a preparer that provides a Key Vault URL +as a `azure_keyvault_url` parameter: ```python class TestExample(AzureRecordedTestCase): @@ -369,7 +461,8 @@ Alternatively, you can delete the installed tool and re-run your tests to automa -[custom_default_matcher]: https://github.com/Azure/azure-sdk-for-python/blob/497f5f3435162c4f2086d1429fc1bba4f31a4354/eng/tools/azure-sdk-tools/devtools_testutils/sanitizers.py#L85 +[cert_setup]: https://github.com/Azure/azure-sdk-for-python/blob/9958caf6269247f940c697a3f982bbbf0a47a19b/eng/tools/azure-sdk-tools/devtools_testutils/proxy_startup.py#L210 +[custom_default_matcher]: https://github.com/Azure/azure-sdk-for-python/blob/9958caf6269247f940c697a3f982bbbf0a47a19b/eng/tools/azure-sdk-tools/devtools_testutils/sanitizers.py#L90 [detailed_docs]: https://github.com/Azure/azure-sdk-tools/tree/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md [env_var_loader]: https://github.com/Azure/azure-sdk-for-python/blob/main/eng/tools/azure-sdk-tools/devtools_testutils/envvariable_loader.py [gitattributes]: https://git-scm.com/docs/gitattributes @@ -379,10 +472,10 @@ Alternatively, you can delete the installed tool and re-run your tests to automa [parametrize_example]: https://github.com/Azure/azure-sdk-for-python/blob/aa607b3b8c3e646928375ebcc6339d68e4e90a49/sdk/keyvault/azure-keyvault-keys/tests/test_key_client.py#L190 [pipelines_ci]: https://github.com/Azure/azure-sdk-for-python/blob/5ba894966ed6b0e1ee8d854871f8c2da36a73d79/sdk/eventgrid/ci.yml#L30 [pipelines_live]: https://github.com/Azure/azure-sdk-for-python/blob/e2b5852deaef04752c1323d2ab0958f83b98858f/sdk/textanalytics/tests.yml#L26-L27 -[playback_request_failure]: https://github.com/Azure/azure-sdk-for-python/blob/e23d9a6b1edcc1127ded40b9993029495b4ad08c/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py#L108 +[playback_request_failure]: https://github.com/Azure/azure-sdk-for-python/blob/9958caf6269247f940c697a3f982bbbf0a47a19b/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py#L102 [py_sanitizers]: https://github.com/Azure/azure-sdk-for-python/blob/main/eng/tools/azure-sdk-tools/devtools_testutils/sanitizers.py [pytest_collection]: https://docs.pytest.org/latest/goodpractices.html#test-discovery [pytest_commands]: https://docs.pytest.org/latest/usage.html -[record_request_failure]: https://github.com/Azure/azure-sdk-for-python/blob/e23d9a6b1edcc1127ded40b9993029495b4ad08c/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py#L97 +[record_request_failure]: https://github.com/Azure/azure-sdk-for-python/blob/9958caf6269247f940c697a3f982bbbf0a47a19b/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py#L91 [test_proxy_sanitizers]: https://github.com/Azure/azure-sdk-tools/blob/57382d5dc00b10a2f9cfd597293eeee0c2dbd8fd/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/SanitizerDictionary.cs#L65 [wrong_exception]: https://github.com/Azure/azure-sdk-tools/issues/2907 diff --git a/eng/common/scripts/ChangeLog-Operations.ps1 b/eng/common/scripts/ChangeLog-Operations.ps1 index f29fc12068dc..c5c4f5ed20ce 100644 --- a/eng/common/scripts/ChangeLog-Operations.ps1 +++ b/eng/common/scripts/ChangeLog-Operations.ps1 @@ -52,6 +52,7 @@ function Get-ChangeLogEntriesFromContent { $sectionHeaderRegex = "^${initialAtxHeader}${SECTION_HEADER_REGEX_SUFFIX}" $changeLogEntries | Add-Member -NotePropertyName "InitialAtxHeader" -NotePropertyValue $initialAtxHeader $releaseTitleAtxHeader = $initialAtxHeader + "#" + $headerLines = @() try { # walk the document, finding where the version specifiers are and creating lists @@ -83,6 +84,9 @@ function Get-ChangeLogEntriesFromContent { $changeLogEntry.ReleaseContent += $line } + else { + $headerLines += $line + } } } } @@ -90,6 +94,8 @@ function Get-ChangeLogEntriesFromContent { Write-Error "Error parsing Changelog." Write-Error $_ } + + $changeLogEntries | Add-Member -NotePropertyName "HeaderBlock" -NotePropertyValue ($headerLines -Join [Environment]::NewLine) return $changeLogEntries } @@ -265,8 +271,13 @@ function Set-ChangeLogContent { ) $changeLogContent = @() - $changeLogContent += "$($ChangeLogEntries.InitialAtxHeader) Release History" - $changeLogContent += "" + if ($ChangeLogEntries.HeaderBlock) { + $changeLogContent += $ChangeLogEntries.HeaderBlock + } + else { + $changeLogContent += "$($ChangeLogEntries.InitialAtxHeader) Release History" + $changeLogContent += "" + } $ChangeLogEntries = Sort-ChangeLogEntries -changeLogEntries $ChangeLogEntries diff --git a/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 b/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 index 08285bfbea0d..2e623672daea 100644 --- a/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 +++ b/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 @@ -11,7 +11,7 @@ param( This script helps to mark release plan completion by finding the active release plans for a package name .PARAMETER PackageInfoFilePath - The path to the package information file (required) + The path to the package information file (required) or path to the directory containing package information files. #> Set-StrictMode -Version 3 @@ -25,34 +25,45 @@ if (-Not (Test-Path $PackageInfoFilePath)) Write-Host "Package information file path $($PackageInfoFilePath) is invalid." exit 0 } -# Get package info from json file created before updating version to daily dev -$pkgInfo = Get-Content $PackageInfoFilePath | ConvertFrom-Json -$PackageVersion = $pkgInfo.Version -$PackageName = $pkgInfo.Name -if (!$PackageName -or !$PackageVersion) -{ - Write-Host "Package name or version is not available in the package information file. Skipping the release plan status update for the package." - exit 0 -} -# Check Azure DevOps Release Plan work items if LanguageShort is available -Write-Host "Checking active release plan work items for package: $PackageName" -$workItems = Get-ReleasePlanForPackage $PackageName -if(!$workItems) +function Process-Package([string]$packageInfoPath) { - Write-Host "No active release plans found for package name: $PackageName." - exit 0 -} + # Get package info from json file created before updating version to daily dev + $pkgInfo = Get-Content $packageInfoPath | ConvertFrom-Json + $PackageVersion = $pkgInfo.Version + $PackageName = $pkgInfo.Name + if (!$PackageName -or !$PackageVersion) + { + Write-Host "Package name or version is not available in the package information file. Skipping the release plan status update for the package." + return + } + + # Check Azure DevOps Release Plan work items + Write-Host "Checking active release plan work items for package: $PackageName" + $workItems = Get-ReleasePlanForPackage $PackageName + if(!$workItems) + { + Write-Host "No active release plans found for package name: $PackageName." + return + } -$activeReleasePlan = $workItems -if($workItems.Count -gt 1 -and ($workItems -is [System.Array])) -{ - $concatenatedIds = ($workItems | Select-Object -ExpandProperty id) -join ',' - Write-Host "Multiple release plans found for package name: $PackageName with work item IDs: $concatenatedIds. Using the first release plan to update release status." - $activeReleasePlan = $workItems[0] + $activeReleasePlan = $workItems + if($workItems.Count -gt 1 -and ($workItems -is [System.Array])) + { + $concatenatedIds = ($workItems | Select-Object -ExpandProperty id) -join ',' + Write-Host "Multiple release plans found for package name: $PackageName with work item IDs: $concatenatedIds. Using the first release plan to update release status." + $activeReleasePlan = $workItems[0] + } + # Update release status + Write-Host "Release plan work item ID: $($activeReleasePlan["id"])" + Write-Host "Marking release completion for package, name: $PackageName version: $PackageVersion" + Update-ReleaseStatusInReleasePlan $activeReleasePlan.id "Released" $PackageVersion + Write-Host "Successfully marked release completion for package, name: $PackageName version: $PackageVersion." } -# Update release status -Write-Host "Release plan work item ID: $($activeReleasePlan["id"])" -Write-Host "Marking release completion for package, name: $PackageName version: $PackageVersion" -Update-ReleaseStatusInReleasePlan $activeReleasePlan.id "Released" $PackageVersion -Write-Host "Successfully marked release completion for package, name: $PackageName version: $PackageVersion." + +Write-Host "Finding all package info files in the path: $PackageInfoFilePath" +# Get all package info file under the directory given in input param and process +Get-ChildItem -Path $PackageInfoFilePath -Filter "*.json" | ForEach-Object { + Write-Host "Processing package info file: $_" + Process-Package $_.FullName +} \ No newline at end of file diff --git a/eng/pipelines/templates/stages/archetype-python-release.yml b/eng/pipelines/templates/stages/archetype-python-release.yml index 64f8027dca4d..e8b281784360 100644 --- a/eng/pipelines/templates/stages/archetype-python-release.yml +++ b/eng/pipelines/templates/stages/archetype-python-release.yml @@ -185,8 +185,8 @@ stages: echo "Uploaded sdist to devops feed" displayName: 'Publish package to feed: ${{parameters.DevFeedName}}' - - job: CreateApiView - displayName: "Create APIView" + - job: MarkPackageReleaseCompletion + displayName: "Mark package release completion" dependsOn: PublishPackage pool: @@ -205,6 +205,11 @@ stages: inputs: versionSpec: '3.9' + - template: /eng/common/pipelines/templates/steps/mark-release-completion.yml + parameters: + ConfigFileDir: '$(Pipeline.Workspace)/${{parameters.ArtifactName}}/PackageInfo' + PackageArtifactName: ${{artifact.name}} + - template: /eng/common/pipelines/templates/steps/create-apireview.yml parameters: ArtifactPath: $(Pipeline.Workspace)/${{parameters.ArtifactName}} diff --git a/eng/scripts/docs/py2docfx_requirements.txt b/eng/scripts/docs/py2docfx_requirements.txt index 0363be3a8639..1bb2496e7cf1 100644 --- a/eng/scripts/docs/py2docfx_requirements.txt +++ b/eng/scripts/docs/py2docfx_requirements.txt @@ -1 +1 @@ -py2docfx==0.1.12 +py2docfx==0.1.20 diff --git a/eng/tools/azure-sdk-tools/azpysdk/depends.py b/eng/tools/azure-sdk-tools/azpysdk/depends.py index fceee09e0bec..f3669cff2096 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/depends.py +++ b/eng/tools/azure-sdk-tools/azpysdk/depends.py @@ -1,12 +1,26 @@ import argparse import os import sys +import logging +import tempfile from typing import Optional,List +from subprocess import check_call from .Check import Check - +from ci_tools.parsing import ParsedSetup from ci_tools.functions import discover_targeted_packages +from ci_tools.scenario.generation import create_package_and_install + +# keyvault has dependency issue when loading private module _BearerTokenCredentialPolicyBase from azure.core.pipeline.policies +# azure.core.tracing.opencensus and azure.eventhub.checkpointstoreblob.aio are skipped due to a known issue in loading azure.core.tracing.opencensus +excluded_packages = [ + "azure", + "azure-mgmt", + ] + +def should_run_import_all(package_name: str) -> bool: + return not (package_name in excluded_packages or "nspkg" in package_name) class depends(Check): def __init__(self) -> None: @@ -20,7 +34,7 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt that contain common arguments. Avoid mutating default arguments. """ parents = parent_parsers or [] - p = subparsers.add_parser("whl", parents=parents, help="Run the whl check") + p = subparsers.add_parser("depends", parents=parents, help="Run the depends check") p.set_defaults(func=self.run) # Add any additional arguments specific to WhlCheck here (do not re-add common args) @@ -36,4 +50,43 @@ def run(self, args: argparse.Namespace) -> int: target_dir = os.getcwd() targeted = discover_targeted_packages(args.target, target_dir) - print(targeted) + # {[tox]pip_command} freeze + # python {repository_root}/eng/tox/import_all.py -t {tox_root} + + outcomes: List[int] = [] + + for pkg in targeted: + parsed = ParsedSetup.from_path(pkg) + + staging_area = tempfile.mkdtemp() + create_package_and_install( + distribution_directory=staging_area, + target_setup=pkg, + skip_install=False, + cache_dir=None, + work_dir=staging_area, + force_create=False, + package_type="wheel", + pre_download_disabled=False, + ) + + if should_run_import_all(parsed.name): + # import all modules from current package + logging.info( + "Importing all modules from namespace [{0}] to verify dependency".format( + parsed.namespace + ) + ) + import_script_all = "from {0} import *".format(parsed.namespace) + commands = [ + sys.executable, + "-c", + import_script_all + ] + + outcomes.append(check_call(commands)) + logging.info("Verified module dependency, no issues found") + else: + logging.info("Package {} is excluded from dependency check".format(parsed.name)) + + return max(outcomes) if outcomes else 0 diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index a35f986040f7..aef3e768c9ab 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -9,11 +9,22 @@ import argparse import sys +import os from typing import Sequence, Optional +from subprocess import check_call from .whl import whl from .depends import depends +from ci_tools.scenario import install_into_venv, get_venv_python +from ci_tools.functions import get_venv_call +from ci_tools.variables import discover_repo_root + +# right now, we are assuming you HAVE to be in the azure-sdk-tools repo +# we assume this because we don't know how a dev has installed this package, and might be +# being called from within a site-packages folder. Due to that, we can't trust the location of __file__ +REPO_ROOT = discover_repo_root() + __all__ = ["main", "build_parser"] __version__ = "0.0.0" @@ -23,6 +34,9 @@ def build_parser() -> argparse.ArgumentParser: prog="azpysdk", description="Azure SDK Python tools (minimal CLI)" ) parser.add_argument("-V", "--version", action="version", version=__version__) + # global flag: allow --isolate to appear before the subcommand as well + parser.add_argument("--isolate", action="store_true", default=False, + help="If set, run in an isolated virtual environment.") common = argparse.ArgumentParser(add_help=False) common.add_argument( @@ -31,17 +45,54 @@ def build_parser() -> argparse.ArgumentParser: default=".", help="Glob pattern for packages. Defaults to '.', but will match patterns below CWD if a value is provided." ) + # allow --isolate to be specified after the subcommand as well + common.add_argument( + "--isolate", + action="store_true", + default=False, + help="If set, run in an isolated virtual environment." + ) subparsers = parser.add_subparsers(title="commands", dest="command") - # register our checks with common params + # register our checks with the common params as their parent whl().register(subparsers, [common]) depends().register(subparsers, [common]) return parser - -def main(argv: Optional[Sequence[str]] = None) -> int: +def handle_venv(isolate: bool, args: argparse.Namespace) -> None: + """Handle virtual environment commands.""" + # we are already in an isolated venv and so do not need to recurse + if(os.getenv("AZURE_SDK_TOOLS_VENV", None)): + return + + # however, if we are not already in an isolated venv, and should be, then we need to + # call + if (isolate): + os.environ["AZURE_SDK_TOOLS_VENV"] = "1" + + venv_cmd = get_venv_call() + venv_location = os.path.join(REPO_ROOT, f".venv_{args.command}") + # todo, make this a consistent directory based on the command + # I'm seriously thinking we should move handle_venv within each check's main(), + # which will mean that we will _know_ what folder we're in. + # however, that comes at the cost of not having every check be able to handle one or multiple packages + # I don't want to get into an isolation loop where every time we need a new venv, we create it, call it, + # and now as we foreach across the targeted packages we've lost our spot. + check_call(venv_cmd + [venv_location]) + + # now use the current virtual environment to install os.path.join(REPO_ROOT, eng/tools/azure-sdk-tools[build]) + # into the NEW virtual env + install_into_venv(venv_location, os.path.join(REPO_ROOT, "eng/tools/azure-sdk-tools"), False, "build") + venv_python_exe = get_venv_python(venv_location) + command_args = [venv_python_exe, "-m", "azpysdk.main"] + sys.argv[1:] + + breakpoint() + check_call(command_args) + + +def main(argv: Optional[Sequence[str]] = None) -> int:# """CLI entrypoint. Args: @@ -58,6 +109,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return 1 try: + # scbedd 8/25 I'm betting that this would be best placed within the check itself, + # but leaving this for now + handle_venv(args.isolate, args) result = args.func(args) return int(result or 0) except KeyboardInterrupt: diff --git a/eng/tools/azure-sdk-tools/azpysdk/whl.py b/eng/tools/azure-sdk-tools/azpysdk/whl.py index 125b3dedf2a8..d14dbfdae421 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/whl.py +++ b/eng/tools/azure-sdk-tools/azpysdk/whl.py @@ -10,6 +10,7 @@ from ci_tools.functions import discover_targeted_packages, is_error_code_5_allowed, pip_install from ci_tools.variables import set_envvar_defaults +from ci_tools.parsing import ParsedSetup from ci_tools.scenario.generation import create_package_and_install @@ -29,7 +30,6 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt p.set_defaults(func=self.run) # Add any additional arguments specific to WhlCheck here (do not re-add common handled by parents) - # todo: figure out venv abstraction mechanism via override def run(self, args: argparse.Namespace) -> int: """Run the whl check command.""" print("Running whl check...") @@ -44,13 +44,14 @@ def run(self, args: argparse.Namespace) -> int: results: List[int] = [] for pkg in targeted: + parsed = ParsedSetup.from_path(pkg) + dev_requirements = os.path.join(pkg, "dev_requirements.txt") if os.path.exists(dev_requirements): - pip_install([f"-r", f"{dev_requirements}"], sys.executable) + pip_install([f"-r", f"{dev_requirements}"], True, sys.executable) staging_area = tempfile.mkdtemp() - create_package_and_install( distribution_directory=staging_area, target_setup=pkg, @@ -70,7 +71,7 @@ def run(self, args: argparse.Namespace) -> int: ) if exit_code != 0: - if exit_code == 5 and is_error_code_5_allowed(): + if exit_code == 5 and is_error_code_5_allowed(parsed.folder, parsed.name): logging.info("Exit code 5 is allowed, continuing execution.") else: logging.info(f"pytest failed with exit code {exit_code}.") diff --git a/eng/tools/azure-sdk-tools/ci_tools/functions.py b/eng/tools/azure-sdk-tools/ci_tools/functions.py index bba314d0a6eb..999bb919d3c5 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/functions.py +++ b/eng/tools/azure-sdk-tools/ci_tools/functions.py @@ -930,6 +930,24 @@ def verify_package_classifiers(package_name: str, package_version: str, package_ return True, None +def get_venv_call(python_exe: Optional[str] = None) -> List[str]: + """ + Determine whether to use 'uv venv' or regular 'python -m venv' based on environment. + + :param str python_exe: The Python executable to use (if not using the default). + :return: List of command arguments for venv. + :rtype: List[str] + + """ + # Check TOX_PIP_IMPL environment variable (aligns with tox.ini configuration) + pip_impl = os.environ.get('TOX_PIP_IMPL', 'pip').lower() + + # soon we will change this to default to uv + if pip_impl == 'uv': + return ["uv", "venv"] + else: + return [python_exe if python_exe else sys.executable, "-m", "venv"] + def get_pip_command(python_exe: Optional[str] = None) -> List[str]: """ Determine whether to use 'uv pip' or regular 'pip' based on environment. diff --git a/eng/tools/azure-sdk-tools/ci_tools/scenario/__init__.py b/eng/tools/azure-sdk-tools/ci_tools/scenario/__init__.py index 295ec630b350..2e079c44140b 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/scenario/__init__.py +++ b/eng/tools/azure-sdk-tools/ci_tools/scenario/__init__.py @@ -1,4 +1,48 @@ +import os +from subprocess import check_call from .generation import prepare_and_test_optional from .managed_virtual_env import ManagedVirtualEnv +from ci_tools.functions import ( + get_pip_command +) -__all__ = ["prepare_and_test_optional", "ManagedVirtualEnv"] +from typing import Optional +# todo rename managed_virtual_env to virtual_env and move below functions there + +def get_venv_python(venv_path: str) -> str: + """ + Given a python venv path, identify the crossplat reference to the python executable. + """ + # cross-platform python in a venv + bin_dir = "Scripts" if os.name == "nt" else "bin" + return os.path.join(venv_path, bin_dir, "python") + +def install_into_venv(venv_path: str, package_path: str, editable: bool = True, extras: Optional[str] = None) -> None: + """ + Install the package into an existing venv (venv_path) without activating it. + + - Uses get_pip_command(get_venv_python) per request. + - If get_pip_command returns the 'uv' wrapper, we fall back to get_venv_python -m pip + so installation goes into the target venv reliably. + """ + py = get_venv_python(venv_path) + pip_cmd = get_pip_command(py) + + install_target = package_path + if extras: + install_target = f"{package_path}[{extras}]" + + if editable: + cmd = pip_cmd + ["install", "-e", install_target] + else: + cmd = pip_cmd + ["install", install_target] + + if pip_cmd[0] == "uv": + cmd += ["--python", py] + + # Run the install; this will install into the interpreter referenced by `py` either by + # the pip command or by calling uv with a target python env + check_call(cmd) + + +__all__ = ["prepare_and_test_optional", "ManagedVirtualEnv", "install_into_venv", "get_venv_python"] diff --git a/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py b/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py index a5c80d82b23f..a216f927992a 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py +++ b/eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py @@ -35,12 +35,12 @@ def create_package_and_install( distribution_directory: str, target_setup: str, skip_install: bool, - cache_dir: str, + cache_dir: Optional[str], work_dir: str, force_create: bool, package_type: str, pre_download_disabled: bool, - python_executable: str = None, + python_executable: Optional[str] = None, ) -> None: """ Workhorse for singular package installation given a package and a possible prebuilt wheel directory. Handles installation of both package AND dependencies, handling compatibility diff --git a/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py b/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py index 9d3dc0c6145e..dd332087bac4 100644 --- a/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py +++ b/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py @@ -237,8 +237,14 @@ def combined_call(*args, **kwargs): except ResourceNotFoundError as error: error_body = ContentDecodePolicy.deserialize_from_http_generics(error.response) + troubleshoot = ( + "Playback failure -- for help resolving, see https://aka.ms/azsdk/python/test-proxy/troubleshoot." + ) message = error_body.get("message") or error_body.get("Message") - error_with_message = ResourceNotFoundError(message=message, response=error.response) + error_with_message = ResourceNotFoundError( + message=f"{troubleshoot} Error details:\n{message}", + response=error.response, + ) six.raise_from(error_with_message, error) finally: diff --git a/eng/tools/azure-sdk-tools/pyproject.toml b/eng/tools/azure-sdk-tools/pyproject.toml index fcb4bccc9ad2..90a2dc26f91d 100644 --- a/eng/tools/azure-sdk-tools/pyproject.toml +++ b/eng/tools/azure-sdk-tools/pyproject.toml @@ -50,7 +50,7 @@ systemperf = "devtools_testutils.perfstress_tests:run_system_perfstress_tests_cm azpysdk = "azpysdk.main:main" [project.optional-dependencies] -build = ["six", "setuptools", "pyparsing", "certifi", "cibuildwheel", "pkginfo", "build"] +build = ["setuptools", "pyparsing", "certifi", "cibuildwheel", "pkginfo", "build"] conda = ["beautifulsoup4"] systemperf = ["aiohttp>=3.0", "requests>=2.0", "tornado==6.0.3", "httpx>=0.21", "azure-core"] ghtools = ["GitPython", "PyGithub>=1.59.0", "requests>=2.0"] diff --git a/eng/tox/import_all.py b/eng/tox/import_all.py index 5237cb88a837..d3713d5e667f 100644 --- a/eng/tox/import_all.py +++ b/eng/tox/import_all.py @@ -43,7 +43,7 @@ def should_run_import_all(package_name): # get target package name from target package path pkg_dir = os.path.abspath(args.target_package) pkg_details = ParsedSetup.from_path(pkg_dir) - + if should_run_import_all(pkg_details.name): # import all modules from current package logging.info( diff --git a/scripts/dev_setup.py b/scripts/dev_setup.py index b901c4f0effe..12b79276b25b 100644 --- a/scripts/dev_setup.py +++ b/scripts/dev_setup.py @@ -75,7 +75,7 @@ def select_install_type(pkg, run_develop, exceptions): packages = { tuple(os.path.dirname(f).rsplit(os.sep, 1)) - for f in glob.glob(os.path.join(root_dir, "sdk/*/azure-*/setup.py")) + glob.glob(os.path.join(root_dir, "eng/tools/azure-*/setup.py")) + for f in glob.glob(os.path.join(root_dir, "sdk/*/azure-*/setup.py")) + glob.glob(os.path.join(root_dir, "eng/tools/azure-sdk-tools/")) } # [(base_folder, package_name), ...] to {package_name: base_folder, ...} packages = {package_name: base_folder for (base_folder, package_name) in packages} From 1f41c5b7fc9389519e8fe02561c43af669044150 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 25 Aug 2025 16:56:04 +0000 Subject: [PATCH 08/11] commit doc fixes + updates to runtime code --- doc/dev/test_proxy_troubleshooting.md | 2 +- eng/tools/azure-sdk-tools/azpysdk/Check.py | 2 -- .../azpysdk/{depends.py => import_all.py} | 17 +++++++++-------- eng/tools/azure-sdk-tools/azpysdk/main.py | 4 ++-- eng/tools/azure-sdk-tools/azpysdk/whl.py | 7 ++----- 5 files changed, 14 insertions(+), 18 deletions(-) rename eng/tools/azure-sdk-tools/azpysdk/{depends.py => import_all.py} (80%) diff --git a/doc/dev/test_proxy_troubleshooting.md b/doc/dev/test_proxy_troubleshooting.md index 6cf5ae6fe778..6f7cc2256a86 100644 --- a/doc/dev/test_proxy_troubleshooting.md +++ b/doc/dev/test_proxy_troubleshooting.md @@ -484,4 +484,4 @@ If your tests require an HTTPS endpoint, reach out to the Azure SDK team for ass [pytest_commands]: https://docs.pytest.org/latest/usage.html [record_request_failure]: https://github.com/Azure/azure-sdk-for-python/blob/9958caf6269247f940c697a3f982bbbf0a47a19b/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py#L91 [test_proxy_sanitizers]: https://github.com/Azure/azure-sdk-tools/blob/57382d5dc00b10a2f9cfd597293eeee0c2dbd8fd/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/SanitizerDictionary.cs#L65 -[wrong_exception]: https://github.com/Azure/azure-sdk-tools/issues/2907 +[wrong_exception]: https://github.com/Azure/azure-sdk-tools/issues/2907 \ No newline at end of file diff --git a/eng/tools/azure-sdk-tools/azpysdk/Check.py b/eng/tools/azure-sdk-tools/azpysdk/Check.py index 6984a1aa751e..98f461b61e6a 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/Check.py +++ b/eng/tools/azure-sdk-tools/azpysdk/Check.py @@ -28,6 +28,4 @@ def run(self, args: argparse.Namespace) -> int: Subclasses can override this to perform the actual work. """ - print(f"Running check: {self.name} ...") - print(args) return 0 \ No newline at end of file diff --git a/eng/tools/azure-sdk-tools/azpysdk/depends.py b/eng/tools/azure-sdk-tools/azpysdk/import_all.py similarity index 80% rename from eng/tools/azure-sdk-tools/azpysdk/depends.py rename to eng/tools/azure-sdk-tools/azpysdk/import_all.py index f3669cff2096..d6008d21d3c6 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/depends.py +++ b/eng/tools/azure-sdk-tools/azpysdk/import_all.py @@ -22,26 +22,27 @@ def should_run_import_all(package_name: str) -> bool: return not (package_name in excluded_packages or "nspkg" in package_name) -class depends(Check): +class import_all(Check): def __init__(self) -> None: super().__init__() def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: - """Register the `depends` check. + """Register the `import_all` check. The import_all check checks dependencies of a package + by installing just the package + its dependencies, then attempts to import * from the base namespace. - `subparsers` is the object returned by ArgumentParser.add_subparsers(). - `parent_parsers` is an optional list of parent ArgumentParser objects - that contain common arguments. Avoid mutating default arguments. + If it fails, there is a dependency being imported somewhere in the package namespace that doesn't + exist in the `pyproject.toml`/`setup.py` dependencies. EG failure to import `isodate` within `azure.storage.blob.BlobClient` + because `isodate` is not listed as a dependency for the package.. """ parents = parent_parsers or [] - p = subparsers.add_parser("depends", parents=parents, help="Run the depends check") + p = subparsers.add_parser("import_all", parents=parents, help="Run the import_all check") p.set_defaults(func=self.run) # Add any additional arguments specific to WhlCheck here (do not re-add common args) # todo: figure out venv abstraction mechanism via override def run(self, args: argparse.Namespace) -> int: - """Run the depends check command.""" - print("Running depends check in isolated venv...") + """Run the import_all check command.""" + print("Running import_all check in isolated venv...") # this is common. we should have an abstraction layer for this somehow if args.target == ".": diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index aef3e768c9ab..e5510f9d9c7d 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -14,7 +14,7 @@ from subprocess import check_call from .whl import whl -from .depends import depends +from .import_all import import_all from ci_tools.scenario import install_into_venv, get_venv_python from ci_tools.functions import get_venv_call @@ -57,7 +57,7 @@ def build_parser() -> argparse.ArgumentParser: # register our checks with the common params as their parent whl().register(subparsers, [common]) - depends().register(subparsers, [common]) + import_all().register(subparsers, [common]) return parser diff --git a/eng/tools/azure-sdk-tools/azpysdk/whl.py b/eng/tools/azure-sdk-tools/azpysdk/whl.py index d14dbfdae421..0e879f049c37 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/whl.py +++ b/eng/tools/azure-sdk-tools/azpysdk/whl.py @@ -19,11 +19,8 @@ def __init__(self) -> None: super().__init__() def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: - """Register the `whl` check. - - `subparsers` is the object returned by ArgumentParser.add_subparsers(). - `parent_parsers` is an optional list of parent ArgumentParser objects - that contain common arguments. Avoid mutating default arguments. + """Register the `whl` check. The `whl` check installs the wheel version of the target package + its dev_requirements.txt, + then invokes pytest. Failures indicate a test issue. """ parents = parent_parsers or [] p = subparsers.add_parser("whl", parents=parents, help="Run the whl check") From 64edd5ff73829c8f8f88e99f7bf8da930c8727a0 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Mon, 25 Aug 2025 17:09:35 +0000 Subject: [PATCH 09/11] remove the extra period --- eng/tools/azure-sdk-tools/azpysdk/import_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/tools/azure-sdk-tools/azpysdk/import_all.py b/eng/tools/azure-sdk-tools/azpysdk/import_all.py index d6008d21d3c6..2da92970dfd2 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/import_all.py +++ b/eng/tools/azure-sdk-tools/azpysdk/import_all.py @@ -32,7 +32,7 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt If it fails, there is a dependency being imported somewhere in the package namespace that doesn't exist in the `pyproject.toml`/`setup.py` dependencies. EG failure to import `isodate` within `azure.storage.blob.BlobClient` - because `isodate` is not listed as a dependency for the package.. + because `isodate` is not listed as a dependency for the package. """ parents = parent_parsers or [] p = subparsers.add_parser("import_all", parents=parents, help="Run the import_all check") From 3580c2e0e4a4ba458d331f7978f9b1efff22b5e1 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 26 Aug 2025 17:27:11 +0000 Subject: [PATCH 10/11] cleanup readme / remove breakpoint --- eng/tools/azure-sdk-tools/README.md | 114 ++++++++++++++++++--- eng/tools/azure-sdk-tools/azpysdk/Check.py | 2 +- eng/tools/azure-sdk-tools/azpysdk/main.py | 4 - 3 files changed, 98 insertions(+), 22 deletions(-) diff --git a/eng/tools/azure-sdk-tools/README.md b/eng/tools/azure-sdk-tools/README.md index def4493d0d15..ce9ab4f20de3 100644 --- a/eng/tools/azure-sdk-tools/README.md +++ b/eng/tools/azure-sdk-tools/README.md @@ -24,10 +24,13 @@ This package is intended for usage in direct combination with the azure-sdk-for- | Module | Description | |---|---| +| `azpysdk` | CI check entrypoints and accompanying implementations. | +| `ci_tools` | Various azure-sdk-for-python specific build and test abstractions. Heavily used in CI functionality. | | `devtools_testutils` | Primary location for test classes, pytest fixtures, and test-proxy integration. | -| `ci_tools` | Various azure-sdk-for-python specific build and test abstractions. | | `packaging_tools` | Templated package generator for management packages. | +| `parsing` | Parse information _about_ python packages at rest on disk. Used to interrogate the monorepo to find relevant packages. | | `pypi_tools` | Helper functionality build upon interactions with PyPI. | +| `scenario` | Functionality to do with installing packages constrained by environmental and input factors. Heavily used in `mindependency` and `latestdependency` CI checks. | | `testutils` | Backwards compatible extension of test classes. | **PLEASE NOTE.** For the "script" entrypoints provided by the package, all should either be run from somewhere **within** the azure-sdk-for-python repository. Barring that, an argument `--repo` should be provided that points to the repo root if a user must start the command from a different CWD. @@ -37,33 +40,39 @@ This package is intended for usage in direct combination with the azure-sdk-for- After installing azure-sdk-tools, package build functionality is available through `sdk_build`. ```text -usage: sdk_build [-h] [-d DISTRIBUTION_DIRECTORY] [--service SERVICE] [--pkgfilter PACKAGE_FILTER_STRING] [--devbuild IS_DEV_BUILD] - [--produce_apiview_artifact] [--repo REPO] [--build_id BUILD_ID] +usage: sdk_build [-h] [-d DISTRIBUTION_DIRECTORY] [--service SERVICE] [--pkgfilter PACKAGE_FILTER_STRING] + [--devbuild IS_DEV_BUILD] [--inactive] [--produce_apiview_artifact] [--repo REPO] [--build_id BUILD_ID] [glob_string] -This is the primary entrypoint for the "build" action. This command is used to build any package within the azure-sdk-for-python repository. +This is the primary entrypoint for the "build" action. This command is used to build any package within the azure-sdk-for- +python repository. positional arguments: - glob_string A comma separated list of glob strings that will target the top level directories that contain packages. Examples: All == "azure-*", - Single = "azure-keyvault" + glob_string A comma separated list of glob strings that will target the top level directories that contain + packages. Examples: All == "azure-*", Single = "azure-keyvault" -optional arguments: +options: -h, --help show this help message and exit -d DISTRIBUTION_DIRECTORY, --distribution-directory DISTRIBUTION_DIRECTORY - The path to the distribution directory. Should be passed $(Build.ArtifactStagingDirectory) from the devops yaml definition.If that - is not provided, will default to env variable SDK_ARTIFACT_DIRECTORY -> /.artifacts. + The path to the distribution directory. Should be passed $(Build.ArtifactStagingDirectory) from the + devops yaml definition.If that is not provided, will default to env variable SDK_ARTIFACT_DIRECTORY + -> /.artifacts. --service SERVICE Name of service directory (under sdk/) to build.Example: --service applicationinsights --pkgfilter PACKAGE_FILTER_STRING - An additional string used to filter the set of artifacts by a simple CONTAINS clause. This filters packages AFTER the set is built - with compatibility and omission lists accounted. + An additional string used to filter the set of artifacts by a simple CONTAINS clause. This filters + packages AFTER the set is built with compatibility and omission lists accounted. --devbuild IS_DEV_BUILD - Set build type to dev build so package requirements will be updated if required package is not available on PyPI + Set build type to dev build so package requirements will be updated if required package is not + available on PyPI + --inactive Include inactive packages when assembling artifacts. CI builds will include inactive packages as a + way to ensure that the yml controlled artifacts can be associated with a wheel/sdist. --produce_apiview_artifact - Should an additional build artifact that contains the targeted package + its direct dependencies be produced? - --repo REPO Where is the start directory that we are building against? If not provided, the current working directory will be used. Please - ensure you are within the azure-sdk-for-python repository. - --build_id BUILD_ID The current build id. It not provided, will default through environment variables in the following order: GITHUB_RUN_ID -> - BUILD_BUILDID -> SDK_BUILD_ID -> default value. + Should an additional build artifact that contains the targeted package + its direct dependencies be + produced? + --repo REPO Where is the start directory that we are building against? If not provided, the current working + directory will be used. Please ensure you are within the azure-sdk-for-python repository. + --build_id BUILD_ID The current build id. It not provided, will default through environment variables in the following + order: GITHUB_RUN_ID -> BUILD_BUILDID -> SDK_BUILD_ID -> default value. ``` Some common invocations. @@ -149,6 +158,77 @@ optional arguments: ensure you are within the azure-sdk-for-python repository. ``` +## Using `Parsing` Modules + +```python +from ci_tools.parsing import ParsedSetup + +path_to_package = "path/to/your/possible/python/package" + +pkg_metadata = ParsedSetup.from_path(path_to_package) + +# pkg_metadata will contain all metadata about the package, EG "name", "version", "python_requires", "dependencies" +# any information that any of our checks would need to operate properly. +``` + +## Writing additional `checks` within `azpysdk` + +To add a new check to `azpysdk`, follow these steps. + +- Create a new file within `azpysdk`. Name it after what your check will be. +- When creating a class for your new check, you should inherit from the class `Check` within the `azpysdk` namespace. +- Implement the required functions within the `Check` abstract class in your new check class. + +This is a commented implementation providing the reasoning for implementation alongside the code. + +```python +class my_check(Check): + def __init__(self) -> None: + super().__init__() + + + def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: + """Register `my_check`. `my_check` does X and Y while doing Z. + + This function will be used to add your command to the set of commands being shown through the `azpysdk` entrypoint. + """ + parents = parent_parsers or [] + p = subparsers.add_parser("whl", parents=parents, help="Run the `my_check` check") + p.set_defaults(func=self.run) + # Add any additional arguments specific to your check here (do not re-add common handled by parents. See `main.py` build_parser to see the common parents provided to the checks) + + def run(self, args: argparse.Namespace) -> int: + """This is the recommended """ + set_envvar_defaults() + + if args.target == ".": + targeted = [os.getcwd()] + else: + target_dir = os.getcwd() + targeted = discover_targeted_packages(args.target, target_dir) + results: List[int] = [] + + for pkg in targeted: + parsed = ParsedSetup.from_path(pkg) + print(f"Processing {pkg.name} for my_check") +``` + +- Once the new check has been created, register it in `azpysdk/main.py` on line 58. This will likely be automated by decorator in the near future but this is out how it should be done for now. + +```python +# ...other registrations above +my_check().register(subparsers, [common]) +``` + +- Your command will now be available through `azpysdk` + +```bash +/> `azpysdk mycheck azure-storage*` + +/> cd sdk/storage/azure-storage +sdk/storage/azure-storage/> azpysdk mycheck +``` + ## Relevant Environment Variables This package honors a few different environment variables as far as logging, artifact placement, etc. diff --git a/eng/tools/azure-sdk-tools/azpysdk/Check.py b/eng/tools/azure-sdk-tools/azpysdk/Check.py index 98f461b61e6a..1cb90b4478f2 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/Check.py +++ b/eng/tools/azure-sdk-tools/azpysdk/Check.py @@ -10,7 +10,7 @@ class Check(abc.ABC): """ def __init__(self) -> None: - self.isolated_namespace = False + pass @abc.abstractmethod def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: diff --git a/eng/tools/azure-sdk-tools/azpysdk/main.py b/eng/tools/azure-sdk-tools/azpysdk/main.py index e5510f9d9c7d..5b89c7f65d68 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/main.py +++ b/eng/tools/azure-sdk-tools/azpysdk/main.py @@ -87,11 +87,8 @@ def handle_venv(isolate: bool, args: argparse.Namespace) -> None: install_into_venv(venv_location, os.path.join(REPO_ROOT, "eng/tools/azure-sdk-tools"), False, "build") venv_python_exe = get_venv_python(venv_location) command_args = [venv_python_exe, "-m", "azpysdk.main"] + sys.argv[1:] - - breakpoint() check_call(command_args) - def main(argv: Optional[Sequence[str]] = None) -> int:# """CLI entrypoint. @@ -121,6 +118,5 @@ def main(argv: Optional[Sequence[str]] = None) -> int:# print(f"Error: {exc}", file=sys.stderr) return 2 - if __name__ == "__main__": raise SystemExit(main()) From c6811fee3a0e4165f6578e19032dceb201b748b0 Mon Sep 17 00:00:00 2001 From: Scott Beddall Date: Tue, 26 Aug 2025 17:32:54 +0000 Subject: [PATCH 11/11] revert troubleshooting guide --- doc/dev/test_proxy_troubleshooting.md | 44 +++++++++++---------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/doc/dev/test_proxy_troubleshooting.md b/doc/dev/test_proxy_troubleshooting.md index 6f7cc2256a86..bd61fc60dfbd 100644 --- a/doc/dev/test_proxy_troubleshooting.md +++ b/doc/dev/test_proxy_troubleshooting.md @@ -7,32 +7,22 @@ GitHub repository, but this isn't necessary to read for Python testing. ## Table of contents -- [Guide for test proxy troubleshooting](#guide-for-test-proxy-troubleshooting) - - [Table of contents](#table-of-contents) - - [Debugging tip](#debugging-tip) - - [ResourceNotFoundError: Playback failure](#resourcenotfounderror-playback-failure) - - [Test collection failure](#test-collection-failure) - - [Special case: using `pytest.mark.parametrize` with recorded tests](#special-case-using-pytestmarkparametrize-with-recorded-tests) - - [Errors in tests using resource preparers](#errors-in-tests-using-resource-preparers) - - [Test failure during `record/start` or `playback/start` requests](#test-failure-during-recordstart-or-playbackstart-requests) - - [Playback failures from body matching errors](#playback-failures-from-body-matching-errors) - - [Playback failures from inconsistent line breaks](#playback-failures-from-inconsistent-line-breaks) - - [Playback failures from URL mismatches](#playback-failures-from-url-mismatches) - - [Duplicated slash(es) in URLs](#duplicated-slashes-in-urls) - - [Inconsistent query parameter ordering](#inconsistent-query-parameter-ordering) - - [Sanitization impacting request URL/body/headers](#sanitization-impacting-request-urlbodyheaders) - - [Opt out](#opt-out) - - [Add another sanitizer](#add-another-sanitizer) - - [Playback failures from inconsistent test values](#playback-failures-from-inconsistent-test-values) - - [Recordings not being produced](#recordings-not-being-produced) - - [ConnectionError during tests](#connectionerror-during-tests) - - [Different error than expected when using proxy](#different-error-than-expected-when-using-proxy) - - [Test setup failure in test pipeline](#test-setup-failure-in-test-pipeline) - - [CI pipelines](#ci-pipelines) - - [Live test pipelines](#live-test-pipelines) - - [Fixture not found error](#fixture-not-found-error) - - [PermissionError during startup](#permissionerror-during-startup) - - [ServiceRequestError: Cannot connect to host](#servicerequesterror-cannot-connect-to-host) +- [Debugging tip](#debugging-tip) +- [ResourceNotFoundError: Playback failure](#resourcenotfounderror-playback-failure) +- [Test collection failure](#test-collection-failure) +- [Errors in tests using resource preparers](#errors-in-tests-using-resource-preparers) +- [Test failure during `record/start` or `playback/start` requests](#test-failure-during-recordstart-or-playbackstart-requests) +- [Playback failures from body matching errors](#playback-failures-from-body-matching-errors) +- [Playback failures from inconsistent line breaks](#playback-failures-from-inconsistent-line-breaks) +- [Playback failures from URL mismatches](#playback-failures-from-url-mismatches) +- [Playback failures from inconsistent test values](#playback-failures-from-inconsistent-test-values) +- [Recordings not being produced](#recordings-not-being-produced) +- [ConnectionError during tests](#connectionerror-during-tests) +- [Different error than expected when using proxy](#different-error-than-expected-when-using-proxy) +- [Test setup failure in test pipeline](#test-setup-failure-in-test-pipeline) +- [Fixture not found error](#fixture-not-found-error) +- [PermissionError during startup](#permissionerror-during-startup) +- [ServiceRequestError: Cannot connect to host](#servicerequesterror-cannot-connect-to-host) ## Debugging tip @@ -484,4 +474,4 @@ If your tests require an HTTPS endpoint, reach out to the Azure SDK team for ass [pytest_commands]: https://docs.pytest.org/latest/usage.html [record_request_failure]: https://github.com/Azure/azure-sdk-for-python/blob/9958caf6269247f940c697a3f982bbbf0a47a19b/eng/tools/azure-sdk-tools/devtools_testutils/proxy_testcase.py#L91 [test_proxy_sanitizers]: https://github.com/Azure/azure-sdk-tools/blob/57382d5dc00b10a2f9cfd597293eeee0c2dbd8fd/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/SanitizerDictionary.cs#L65 -[wrong_exception]: https://github.com/Azure/azure-sdk-tools/issues/2907 \ No newline at end of file +[wrong_exception]: https://github.com/Azure/azure-sdk-tools/issues/2907