Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions eng/tools/ci/azure-sdk-tools/README.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions eng/tools/ci/uv/README.md
Original file line number Diff line number Diff line change
@@ -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/<checkname>.py azure-core`
- `uv run tools/ci/uv/<checkname>.py azure-storage*`
- `uv run tools/ci/uv/<checkname>.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 <check.py> -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`.
89 changes: 89 additions & 0 deletions eng/tools/ci/uv/azpy-whl.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion eng/tox/create_package_and_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,4 @@
)



37 changes: 11 additions & 26 deletions scripts/devops_tasks/common_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accidentally added will be backed out.

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

3 changes: 1 addition & 2 deletions scripts/devops_tasks/tox_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@
from common_tasks import (
run_check_call,
clean_coverage,
is_error_code_5_allowed,
create_code_coverage_params,
)

from ci_tools.variables import in_ci
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
Expand Down
21 changes: 21 additions & 0 deletions tools/azure-sdk-tools/ci_tools/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion tools/azure-sdk-tools/ci_tools/scenario/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions tools/azure-sdk-tools/ci_tools/uv/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
32 changes: 32 additions & 0 deletions tools/azure-sdk-tools/ci_tools/uv/invoke.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions tools/azure-sdk-tools/ci_tools/uv/manage.py
Original file line number Diff line number Diff line change
@@ -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}")
25 changes: 25 additions & 0 deletions tools/azure-sdk-tools/ci_tools/uv/prepare.py
Original file line number Diff line number Diff line change
@@ -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)
Loading