Skip to content

Commit 8d10d58

Browse files
authored
Venv Isolation Update (#42772)
* checks can now properly isolate themselves without a recursive call to azure-sdk-tools from a venv to another venv
1 parent f4d351c commit 8d10d58

File tree

8 files changed

+102
-80
lines changed

8 files changed

+102
-80
lines changed

eng/tools/azure-sdk-tools/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ class my_check(Check):
217217
for parsed in targeted:
218218
pkg_dir = parsed.folder
219219
pkg_name = parsed.name
220+
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, pkg)
221+
# the rest of the check should use executable, not sys.executable
222+
# if a staging area is needed use staging_directory
220223
print(f"Processing {pkg_name} for my_check")
221224
```
222225

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@
22
import os
33
import argparse
44
import traceback
5-
from typing import Sequence, Optional, List, Any
5+
import sys
6+
7+
from typing import Sequence, Optional, List, Any, Tuple
8+
from subprocess import check_call
9+
610
from ci_tools.parsing import ParsedSetup
7-
from ci_tools.functions import discover_targeted_packages
11+
from ci_tools.functions import discover_targeted_packages, get_venv_call
12+
from ci_tools.variables import discover_repo_root
13+
from ci_tools.scenario import install_into_venv, get_venv_python
14+
15+
# right now, we are assuming you HAVE to be in the azure-sdk-tools repo
16+
# we assume this because we don't know how a dev has installed this package, and might be
17+
# being called from within a site-packages folder. Due to that, we can't trust the location of __file__
18+
REPO_ROOT = discover_repo_root()
819

920
class Check(abc.ABC):
1021
"""
@@ -34,13 +45,41 @@ def run(self, args: argparse.Namespace) -> int:
3445
"""
3546
return 0
3647

48+
def create_venv(self, isolate: bool, venv_location: str) -> str:
49+
"""Abstraction for creating a virtual environment."""
50+
if (isolate):
51+
venv_cmd = get_venv_call(sys.executable)
52+
check_call(venv_cmd + [venv_location])
53+
54+
# 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
55+
# venv
56+
install_into_venv(venv_location, os.path.join(REPO_ROOT, "eng/tools/azure-sdk-tools"), False, "build")
57+
venv_python_exe = get_venv_python(venv_location)
58+
59+
return venv_python_exe
60+
61+
# if we don't need to isolate, just return the python executable that we're invoking
62+
return sys.executable
63+
64+
def get_executable(self, isolate: bool, check_name: str, executable: str, package_folder: str) -> Tuple[str, str]:
65+
"""Get the Python executable that should be used for this check."""
66+
venv_location = os.path.join(package_folder, f".venv_{check_name}")
67+
68+
# if isolation is required, the executable we get back will align with the venv
69+
# otherwise we'll just get sys.executable and install in current
70+
executable = self.create_venv(isolate, venv_location)
71+
staging_directory = os.path.join(venv_location, ".staging")
72+
os.makedirs(staging_directory, exist_ok=True)
73+
return executable, staging_directory
74+
75+
3776
def get_targeted_directories(self, args: argparse.Namespace) -> List[ParsedSetup]:
3877
"""
3978
Get the directories that are targeted for the check.
4079
"""
4180
targeted: List[ParsedSetup] = []
4281
targeted_dir = os.getcwd()
43-
82+
4483
if args.target == ".":
4584
try:
4685
targeted.append(ParsedSetup.from_path(targeted_dir))

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,18 @@ def run(self, args: argparse.Namespace) -> int:
5353

5454
for parsed in targeted:
5555
pkg = parsed.folder
56-
56+
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, pkg)
5757

58-
staging_area = tempfile.mkdtemp()
5958
create_package_and_install(
60-
distribution_directory=staging_area,
59+
distribution_directory=staging_directory,
6160
target_setup=pkg,
6261
skip_install=False,
6362
cache_dir=None,
64-
work_dir=staging_area,
63+
work_dir=staging_directory,
6564
force_create=False,
6665
package_type="wheel",
6766
pre_download_disabled=False,
67+
python_executable=executable
6868
)
6969

7070
if should_run_import_all(parsed.name):
@@ -76,7 +76,7 @@ def run(self, args: argparse.Namespace) -> int:
7676
)
7777
import_script_all = "from {0} import *".format(parsed.namespace)
7878
commands = [
79-
sys.executable,
79+
executable,
8080
"-c",
8181
import_script_all
8282
]

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

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,11 @@
1111
import sys
1212
import os
1313
from typing import Sequence, Optional
14-
from subprocess import check_call
1514

1615
from .whl import whl
1716
from .import_all import import_all
1817
from .mypy import mypy
1918

20-
from ci_tools.scenario import install_into_venv, get_venv_python
21-
from ci_tools.functions import get_venv_call
22-
from ci_tools.variables import discover_repo_root
23-
24-
# right now, we are assuming you HAVE to be in the azure-sdk-tools repo
25-
# we assume this because we don't know how a dev has installed this package, and might be
26-
# being called from within a site-packages folder. Due to that, we can't trust the location of __file__
27-
REPO_ROOT = discover_repo_root()
28-
2919
__all__ = ["main", "build_parser"]
3020
__version__ = "0.0.0"
3121

@@ -63,35 +53,7 @@ def build_parser() -> argparse.ArgumentParser:
6353

6454
return parser
6555

66-
def handle_venv(isolate: bool, args: argparse.Namespace) -> None:
67-
"""Handle virtual environment commands."""
68-
# we are already in an isolated venv and so do not need to recurse
69-
if(os.getenv("AZURE_SDK_TOOLS_VENV", None)):
70-
return
71-
72-
# however, if we are not already in an isolated venv, and should be, then we need to
73-
# call
74-
if (isolate):
75-
os.environ["AZURE_SDK_TOOLS_VENV"] = "1"
76-
77-
venv_cmd = get_venv_call()
78-
venv_location = os.path.join(REPO_ROOT, f".venv_{args.command}")
79-
# todo, make this a consistent directory based on the command
80-
# I'm seriously thinking we should move handle_venv within each check's main(),
81-
# which will mean that we will _know_ what folder we're in.
82-
# however, that comes at the cost of not having every check be able to handle one or multiple packages
83-
# I don't want to get into an isolation loop where every time we need a new venv, we create it, call it,
84-
# and now as we foreach across the targeted packages we've lost our spot.
85-
check_call(venv_cmd + [venv_location])
86-
87-
# now use the current virtual environment to install os.path.join(REPO_ROOT, eng/tools/azure-sdk-tools[build])
88-
# into the NEW virtual env
89-
install_into_venv(venv_location, os.path.join(REPO_ROOT, "eng/tools/azure-sdk-tools"), False, "build")
90-
venv_python_exe = get_venv_python(venv_location)
91-
command_args = [venv_python_exe, "-m", "azpysdk.main"] + sys.argv[1:]
92-
check_call(command_args)
93-
94-
def main(argv: Optional[Sequence[str]] = None) -> int:#
56+
def main(argv: Optional[Sequence[str]] = None) -> int:
9557
"""CLI entrypoint.
9658
9759
Args:
@@ -108,9 +70,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int:#
10870
return 1
10971

11072
try:
111-
# scbedd 8/25 I'm betting that this would be best placed within the check itself,
112-
# but leaving this for now
113-
handle_venv(args.isolate, args)
11473
result = args.func(args)
11574
return int(result or 0)
11675
except KeyboardInterrupt:

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

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from .Check import Check
1111
from ci_tools.parsing import ParsedSetup
12-
from ci_tools.functions import discover_targeted_packages
12+
from ci_tools.functions import pip_install
1313
from ci_tools.scenario.generation import create_package_and_install
1414
from ci_tools.variables import in_ci, set_envvar_defaults
1515
from ci_tools.environment_exclusions import (
@@ -33,8 +33,8 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt
3333
p.set_defaults(func=self.run)
3434

3535
p.add_argument(
36-
"--next",
37-
default=False,
36+
"--next",
37+
default=False,
3838
help="Next version of mypy is being tested",
3939
required=False
4040
)
@@ -52,27 +52,27 @@ def run(self, args: argparse.Namespace) -> int:
5252
for parsed in targeted:
5353
package_dir = parsed.folder
5454
package_name = parsed.name
55+
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir)
5556
print(f"Processing {package_name} for mypy check")
56-
57-
staging_area = tempfile.mkdtemp()
5857
create_package_and_install(
59-
distribution_directory=staging_area,
58+
distribution_directory=staging_directory,
6059
target_setup=package_dir,
6160
skip_install=False,
6261
cache_dir=None,
63-
work_dir=staging_area,
62+
work_dir=staging_directory,
6463
force_create=False,
6564
package_type="wheel",
6665
pre_download_disabled=False,
66+
python_executable=executable
6767
)
6868

6969
# install mypy
7070
try:
7171
if (args.next):
7272
# use latest version of mypy
73-
check_call([sys.executable, "-m", "pip", "install", "mypy"])
73+
pip_install(["mypy"], True, executable, package_dir)
7474
else:
75-
check_call([sys.executable, "-m", "pip", "install", f"mypy=={MYPY_VERSION}"])
75+
pip_install([f"mypy=={MYPY_VERSION}"], True, executable, package_dir)
7676
except CalledProcessError as e:
7777
print("Failed to install mypy:", e)
7878
return e.returncode
@@ -89,7 +89,7 @@ def run(self, args: argparse.Namespace) -> int:
8989
top_level_module = parsed.namespace.split(".")[0]
9090

9191
commands = [
92-
sys.executable,
92+
executable,
9393
"-m",
9494
"mypy",
9595
"--python-version",
@@ -107,9 +107,9 @@ def run(self, args: argparse.Namespace) -> int:
107107
results.append(check_call(src_code))
108108
logging.info("Verified mypy, no issues found")
109109
except CalledProcessError as src_error:
110-
src_code_error = src_error
110+
src_code_error = src_error
111111
results.append(src_error.returncode)
112-
112+
113113
if not args.next and in_ci() and not is_check_enabled(package_dir, "type_check_samples", True):
114114
logging.info(
115115
f"Package {package_name} opts-out of mypy check on samples."
@@ -145,6 +145,5 @@ def run(self, args: argparse.Namespace) -> int:
145145
create_vnext_issue(package_dir, "mypy")
146146
else:
147147
close_vnext_issue(package_name, "mypy")
148-
148+
149149
return max(results) if results else 0
150-

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

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import tempfile
44
import os
55
from typing import Optional, List, Any
6-
from pytest import main as pytest_main
76
import sys
7+
from subprocess import run
88

99
from .Check import Check
1010

11-
from ci_tools.functions import discover_targeted_packages, is_error_code_5_allowed, pip_install
11+
from ci_tools.functions import is_error_code_5_allowed, pip_install
1212
from ci_tools.variables import set_envvar_defaults
1313
from ci_tools.parsing import ParsedSetup
1414
from ci_tools.scenario.generation import create_package_and_install
@@ -25,7 +25,7 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt
2525
parents = parent_parsers or []
2626
p = subparsers.add_parser("whl", parents=parents, help="Run the whl check")
2727
p.set_defaults(func=self.run)
28-
# Add any additional arguments specific to WhlCheck here (do not re-add common handled by parents)
28+
# TODO add mark_args, and other parameters
2929

3030
def run(self, args: argparse.Namespace) -> int:
3131
"""Run the whl check command."""
@@ -39,30 +39,47 @@ def run(self, args: argparse.Namespace) -> int:
3939

4040
for parsed in targeted:
4141
pkg = parsed.folder
42+
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, pkg)
4243

44+
print(f"Invoking check with {executable}")
4345
dev_requirements = os.path.join(pkg, "dev_requirements.txt")
4446

4547
if os.path.exists(dev_requirements):
46-
pip_install([f"-r", f"{dev_requirements}"], True, sys.executable)
48+
print(f"Installing dev_requirements at {dev_requirements}")
49+
pip_install([f"-r", f"{dev_requirements}"], True, executable, pkg)
50+
else:
51+
print("Skipping installing dev_requirements")
4752

48-
staging_area = tempfile.mkdtemp()
4953
create_package_and_install(
50-
distribution_directory=staging_area,
54+
distribution_directory=staging_directory,
5155
target_setup=pkg,
5256
skip_install=False,
5357
cache_dir=None,
54-
work_dir=staging_area,
58+
work_dir=staging_directory,
5559
force_create=False,
5660
package_type="wheel",
5761
pre_download_disabled=False,
62+
python_executable=executable
5863
)
5964

60-
# todo, come up with a good pattern for passing all the additional args after -- to pytest
65+
# TODO: split sys.argv[1:] on -- and pass in everything after the -- as additional arguments
66+
# TODO: handle mark_args
6167
logging.info(f"Invoke pytest for {pkg}")
62-
63-
exit_code = pytest_main(
64-
[pkg]
65-
)
68+
exit_code = run(
69+
[executable, "-m", "pytest", "."] + [
70+
"-rsfE",
71+
f"--junitxml={pkg}/test-junit-{args.command}.xml",
72+
"--verbose",
73+
"--cov-branch",
74+
"--durations=10",
75+
"--ignore=azure",
76+
"--ignore-glob=.venv*",
77+
"--ignore=build",
78+
"--ignore=.eggs",
79+
"--ignore=samples"
80+
]
81+
, cwd=pkg
82+
).returncode
6683

6784
if exit_code != 0:
6885
if exit_code == 5 and is_error_code_5_allowed(parsed.folder, parsed.name):

eng/tools/azure-sdk-tools/ci_tools/functions.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ def find_sdist(dist_dir: str, pkg_name: str, pkg_version: str) -> Optional[str]:
438438

439439

440440
def pip_install(
441-
requirements: List[str], include_dependencies: bool = True, python_executable: Optional[str] = None
441+
requirements: List[str], include_dependencies: bool = True, python_executable: Optional[str] = None, cwd: Optional[str] = None
442442
) -> bool:
443443
"""
444444
Attempts to invoke an install operation using the invoking python's pip. Empty requirements are auto-success.
@@ -454,7 +454,10 @@ def pip_install(
454454
return True
455455

456456
try:
457-
subprocess.check_call(command)
457+
if cwd:
458+
subprocess.check_call(command, cwd=cwd)
459+
else:
460+
subprocess.check_call(command)
458461
except subprocess.CalledProcessError as f:
459462
return False
460463

@@ -488,8 +491,10 @@ def get_pip_list_output(python_executable: Optional[str] = None):
488491
"""Uses the invoking python executable to get the output from pip list."""
489492
exe = python_executable or sys.executable
490493

494+
pip_cmd = get_pip_command(exe)
495+
491496
out = subprocess.Popen(
492-
[exe, "-m", "pip", "list", "--disable-pip-version-check", "--format", "freeze"],
497+
pip_cmd + ["list", "--disable-pip-version-check", "--format", "freeze"],
493498
stdout=subprocess.PIPE,
494499
stderr=subprocess.STDOUT,
495500
)

eng/tools/azure-sdk-tools/ci_tools/scenario/generation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def build_whl_for_req(req: str, package_path: str, wheel_dir: Optional[str]) ->
316316
"""Builds a whl from the dev_requirements file.
317317
318318
:param str req: a requirement from the dev_requirements.txt
319-
:param str package_path: the absolute path to the package's root
319+
:param str package_path: the absolute path to the package's root (used as relative path root)
320320
:param Optional[str] wheel_dir: the absolute path to the prebuilt wheel directory
321321
:return: The absolute path to the whl built or the requirement if a third-party package
322322
"""

0 commit comments

Comments
 (0)