diff --git a/eng/tools/ci/azure-sdk-tools/README.md b/eng/tools/ci/azure-sdk-tools/README.md new file mode 100644 index 000000000000..5ad54468b27b --- /dev/null +++ b/eng/tools/ci/azure-sdk-tools/README.md @@ -0,0 +1,3 @@ +# Readme for azure-sdk-tools + +This will be the location of the moved azure-sdk-tools after it's moved from `tools/azure-sdk-tools` to here. diff --git a/eng/tools/ci/uv/README.md b/eng/tools/ci/uv/README.md new file mode 100644 index 000000000000..d643ca4b3222 --- /dev/null +++ b/eng/tools/ci/uv/README.md @@ -0,0 +1,63 @@ +# `uv` script checks for `azure-sdk-for-python` + +The scripts contained within this directory are self-contained validation scripts that are intended to be used by CI and for local development. + +## Example Check Invocations + +- `uv run tools/ci/uv/.py azure-core` +- `uv run tools/ci/uv/.py azure-storage*` +- `uv run tools/ci/uv/.py azure-storage-blob,azure-storage-queue` + +## Outstanding questions + +### Using tool instead of script? + +- Should we instead create a root `pyproject.toml` for each of these and install as a `uv tool`? + - ``` + tools/ + ci/ + uv/ + whl/ + whl.py + pyproject.toml + ``` + - Doing the above will allow us to install the check as a `tool` (which will have an isolated venv) and enable easy access via a named entrypoint on the PATH. + - We will need to be more explicit about cleaning up the venv being used in CI, versus `uv run` which is purely ephemeral unless told otherwise. +- Another option would be to: + - ``` + tools/ + ci/ + uv/ + whl.py + mindependency.py + pyproject.toml -> reference both + ``` + +### How do I debug when I'm modifying a new `uv` check? + +todo: once scbedd r someone else has a good story for this in vscode + pylance. + +### Is there a good way to pass on custom arguments that are after the -- in the uv script invocation + +todo: discover this + +## Guidelines for creating a uv check + +- You **must** include `uv` in the list of dependencies for your script. This will enable access to `uv pip` from within the environment without assuming anything about a global `uv` install. This will enable `subprocess.run([sys.executable, '-m', 'uv', 'pip', 'install', 'packagename'])` with all the efficiency implied by using `uv`. +- You **must** `uv add --script tools/ci/uv/whl.py "-r eng/ci_tools.txt"` to ensure that the generic pinned dependencies are present on your script. +- You **must** preface your scripts with `azpy`. So the `whl` check would be `azpy-whl.py`. +- You **must** cleave to the common argparse definition to ensure that all the checks have similar entrypoint behavior. + - This does not exist yet. + +## Transition from `tox` details + +### Pros + +- The speed is astonishing +- We get ephemeral venvs by default +- We can encode a ton more information due to the freedom of a pure-python script invoking all the work. + +### Cons + +- A single unified dependency file is not supported. When we update we will just have to `uv add --script -r eng/ci_tools.txt` which will update all the PEP723 preambles for our check scripts. +- We will need to parallelize invocation of these environments ourselves and deal with any gotchas. We'll do that in `dispatch_uv.py` which will be a follow-up to `dispatch_tox.py`. \ No newline at end of file diff --git a/eng/tools/ci/uv/azpy-whl.py b/eng/tools/ci/uv/azpy-whl.py new file mode 100644 index 000000000000..06b85367556c --- /dev/null +++ b/eng/tools/ci/uv/azpy-whl.py @@ -0,0 +1,89 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "wheel==0.45.1", +# "packaging==24.2", +# "urllib3==2.2.3", +# "tomli", +# "build==1.2.2.post1", +# "pytest==8.3.5", +# "pytest-cov==5.0.0", +# "azure-sdk-tools", +# "setuptools", +# "pytest-asyncio==0.24.0", +# "pytest-custom-exit-code==0.3.0", +# "pytest-xdist==3.2.1", +# "coverage==7.6.1", +# "bandit==1.6.2", +# "pyproject-api==1.8.0", +# "jinja2==3.1.6", +# "json-delta==2.0.2", +# "readme-renderer==43.0", +# "python-dotenv==1.0.1", +# "pyyaml==6.0.2", +# "six==1.17.0", +# "uv", +# ] +# +# [tool.uv.sources] +# azure-sdk-tools = { path = "../../../../tools/azure-sdk-tools", editable = true } +# /// + +import os +import argparse + +from ci_tools.functions import discover_targeted_packages + +from ci_tools.uv import install_uv_devrequirements, set_environment_variable_defaults, DEFAULT_ENVIRONMENT_VARIABLES, uv_pytest +from ci_tools.variables import discover_repo_root +from ci_tools.scenario.generation import create_package_and_install +import tempfile +import shutil +import os + +def main(): + parser = argparse.ArgumentParser( + description="Run dev tooling against a given package directory" + ) + parser.add_argument( + "target", + nargs="?", + default="azure-core", + help="Path to the target package folder (default: current directory)", + ) + args = parser.parse_args() + + set_environment_variable_defaults(DEFAULT_ENVIRONMENT_VARIABLES) + + # todo, we should use os.cwd as the target if no target is provided + # with some validation to ensure we're in a package directory (eg setup.py or pyproject.toml exists) and not repo root. + target_root_dir=discover_repo_root() + targeted = discover_targeted_packages(args.target, target_root_dir) + + failed = False + + for pkg in targeted: + install_uv_devrequirements( + pkg_path=pkg, + allow_nonpresence=True, + ) + + 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 + result = uv_pytest(target_path=pkg, additional_args=["--cov", "--cov-report=xml", "--cov-report=html"]) + +if __name__ == "__main__": + main() diff --git a/eng/tox/create_package_and_install.py b/eng/tox/create_package_and_install.py index 15c5bf926f9b..6d8f6317ca6e 100644 --- a/eng/tox/create_package_and_install.py +++ b/eng/tox/create_package_and_install.py @@ -94,4 +94,4 @@ ) - + diff --git a/scripts/devops_tasks/common_tasks.py b/scripts/devops_tasks/common_tasks.py index 864bcaccc39f..9f4a79001821 100644 --- a/scripts/devops_tasks/common_tasks.py +++ b/scripts/devops_tasks/common_tasks.py @@ -17,14 +17,12 @@ from subprocess import check_call, CalledProcessError, Popen from argparse import Namespace from typing import Iterable - -# Assumes the presence of setuptools -from pkg_resources import parse_version, parse_requirements, Requirement, WorkingSet, working_set +import importlib.metadata # this assumes the presence of "packaging" from packaging.specifiers import SpecifierSet -from ci_tools.functions import MANAGEMENT_PACKAGE_IDENTIFIERS, NO_TESTS_ALLOWED, lambda_filter_azure_pkg, str_to_bool +from ci_tools.functions import lambda_filter_azure_pkg, get_pip_list_output from ci_tools.parsing import parse_require, ParsedSetup DEV_REQ_FILE = "dev_requirements.txt" @@ -114,23 +112,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 = [ @@ -247,9 +228,13 @@ def find_tools_packages(root_path): def get_installed_packages(paths=None): """Find packages in default or given lib paths""" - # WorkingSet returns installed packages in given path - # working_set returns installed packages in default path - # if paths is set then find installed packages from given paths - ws = WorkingSet(paths) if paths else working_set - return ["{0}=={1}".format(p.project_name, p.version) for p in ws] + # Use importlib.metadata.distributions, optionally searching provided paths + if paths: + dists = importlib.metadata.distributions(path=paths) + else: + dists = importlib.metadata.distributions() + + newWS = [f"{k}=={v}" for k, v in get_pip_list_output(sys.executable).items()] + + return newWS diff --git a/scripts/devops_tasks/tox_harness.py b/scripts/devops_tasks/tox_harness.py index 9ffe33502591..21ddd707ab79 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/ci_tools/functions.py b/tools/azure-sdk-tools/ci_tools/functions.py index 08cef76625c4..eeeedc56a088 100644 --- a/tools/azure-sdk-tools/ci_tools/functions.py +++ b/tools/azure-sdk-tools/ci_tools/functions.py @@ -943,5 +943,26 @@ def get_pip_command(python_exe: Optional[str] = None) -> List[str]: if pip_impl == 'uv': return ["uv", "pip"] + # using environment uv and not depending on global uv install + elif os.environ.get('IN_UV', None) == '1': + return [python_exe if python_exe else sys.executable, "-m", "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/tools/azure-sdk-tools/ci_tools/scenario/generation.py b/tools/azure-sdk-tools/ci_tools/scenario/generation.py index 7eb473e58d61..d96de04ee058 100644 --- a/tools/azure-sdk-tools/ci_tools/scenario/generation.py +++ b/tools/azure-sdk-tools/ci_tools/scenario/generation.py @@ -112,7 +112,11 @@ 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/tools/azure-sdk-tools/ci_tools/uv/__init__.py b/tools/azure-sdk-tools/ci_tools/uv/__init__.py new file mode 100644 index 000000000000..ddcace71f26f --- /dev/null +++ b/tools/azure-sdk-tools/ci_tools/uv/__init__.py @@ -0,0 +1,5 @@ +from .manage import install_uv_devrequirements +from .prepare import set_environment_variable_defaults, DEFAULT_ENVIRONMENT_VARIABLES +from .invoke import uv_pytest + +__all__ = ["install_uv_devrequirements", "set_environment_variable_defaults", "DEFAULT_ENVIRONMENT_VARIABLES", "uv_pytest"] \ No newline at end of file diff --git a/tools/azure-sdk-tools/ci_tools/uv/invoke.py b/tools/azure-sdk-tools/ci_tools/uv/invoke.py new file mode 100644 index 000000000000..be45fb98753e --- /dev/null +++ b/tools/azure-sdk-tools/ci_tools/uv/invoke.py @@ -0,0 +1,32 @@ +import subprocess +import logging + +from pytest import main as pytest_main + +from ci_tools.functions import is_error_code_5_allowed + +def uv_pytest(target_path: str, additional_args: list[str] = [], shell: bool = False) -> bool: + logging.info(f"Invoke pytest for {target_path}") + + exit_code = 0 + + if shell: + result = subprocess.run( + [sys.executable, "-m", "pytest", target_path] + additional_args, + check=False + ) + exit_code = result.returncode + else: + exit_code = pytest_main( + [target_path] + additional_args + ) + + if exit_code != 0: + if exit_code == 5 and is_error_code_5_allowed(): + logging.info("Exit code 5 is allowed, continuing execution.") + return True + else: + logging.info(f"pytest failed with exit code {exit_code}.") + return False + + return True \ No newline at end of file diff --git a/tools/azure-sdk-tools/ci_tools/uv/manage.py b/tools/azure-sdk-tools/ci_tools/uv/manage.py new file mode 100644 index 000000000000..76b44551f598 --- /dev/null +++ b/tools/azure-sdk-tools/ci_tools/uv/manage.py @@ -0,0 +1,25 @@ +import os +import logging +import subprocess +import sys + +def install_uv_devrequirements(pkg_path: str, allow_nonpresence: bool = False): + """ + Installs the development requirements for a given package. + + Args: + pkg_path (str): The path to the package directory. + """ + + dev_req_path = os.path.join(pkg_path, "dev_requirements.txt") + + if not os.path.exists(dev_req_path) and not allow_nonpresence: + print(f"Development requirements file not found at {dev_req_path}") + raise FileNotFoundError(f"Development requirements file not found at {dev_req_path}") + + try: + command = [sys.executable, "-m", "uv", "pip", "install", "-r", dev_req_path] + subprocess.run(command, check=True, cwd=pkg_path) + logging.info(f"Successfully installed development requirements from {dev_req_path}.") + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to install development requirements: {e}") diff --git a/tools/azure-sdk-tools/ci_tools/uv/prepare.py b/tools/azure-sdk-tools/ci_tools/uv/prepare.py new file mode 100644 index 000000000000..e7a2dfa2276c --- /dev/null +++ b/tools/azure-sdk-tools/ci_tools/uv/prepare.py @@ -0,0 +1,25 @@ +from typing import Dict +import os + +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_variable_defaults(settings: Dict[str, str]) -> None: + """ + Sets default environment variables for the UV prepare script. + + 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) \ No newline at end of file