Skip to content

Commit 9011bd1

Browse files
authored
Analyze passes target python version (#46093)
* analyze now passes the python version as an argument to azpysdk IF the package defines an analyze_python_version * ensure that the dispatch_checks.py properly grabs the analyze_python_version from the pyproject.toml for the targeted package * remove python 3.11 from the ci.yml for agentserver, we should rely on the pyproject.toml
1 parent 76d8000 commit 9011bd1

30 files changed

Lines changed: 458 additions & 277 deletions

eng/pipelines/templates/jobs/ci.yml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,6 @@ parameters:
6464
- name: EnvVars
6565
type: object
6666
default: {}
67-
- name: PythonVersionForAnalyze
68-
type: string
69-
default: ''
7067

7168
jobs:
7269
- job: 'Build_Linux'
@@ -212,10 +209,10 @@ jobs:
212209

213210
steps:
214211
- task: UsePythonVersion@0
215-
displayName: "Use Python ${{ coalesce(parameters.PythonVersionForAnalyze, '$(PythonVersion)') }}"
212+
displayName: "Use Python $(PythonVersion)"
216213
condition: succeededOrFailed()
217214
inputs:
218-
versionSpec: ${{ coalesce(parameters.PythonVersionForAnalyze, '$(PythonVersion)') }}
215+
versionSpec: $(PythonVersion)
219216
- template: /eng/pipelines/templates/steps/use-venv.yml
220217

221218
- template: /eng/common/pipelines/templates/steps/check-spelling.yml

eng/pipelines/templates/stages/archetype-sdk-client.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,6 @@ parameters:
8484
- name: EnvVars
8585
type: object
8686
default: {}
87-
- name: PythonVersionForAnalyze
88-
type: string
89-
default: ''
9087

9188
extends:
9289
template: /eng/pipelines/templates/stages/1es-redirect.yml
@@ -123,7 +120,6 @@ extends:
123120
VerifyAutorest: ${{ parameters.VerifyAutorest }}
124121
TestProxy: ${{ parameters.TestProxy }}
125122
GenerateApiReviewForManualOnly: ${{ parameters.GenerateApiReviewForManualOnly }}
126-
PythonVersionForAnalyze: ${{ parameters.PythonVersionForAnalyze }}
127123

128124
variables:
129125
- template: /eng/pipelines/templates/variables/globals.yml

eng/scripts/dispatch_checks.py

Lines changed: 36 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
import asyncio
3+
import glob
34
import os
45
import sys
56
import time
@@ -15,6 +16,7 @@
1516
from ci_tools.scenario.generation import build_whl_for_req, replace_dev_reqs
1617
from ci_tools.logging import configure_logging, logger
1718
from ci_tools.environment_exclusions import is_check_enabled, CHECK_DEFAULTS
19+
from ci_tools.parsing import get_config_setting
1820
from devtools_testutils.proxy_startup import prepare_local_tool
1921
from packaging.requirements import Requirement
2022

@@ -49,6 +51,7 @@ class ProxyProcess:
4951
"sdist",
5052
"devtest",
5153
"optional",
54+
"import_all",
5255
"latestdependency",
5356
"mindependency",
5457
}
@@ -84,9 +87,7 @@ def _compare_req_to_injected_reqs(parsed_req, injected_packages: List[str]) -> b
8487
return any(parsed_req.name in req for req in injected_packages)
8588

8689

87-
def _inject_custom_reqs(
88-
req_file: str, injected_packages: str, package_dir: str
89-
) -> None:
90+
def _inject_custom_reqs(req_file: str, injected_packages: str, package_dir: str) -> None:
9091
req_lines = []
9192
injected_list = [p for p in re.split(r"[\s,]", injected_packages) if p]
9293

