Skip to content

Commit 9642d96

Browse files
authored
Add pip_freeze to Check.py (#43591)
* add generic pip_freeze function to Check.py * use new pip_freeze in tools that used to manually pip_freeze * properly replace dev_requirements.txt, ci_tools.txt requirements, etc for checks invoked through dispatch_checks.py with prebuilt wheels. This also implies that the same wheel cache is passed in and utilized for all codepaths from dispatch_checks.py * update run_venv_command to identify incoming shell command, find it within the venv, and call it directly. shell command discovery isn't available from subprocess.run() without calling shell=True, which we do not want to do here. * refactor `sphinx` to use updated run_venv_command to call the sphinx within the isolated venv * remove `whl` from set of registered checks until it's ready to go
1 parent bc83fb4 commit 9642d96

File tree

11 files changed

+275
-180
lines changed

11 files changed

+275
-180
lines changed

.github/workflows/azure-sdk-tools.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,60 @@ jobs:
5353
black --check --config eng/black-pyproject.toml eng/tools/azure-sdk-tools --exclude 'templates'
5454
shell: bash
5555

56+
verify-azpysdk-checks:
57+
runs-on: ubuntu-latest
58+
steps:
59+
- uses: actions/checkout@v2
60+
61+
- name: Set up Python 3.13
62+
uses: actions/setup-python@v4
63+
with:
64+
python-version: 3.13
65+
66+
- name: Install uv
67+
run: |
68+
curl -LsSf https://astral.sh/uv/install.sh | sh
69+
shell: bash
70+
71+
- name: Install azure-sdk-tools on in global uv, discover azpysdk checks
72+
run: |
73+
uv pip install --system eng/tools/azure-sdk-tools[build,ghtools,conda]
74+
75+
# Discover available azpysdk commands from the {command1,command2,...} line in help output
76+
CHECKS=$(azpysdk -h 2>&1 | \
77+
grep -oP '\{[^}]+\}' | \
78+
tail -1 | \
79+
tr -d '{}' | \
80+
tr ',' '\n' | \
81+
grep -v '^next-' | \
82+
sort | \
83+
paste -sd,)
84+
85+
if [ -z "$CHECKS" ]; then
86+
echo "No azpysdk check modules discovered from azpysdk -h" >&2
87+
exit 1
88+
fi
89+
echo "Discovered azpysdk checks: $CHECKS"
90+
echo "AZPYSDK_CHECKS=$CHECKS" >> "$GITHUB_ENV"
91+
shell: bash
92+
93+
- name: Run all discovered checks against azure-template using uv as package manager
94+
run: |
95+
python eng/scripts/dispatch_checks.py --checks "$AZPYSDK_CHECKS" azure-template
96+
shell: bash
97+
env:
98+
TOX_PIP_IMPL: "uv"
99+
100+
- name: Install azure-sdk-tools on global pip env
101+
run: |
102+
python -m pip install -e eng/tools/azure-sdk-tools[build,ghtools,conda]
103+
shell: bash
104+
105+
- name: Run all discovered checks against azure-template using pip as package manager
106+
run: |
107+
python eng/scripts/dispatch_checks.py --checks "$AZPYSDK_CHECKS" azure-template
108+
shell: bash
109+
56110
dev-setup-and-import:
57111
runs-on: ubuntu-latest
58112
steps:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ conda/assembled/
6060
conda/downloaded/
6161
conda/conda-env/
6262
scenario_*.txt
63+
.wheels
6364

6465
# tox environment folders
6566
.tox/

eng/scripts/dispatch_checks.py

Lines changed: 44 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ci_tools.functions import discover_targeted_packages
1212
from ci_tools.variables import in_ci
13-
from ci_tools.scenario.generation import build_whl_for_req
13+
from ci_tools.scenario.generation import build_whl_for_req, replace_dev_reqs
1414
from ci_tools.logging import configure_logging, logger
1515
from ci_tools.environment_exclusions import is_check_enabled, CHECK_DEFAULTS
1616

@@ -73,9 +73,7 @@ async def run_check(
7373
stderr = stderr_b.decode(errors="replace")
7474
exit_code = proc.returncode or 0
7575
status = "OK" if exit_code == 0 else f"FAIL({exit_code})"
76-
logger.info(
77-
f"[END {idx}/{total}] {check} :: {package} -> {status} in {duration:.2f}s"
78-
)
76+
logger.info(f"[END {idx}/{total}] {check} :: {package} -> {status} in {duration:.2f}s")
7977
# Print captured output after completion to avoid interleaving
8078
header = f"===== OUTPUT: {check} :: {package} (exit {exit_code}) ====="
8179
trailer = "=" * len(header)
@@ -96,9 +94,7 @@ async def run_check(
9694
try:
9795
shutil.rmtree(isolate_dir)
9896
except:
99-
logger.warning(
100-
f"Failed to remove isolate dir {isolate_dir} for {package} / {check}"
101-
)
97+
logger.warning(f"Failed to remove isolate dir {isolate_dir} for {package} / {check}")
10298
return CheckResult(package, check, exit_code, duration, stdout, stderr)
10399

104100

@@ -122,18 +118,14 @@ def summarize(results: List[CheckResult]) -> int:
122118
print("-" * len(header))
123119
for r in sorted(results, key=lambda x: (x.exit_code != 0, x.package, x.check)):
124120
status = "OK" if r.exit_code == 0 else f"FAIL({r.exit_code})"
125-
print(
126-
f"{r.package.ljust(pkg_w)} {r.check.ljust(chk_w)} {status.ljust(8)} {r.duration:>10.2f}"
127-
)
121+
print(f"{r.package.ljust(pkg_w)} {r.check.ljust(chk_w)} {status.ljust(8)} {r.duration:>10.2f}")
128122
worst = max((r.exit_code for r in results), default=0)
129123
failed = [r for r in results if r.exit_code != 0]
130-
print(
131-
f"\nTotal checks: {len(results)} | Failed: {len(failed)} | Worst exit code: {worst}"
132-
)
124+
print(f"\nTotal checks: {len(results)} | Failed: {len(failed)} | Worst exit code: {worst}")
133125
return worst
134126

135127

136-
async def run_all_checks(packages, checks, max_parallel):
128+
async def run_all_checks(packages, checks, max_parallel, wheel_dir):
137129
"""Run all checks for all packages concurrently and return the worst exit code.
138130
139131
:param packages: Iterable of package paths to run checks against.
@@ -142,6 +134,9 @@ async def run_all_checks(packages, checks, max_parallel):
142134
:type checks: List[str]
143135
:param max_parallel: Maximum number of concurrent checks to run.
144136
:type max_parallel: int
137+
:param wheel_dir: The directory where wheels should be located and stored when built.
138+
In CI should correspond to `$(Build.ArtifactStagingDirectory)`.
139+
:type wheel_dir: str
145140
:returns: The worst exit code from all checks (0 if all passed).
146141
:rtype: int
147142
"""
@@ -150,17 +145,33 @@ async def run_all_checks(packages, checks, max_parallel):
150145
semaphore = asyncio.Semaphore(max_parallel)
151146
combos = [(p, c) for p in packages for c in checks]
152147
total = len(combos)
148+
149+
test_tools_path = os.path.join(root_dir, "eng", "test_tools.txt")
150+
dependency_tools_path = os.path.join(root_dir, "eng", "dependency_tools.txt")
151+
152+
if in_ci():
153+
logger.info("Replacing relative requirements in eng/test_tools.txt with prebuilt wheels.")
154+
replace_dev_reqs(test_tools_path, root_dir, wheel_dir)
155+
156+
logger.info("Replacing relative requirements in eng/dependency_tools.txt with prebuilt wheels.")
157+
replace_dev_reqs(dependency_tools_path, root_dir, wheel_dir)
158+
159+
for pkg in packages:
160+
destination_dev_req = os.path.join(pkg, "dev_requirements.txt")
161+
162+
logger.info(f"Replacing dev requirements w/ path {destination_dev_req}")
163+
if not os.path.exists(destination_dev_req):
164+
logger.info("No dev_requirements present.")
165+
with open(destination_dev_req, "w+") as file:
166+
file.write("\n")
167+
168+
replace_dev_reqs(destination_dev_req, pkg, wheel_dir)
169+
153170
for idx, (package, check) in enumerate(combos, start=1):
154171
if not is_check_enabled(package, check, CHECK_DEFAULTS.get(check, True)):
155-
logger.warning(
156-
f"Skipping disabled check {check} ({idx}/{total}) for package {package}"
157-
)
172+
logger.warning(f"Skipping disabled check {check} ({idx}/{total}) for package {package}")
158173
continue
159-
tasks.append(
160-
asyncio.create_task(
161-
run_check(semaphore, package, check, base_args, idx, total)
162-
)
163-
)
174+
tasks.append(asyncio.create_task(run_check(semaphore, package, check, base_args, idx, total)))
164175

165176
# Handle Ctrl+C gracefully
166177
pending = set(tasks)
@@ -179,9 +190,7 @@ async def run_all_checks(packages, checks, max_parallel):
179190
elif isinstance(res, Exception):
180191
norm_results.append(CheckResult(package, check, 99, 0.0, "", str(res)))
181192
else:
182-
norm_results.append(
183-
CheckResult(package, check, 98, 0.0, "", f"Unknown result type: {res}")
184-
)
193+
norm_results.append(CheckResult(package, check, 98, 0.0, "", f"Unknown result type: {res}"))
185194
return summarize(norm_results)
186195

187196

@@ -257,15 +266,11 @@ def handler(signum, frame):
257266
),
258267
)
259268

260-
parser.add_argument(
261-
"--disablecov", help=("Flag. Disables code coverage."), action="store_true"
262-
)
269+
parser.add_argument("--disablecov", help=("Flag. Disables code coverage."), action="store_true")
263270

264271
parser.add_argument(
265272
"--service",
266-
help=(
267-
"Name of service directory (under sdk/) to test. Example: --service applicationinsights"
268-
),
273+
help=("Name of service directory (under sdk/) to test. Example: --service applicationinsights"),
269274
)
270275

271276
parser.add_argument(
@@ -325,9 +330,7 @@ def handler(signum, frame):
325330
else:
326331
target_dir = root_dir
327332

328-
logger.info(
329-
f"Beginning discovery for {args.service} and root dir {root_dir}. Resolving to {target_dir}."
330-
)
333+
logger.info(f"Beginning discovery for {args.service} and root dir {root_dir}. Resolving to {target_dir}.")
331334

332335
# ensure that recursive virtual envs aren't messed with by this call
333336
os.environ.pop("VIRTUAL_ENV", None)
@@ -344,26 +347,21 @@ def handler(signum, frame):
344347
)
345348

346349
if len(targeted_packages) == 0:
347-
logger.info(
348-
f"No packages collected for targeting string {args.glob_string} and root dir {root_dir}. Exit 0."
349-
)
350+
logger.info(f"No packages collected for targeting string {args.glob_string} and root dir {root_dir}. Exit 0.")
350351
exit(0)
351352

352353
logger.info(f"Executing checks with the executable {sys.executable}.")
353354
logger.info(f"Packages targeted: {targeted_packages}")
354355

356+
temp_wheel_dir = args.wheel_dir or os.path.join(root_dir, ".wheels")
355357
if args.wheel_dir:
356358
os.environ["PREBUILT_WHEEL_DIR"] = args.wheel_dir
357-
358-
if not os.path.exists(os.path.join(root_dir, ".wheels")):
359-
os.makedirs(os.path.join(root_dir, ".wheels"))
359+
else:
360+
if not os.path.exists(temp_wheel_dir):
361+
os.makedirs(temp_wheel_dir)
360362

361363
if in_ci():
362-
# prepare a build of eng/tools/azure-sdk-tools
363-
# todo: ensure that we honor this .wheels directory when replacing for dev reqs
364-
build_whl_for_req(
365-
"eng/tools/azure-sdk-tools", root_dir, os.path.join(root_dir, ".wheels")
366-
)
364+
build_whl_for_req("eng/tools/azure-sdk-tools", root_dir, temp_wheel_dir)
367365

368366
# so if we have checks whl,import_all and selected package paths `sdk/core/azure-core`, `sdk/storage/azure-storage-blob` we should
369367
# shell out to `azypysdk <checkname>` with cwd of the package directory, which is what is in `targeted_packages` array
@@ -382,9 +380,7 @@ def handler(signum, frame):
382380

383381
configure_interrupt_handling()
384382
try:
385-
exit_code = asyncio.run(
386-
run_all_checks(targeted_packages, checks, args.max_parallel)
387-
)
383+
exit_code = asyncio.run(run_all_checks(targeted_packages, checks, args.max_parallel, temp_wheel_dir))
388384
except KeyboardInterrupt:
389385
logger.error("Aborted by user.")
390386
exit_code = 130

eng/tools/azure-sdk-tools/azpysdk/Check.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@
1111
import subprocess
1212

1313
from ci_tools.parsing import ParsedSetup
14-
from ci_tools.functions import discover_targeted_packages, get_venv_call, install_into_venv, get_venv_python
15-
from ci_tools.variables import discover_repo_root
14+
from ci_tools.functions import (
15+
discover_targeted_packages,
16+
get_venv_call,
17+
install_into_venv,
18+
get_venv_python,
19+
get_pip_command,
20+
find_whl,
21+
)
22+
from ci_tools.variables import discover_repo_root, in_ci
1623
from ci_tools.logging import logger
1724

1825
# right now, we are assuming you HAVE to be in the azure-sdk-tools repo
@@ -64,9 +71,26 @@ def create_venv(self, isolate: bool, venv_location: str) -> str:
6471

6572
subprocess.check_call(venv_cmd + [venv_location])
6673

67-
# TODO: we should reuse part of build_whl_for_req to integrate with PREBUILT_WHL_DIR so that we don't have to fresh build for each
68-
# venv
69-
install_into_venv(venv_location, [os.path.join(REPO_ROOT, "eng/tools/azure-sdk-tools[build]")], REPO_ROOT)
74+
if in_ci():
75+
# first attempt to retrieve azure-sdk-tools from the prebuilt wheel directory
76+
# if present, install from there instead of constantly rebuilding azure-sdk-tools in a possible
77+
# parallel situation
78+
wheel_dir = os.getenv("PREBUILT_WHEEL_DIR", None) or os.path.join(REPO_ROOT, ".wheels")
79+
prebuilt_whl = find_whl(wheel_dir, "azure-sdk-tools", "0.0.0")
80+
81+
if prebuilt_whl:
82+
install_location = os.path.join(wheel_dir, prebuilt_whl)
83+
install_into_venv(venv_location, [f"{install_location}[build]"], REPO_ROOT)
84+
else:
85+
logger.error(
86+
"Falling back to manual build and install of azure-sdk-tools into isolated env,"
87+
f" unable to locate prebuilt azure-sdk-tools within {wheel_dir}"
88+
)
89+
else:
90+
install_into_venv(
91+
venv_location, [os.path.join(REPO_ROOT, "eng/tools/azure-sdk-tools[build]")], REPO_ROOT
92+
)
93+
7094
venv_python_exe = get_venv_python(venv_location)
7195

7296
return venv_python_exe
@@ -86,7 +110,7 @@ def get_executable(self, isolate: bool, check_name: str, executable: str, packag
86110
return executable, staging_directory
87111

88112
def run_venv_command(
89-
self, executable: str, command: Sequence[str], cwd: str, check: bool = False
113+
self, executable: str, command: Sequence[str], cwd: str, check: bool = False, append_executable: bool = True
90114
) -> subprocess.CompletedProcess[str]:
91115
"""Run a command in the given virtual environment.
92116
- Prepends the virtual environment's bin directory to the PATH environment variable (if one exists)
@@ -112,8 +136,21 @@ def run_venv_command(
112136
else:
113137
raise RuntimeError(f"Unable to find parent venv for executable {executable}")
114138

139+
# When not appending executable, resolve the command using the modified PATH
140+
if not append_executable:
141+
resolved = shutil.which(command[0], path=env["PATH"])
142+
if not resolved:
143+
raise RuntimeError(f"Command '{command[0]}' not found in PATH: {env['PATH']}")
144+
cmd_to_run = [resolved] + list(command[1:])
145+
else:
146+
cmd_to_run = [executable] + list(command)
147+
148+
logger.debug(f"Running command: {cmd_to_run}.")
149+
logger.debug(f"VIRTUAL_ENV: {env['VIRTUAL_ENV']}.")
150+
logger.debug(f"PATH : {env['PATH']}.")
151+
115152
result = subprocess.run(
116-
[executable, *command], cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=check
153+
cmd_to_run, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=check, env=env
117154
)
118155

119156
return result
@@ -180,3 +217,28 @@ def install_dev_reqs(self, executable: str, args: argparse.Namespace, package_di
180217
os.remove(temp_req_file.name)
181218
except Exception as cleanup_error:
182219
logger.warning(f"Failed to remove temporary requirements file: {cleanup_error}")
220+
221+
def pip_freeze(self, executable: str) -> None:
222+
"""Run pip freeze in the given virtual environment and log the output. This function handles both isolated and non-isolated
223+
environments, as well as calling the proper `uv` executable with additional --python argument if needed.
224+
225+
:param executable: Path to the python executable that should invoke this check.
226+
:returns None:
227+
"""
228+
try:
229+
# to uv pip install or freeze to a target environment, we have to add `--python <path to python exe>`
230+
# to tell uv which environment to target
231+
command = get_pip_command(executable)
232+
233+
if command[0] == "uv":
234+
command += ["freeze", "--python", executable]
235+
else:
236+
command += ["freeze"]
237+
238+
result = subprocess.run(command, cwd=os.getcwd(), check=True, capture_output=True, text=True)
239+
logger.info("Installed packages:")
240+
logger.info(result.stdout)
241+
except subprocess.CalledProcessError as e:
242+
logger.error(f"Failed to run pip freeze: {e}")
243+
logger.error(e.stdout)
244+
logger.error(e.stderr)

eng/tools/azure-sdk-tools/azpysdk/bandit.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,7 @@ def run(self, args: argparse.Namespace) -> int:
4949
logger.error(f"Failed to install bandit: {e}")
5050
return e.returncode
5151

52-
# debug a pip freeze result
53-
cmd = get_pip_command(executable) + ["freeze"]
54-
freeze_result = subprocess.run(
55-
cmd, cwd=package_dir, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
56-
)
57-
logger.debug(f"Running pip freeze with {cmd}")
58-
logger.debug(freeze_result.stdout)
52+
self.pip_freeze(executable)
5953

6054
if in_ci():
6155
if not is_check_enabled(package_dir, "bandit"):

eng/tools/azure-sdk-tools/azpysdk/import_all.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,6 @@ def run(self, args: argparse.Namespace) -> int:
5050

5151
targeted = self.get_targeted_directories(args)
5252

53-
# {[tox]pip_command} freeze
54-
# python {repository_root}/eng/tox/import_all.py -t {tox_root}
55-
5653
outcomes: List[int] = []
5754

5855
for parsed in targeted:

0 commit comments

Comments
 (0)