@@ -115,8 +116,7 @@ def _inject_custom_reqs(
115116
all_adjustments = installable + [
116117
line_tuple[0].strip()
117118
for line_tuple in req_lines
118-
if line_tuple[0].strip()
119-
and not _compare_req_to_injected_reqs(line_tuple[1], all_filter_names)
119+
if line_tuple[0].strip() and not _compare_req_to_injected_reqs(line_tuple[1], all_filter_names)
120120
]
121121
else:
122122
all_adjustments = installable
@@ -138,6 +138,7 @@ async def run_check(
138138
mark_arg: Optional[str],
139139
dest_dir: Optional[str] = None,
140140
service: Optional[str] = None,
141+
python_version: Optional[str] = None,
141142
) -> CheckResult:
142143
"""Run a single check (subprocess) within a concurrency semaphore, capturing output and timing.
143144
@@ -161,6 +162,8 @@ async def run_check(
161162
async with semaphore:
162163
start = time.time()
163164
cmd = base_args + [check, "--isolate", package]
165+
if python_version:
166+
cmd += ["--python", python_version]
164167
if service:
165168
cmd += ["--service", service]
166169
if mark_arg:
@@ -172,9 +175,7 @@ async def run_check(
172175
env["PROXY_URL"] = f"http://localhost:{proxy_port}"
173176

174177
if in_ci():
175-
env["PROXY_ASSETS_FOLDER"] = os.path.join(
176-
root_dir, ".assets_distributed", str(proxy_port)
177-
)
178+
env["PROXY_ASSETS_FOLDER"] = os.path.join(root_dir, ".assets_distributed", str(proxy_port))
178179
try:
179180
logger.info(" ".join(cmd))
180181
proc = await asyncio.create_subprocess_exec(
@@ -194,9 +195,7 @@ async def run_check(
194195
stderr = stderr_b.decode(errors="replace")
195196
exit_code = proc.returncode or 0
196197
status = "OK" if exit_code == 0 else f"FAIL({exit_code})"
197-
logger.info(
198-
f"[END {idx}/{total}] {check} :: {package} -> {status} in {duration:.2f}s"
199-
)
198+
logger.info(f"[END {idx}/{total}] {check} :: {package} -> {status} in {duration:.2f}s")
200199
# Print captured output after completion to avoid interleaving
201200
header = f"===== OUTPUT: {check} :: {package} (exit {exit_code}) ====="
202201
trailer = "=" * len(header)
@@ -220,10 +219,10 @@ async def run_check(
220219
# finally, we need to clean up any temp dirs created by --isolate
221220
if in_ci():
222221
package_name = os.path.basename(os.path.normpath(package))
223-
isolate_dir = os.path.join(
224-
root_dir, ".venv", package_name, f".venv_{check}"
225-
)
226-
ISOLATE_DIRS_TO_CLEAN.append(isolate_dir)
222+
venv_pkg_root = os.path.join(root_dir, ".venv", package_name)
223+
# match both .venv_{check} and version-qualified .venv_{check}_py311 etc.
224+
for d in glob.glob(os.path.join(venv_pkg_root, f".venv_{check}*")):
225+
ISOLATE_DIRS_TO_CLEAN.append(d)
227226
return CheckResult(package, check, exit_code, duration, stdout, stderr)
228227

229228

@@ -247,14 +246,10 @@ def summarize(results: List[CheckResult]) -> int:
247246
print("-" * len(header))
248247
for r in sorted(results, key=lambda x: (x.exit_code != 0, x.package, x.check)):
249248
status = "OK" if r.exit_code == 0 else f"FAIL({r.exit_code})"
250-
print(
251-
f"{r.package.ljust(pkg_w)} {r.check.ljust(chk_w)} {status.ljust(8)} {r.duration:>10.2f}"
252-
)
249+
print(f"{r.package.ljust(pkg_w)} {r.check.ljust(chk_w)} {status.ljust(8)} {r.duration:>10.2f}")
253250
worst = max((r.exit_code for r in results), default=0)
254251
failed = [r for r in results if r.exit_code != 0]
255-
print(
256-
f"\nTotal checks: {len(results)} | Failed: {len(failed)} | Worst exit code: {worst}"
257-
)
252+
print(f"\nTotal checks: {len(results)} | Failed: {len(failed)} | Worst exit code: {worst}")
258253
return worst
259254

260255

@@ -292,14 +287,10 @@ async def run_all_checks(
292287
dependency_tools_path = os.path.join(root_dir, "eng", "dependency_tools.txt")
293288

294289
if in_ci():
295-
logger.info(
296-
"Replacing relative requirements in eng/test_tools.txt with prebuilt wheels."
297-
)
290+
logger.info("Replacing relative requirements in eng/test_tools.txt with prebuilt wheels.")
298291
replace_dev_reqs(test_tools_path, root_dir, wheel_dir)
299292

300-
logger.info(
301-
"Replacing relative requirements in eng/dependency_tools.txt with prebuilt wheels."
302-
)
293+
logger.info("Replacing relative requirements in eng/dependency_tools.txt with prebuilt wheels.")
303294
replace_dev_reqs(dependency_tools_path, root_dir, wheel_dir)
304295

305296
for pkg in packages:
@@ -321,15 +312,19 @@ async def run_all_checks(
321312
if not is_check_enabled(package, check, CHECK_DEFAULTS.get(check, True)):
322313
logger.warning(f"Skipping disabled check {check} for package {package}")
323314
continue
324-
logger.info(
325-
f"Assigning proxy port {next_proxy_port} to check {check} for package {package}"
326-
)
327-
scheduled.append((package, check, next_proxy_port))
315+
logger.info(f"Assigning proxy port {next_proxy_port} to check {check} for package {package}")
316+
317+
# Check if this package overrides the Python version for analysis
318+
pkg_python_version = get_config_setting(package, "analyze_python_version", None)
319+
if pkg_python_version:
320+
logger.info(f"Package {package} overrides analyze Python version to {pkg_python_version}")
321+
322+
scheduled.append((package, check, next_proxy_port, pkg_python_version))
328323
next_proxy_port += 1
329324

330325
total = len(scheduled)
331326

332-
for idx, (package, check, proxy_port) in enumerate(scheduled, start=1):
327+
for idx, (package, check, proxy_port, pkg_python_version) in enumerate(scheduled, start=1):
333328
tasks.append(
334329
asyncio.create_task(
335330
run_check(
@@ -343,6 +338,7 @@ async def run_all_checks(
343338
mark_arg,
344339
dest_dir,
345340
service,
341+
pkg_python_version,
346342
)
347343
)
348344
)
@@ -358,15 +354,13 @@ async def run_all_checks(
358354
raise
359355
# Normalize exceptions
360356
norm_results: List[CheckResult] = []
361-
for res, (package, check, _) in zip(results, scheduled):
357+
for res, (package, check, _, _) in zip(results, scheduled):
362358
if isinstance(res, CheckResult):
363359
norm_results.append(res)
364360
elif isinstance(res, Exception):
365361
norm_results.append(CheckResult(package, check, 99, 0.0, "", str(res)))
366362
else:
367-
norm_results.append(
368-
CheckResult(package, check, 98, 0.0, "", f"Unknown result type: {res}")
369-
)
363+
norm_results.append(CheckResult(package, check, 98, 0.0, "", f"Unknown result type: {res}"))
370364
return summarize(norm_results)
371365

372366

@@ -442,15 +436,11 @@ def handler(signum, frame):
442436
),
443437
)
444438

445-
parser.add_argument(
446-
"--disablecov", help=("Flag. Disables code coverage."), action="store_true"
447-
)
439+
parser.add_argument("--disablecov", help=("Flag. Disables code coverage."), action="store_true")
448440

449441
parser.add_argument(
450442
"--service",
451-
help=(
452-
"Name of service directory (under sdk/) to test. Example: --service applicationinsights"
453-
),
443+
help=("Name of service directory (under sdk/) to test. Example: --service applicationinsights"),
454444
)
455445

456446
parser.add_argument(
@@ -517,9 +507,7 @@ def handler(signum, frame):
517507
else:
518508
target_dir = root_dir
519509

520-
logger.info(
521-
f"Beginning discovery for {args.service} and root dir {root_dir}. Resolving to {target_dir}."
522-
)
510+
logger.info(f"Beginning discovery for {args.service} and root dir {root_dir}. Resolving to {target_dir}.")
523511

524512
# ensure that recursive virtual envs aren't messed with by this call
525513
os.environ.pop("VIRTUAL_ENV", None)
@@ -536,9 +524,7 @@ def handler(signum, frame):
536524
)
537525

538526
if len(targeted_packages) == 0:
539-
logger.info(
540-
f"No packages collected for targeting string {args.glob_string} and root dir {root_dir}. Exit 0."
541-
)
527+
logger.info(f"No packages collected for targeting string {args.glob_string} and root dir {root_dir}. Exit 0.")
542528
exit(0)
543529

544530
logger.info(f"Executing checks with the executable {sys.executable}.")
@@ -570,9 +556,7 @@ def handler(signum, frame):
570556
try:
571557
proxy_executable = prepare_local_tool(root_dir)
572558
except Exception as exc:
573-
logger.error(
574-
f"Unable to prepare test proxy executable for recording restore: {exc}"
575-
)
559+
logger.error(f"Unable to prepare test proxy executable for recording restore: {exc}")
576560
sys.exit(1)
577561

578562
logger.info(
@@ -583,9 +567,7 @@ def handler(signum, frame):
583567
proxy_processes: List[ProxyProcess] = []
584568
try:
585569
if in_ci():
586-
logger.info(
587-
f"Ensuring {len(checks)} test proxies are running for requested checks..."
588-
)
570+
logger.info(f"Ensuring {len(checks)} test proxies are running for requested checks...")
589571
# Pass through service if set and not "auto"
590572
effective_service = args.service if (args.service and args.service != "auto") else None
591573
exit_code = asyncio.run(

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
from ci_tools.parsing import ParsedSetup
1515
from ci_tools.functions import (
1616
discover_targeted_packages,
17+
find_whl,
18+
)
19+
from ci_tools.venv import (
1720
get_venv_call,
1821
install_into_venv,
1922
get_venv_python,
2023
get_pip_command,
21-
find_whl,
2224
)
2325
from ci_tools.variables import discover_repo_root, in_ci
2426
from ci_tools.logging import logger
@@ -71,10 +73,14 @@ def run(self, args: argparse.Namespace) -> int:
7173
"""
7274
return 0
7375

74-
def create_venv(self, isolate: bool, venv_location: str) -> str:
75-
"""Abstraction for creating a virtual environment."""
76+
def create_venv(self, isolate: bool, venv_location: str, python_version: Optional[str] = None) -> str:
77+
"""Abstraction for creating a virtual environment.
78+
79+
:param python_version: If provided, passed to ``uv venv --python`` during initial creation only.
80+
Has no effect on venv reuse or lookup.
81+
"""
7682
if isolate:
77-
venv_cmd = get_venv_call(sys.executable)
83+
venv_cmd = get_venv_call(sys.executable, python_version=python_version)
7884
venv_python = get_venv_python(venv_location)
7985
if os.path.exists(venv_python):
8086
logger.info(f"Reusing existing venv at {venv_python}")
@@ -109,16 +115,30 @@ def create_venv(self, isolate: bool, venv_location: str) -> str:
109115
# if we don't need to isolate, just return the python executable that we're invoking
110116
return sys.executable
111117

112-
def get_executable(self, isolate: bool, check_name: str, executable: str, package_folder: str) -> Tuple[str, str]:
118+
def get_executable(
119+
self,
120+
isolate: bool,
121+
check_name: str,
122+
executable: str,
123+
package_folder: str,
124+
python_version: Optional[str] = None,
125+
) -> Tuple[str, str]:
113126
"""Get the Python executable that should be used for this check."""
114127
# Keep venvs under a shared repo-level folder to prevent nested import errors during pytest collection
115128
package_name = os.path.basename(os.path.normpath(package_folder))
116129
shared_venv_root = os.path.join(REPO_ROOT, ".venv", package_name)
117130
os.makedirs(shared_venv_root, exist_ok=True)
118-
venv_location = os.path.join(shared_venv_root, f".venv_{check_name}")
131+
132+
# version-qualify the venv dir when a specific Python is requested to avoid cache collisions
133+
venv_suffix = f".venv_{check_name}"
134+
if python_version:
135+
sanitized = python_version.replace(".", "").replace("-", "")
136+
venv_suffix = f".venv_{check_name}_py{sanitized}"
137+
venv_location = os.path.join(shared_venv_root, venv_suffix)
138+
119139
# if isolation is required, the executable we get back will align with the venv
120140
# otherwise we'll just get sys.executable and install in current
121-
executable = self.create_venv(isolate, venv_location)
141+
executable = self.create_venv(isolate, venv_location, python_version=python_version)
122142
staging_directory = os.path.join(venv_location, ".staging")
123143
os.makedirs(staging_directory, exist_ok=True)
124144
return executable, staging_directory

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,13 @@ def run(self, args: argparse.Namespace) -> int:
9292
os.chdir(parsed.folder)
9393
package_dir = parsed.folder
9494
package_name = parsed.name
95-
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir)
95+
executable, staging_directory = self.get_executable(
96+
args.isolate,
97+
args.command,
98+
sys.executable,
99+
package_dir,
100+
python_version=getattr(args, "python_version", None),
101+
)
96102
logger.info(f"Processing {package_name} for apistub check")
97103

98104
# install dependencies

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ def run(self, args: argparse.Namespace) -> int:
4242
os.chdir(parsed.folder)
4343
package_dir = parsed.folder
4444
package_name = parsed.name
45-
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir)
45+
executable, staging_directory = self.get_executable(
46+
args.isolate,
47+
args.command,
48+
sys.executable,
49+
package_dir,
50+
python_version=getattr(args, "python_version", None),
51+
)
4652
logger.info(f"Processing {package_name} for bandit check")
4753

4854
self.install_dev_reqs(executable, args, package_dir)

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@ def run(self, args: argparse.Namespace) -> int:
4545
package_dir = parsed.folder
4646
package_name = parsed.name
4747

48-
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir)
48+
executable, staging_directory = self.get_executable(
49+
args.isolate,
50+
args.command,
51+
sys.executable,
52+
package_dir,
53+
python_version=getattr(args, "python_version", None),
54+
)
4955
logger.info(f"Processing {package_name} for black check")
5056

5157
self.install_dev_reqs(executable, args, package_dir)

0 commit comments

Comments
 (0)