Skip to content

Commit 674b7ff

Browse files
authored
Add optional Check to azpysdk (#44438)
* base * refactor prepare_and_test_optional * progress but installation issue * working * clean * comment fix * copilot fixes * minor logic fix
1 parent 05b029a commit 674b7ff

File tree

3 files changed

+220
-0
lines changed

3 files changed

+220
-0
lines changed

doc/tool_usage_guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This repo is currently migrating all checks from a slower `tox`-based framework,
3131
|`generate`| Regenerates the code. | `azpysdk generate .` |
3232
|`breaking`| Checks for breaking changes. | `azpysdk breaking .` |
3333
|`samples`| Runs the package's samples. | `azpysdk samples .` |
34+
|`optional`| Invokes 'optional' requirements for a given package. | `azpysdk optional .` |
3435
|`devtest`| Tests a package against dependencies installed from a dev index. | `azpysdk devtest .` |
3536

3637
## Common arguments

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .breaking import breaking
3535
from .samples import samples
3636
from .devtest import devtest
37+
from .optional import optional
3738

3839
from ci_tools.logging import configure_logging, logger
3940

@@ -99,6 +100,7 @@ def build_parser() -> argparse.ArgumentParser:
99100
breaking().register(subparsers, [common])
100101
samples().register(subparsers, [common])
101102
devtest().register(subparsers, [common])
103+
optional().register(subparsers, [common])
102104

103105
return parser
104106

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import argparse
2+
import os
3+
from subprocess import CalledProcessError
4+
import sys
5+
6+
from typing import Optional, List
7+
8+
from .Check import Check
9+
from ci_tools.functions import (
10+
install_into_venv,
11+
uninstall_from_venv,
12+
is_error_code_5_allowed,
13+
)
14+
from ci_tools.scenario.generation import create_package_and_install, prepare_environment
15+
from ci_tools.variables import discover_repo_root, in_ci, set_envvar_defaults
16+
from ci_tools.environment_exclusions import is_check_enabled
17+
from ci_tools.parsing import get_config_setting
18+
from ci_tools.logging import logger
19+
20+
REPO_ROOT = discover_repo_root()
21+
22+
23+
class optional(Check):
24+
def __init__(self) -> None:
25+
super().__init__()
26+
27+
def register(
28+
self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None
29+
) -> None:
30+
"""Register the optional check. The optional check invokes 'optional' requirements for a given package. View the pyproject.toml within the targeted package folder to see configuration."""
31+
parents = parent_parsers or []
32+
p = subparsers.add_parser(
33+
"optional",
34+
parents=parents,
35+
help="Run the optional check to invoke 'optional' requirements for a given package.",
36+
)
37+
p.set_defaults(func=self.run)
38+
39+
p.add_argument(
40+
"-o",
41+
"--optional",
42+
dest="optional",
43+
help="The target environment. If not provided, all optional environments will be run.",
44+
required=False,
45+
)
46+
47+
def run(self, args: argparse.Namespace) -> int:
48+
"""Run the optional check command."""
49+
logger.info("Running optional check...")
50+
51+
set_envvar_defaults({"PROXY_URL": "http://localhost:5004"})
52+
targeted = self.get_targeted_directories(args)
53+
54+
results: List[int] = []
55+
56+
for parsed in targeted:
57+
package_dir = parsed.folder
58+
package_name = parsed.name
59+
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir)
60+
logger.info(f"Processing {package_name} for optional check")
61+
62+
if in_ci():
63+
if not is_check_enabled(package_dir, "optional", False):
64+
logger.info(f"Package {package_name} opts-out of optional check.")
65+
continue
66+
67+
try:
68+
self.install_dev_reqs(executable, args, package_dir)
69+
except CalledProcessError as exc:
70+
logger.error(f"Failed to install dependencies for {package_name}: {exc}")
71+
results.append(exc.returncode)
72+
continue
73+
74+
try:
75+
self.prepare_and_test_optional(package_name, package_dir, staging_directory, args.optional)
76+
except Exception as e:
77+
logger.error(f"Optional check for package {package_name} failed with exception: {e}")
78+
results.append(1)
79+
continue
80+
81+
return max(results) if results else 0
82+
83+
# TODO copying from generation.py, remove old code later
84+
# TODO remove pytest() function from ci_tools.functions as it was only used in the old version of this logic
85+
def prepare_and_test_optional(
86+
self, package_name: str, package_dir: str, temp_dir: str, target_env_name: str
87+
) -> None:
88+
"""
89+
Prepare and test the optional environment for the given package.
90+
"""
91+
optional_configs = get_config_setting(package_dir, "optional")
92+
93+
if len(optional_configs) == 0:
94+
logger.info(f"No optional environments detected in pyproject.toml within {package_dir}.")
95+
exit(0)
96+
97+
config_results = []
98+
99+
for config in optional_configs:
100+
env_name = config.get("name")
101+
102+
if target_env_name:
103+
if env_name != target_env_name:
104+
logger.info(
105+
f"{env_name} does not match targeted environment {target_env_name}, skipping this environment."
106+
)
107+
config_results.append(True)
108+
continue
109+
110+
environment_exe = prepare_environment(package_dir, temp_dir, env_name)
111+
112+
create_package_and_install(
113+
distribution_directory=temp_dir,
114+
target_setup=package_dir,
115+
skip_install=False,
116+
cache_dir=None,
117+
work_dir=temp_dir,
118+
force_create=False,
119+
package_type="sdist",
120+
pre_download_disabled=False,
121+
python_executable=environment_exe,
122+
)
123+
dev_reqs = os.path.join(package_dir, "dev_requirements.txt")
124+
test_tools = os.path.join(REPO_ROOT, "eng", "test_tools.txt")
125+
126+
# install the dev requirements and test_tools requirements files to ensure tests can run
127+
try:
128+
install_into_venv(environment_exe, ["-r", dev_reqs, "-r", test_tools], package_dir)
129+
except CalledProcessError as exc:
130+
logger.error(
131+
f"Unable to complete installation of dev_requirements.txt and/or test_tools.txt for {package_name}, check command output above."
132+
)
133+
config_results.append(False)
134+
break
135+
136+
# install any packages that are added in the optional config
137+
additional_installs = config.get("install", [])
138+
if additional_installs:
139+
try:
140+
install_into_venv(environment_exe, additional_installs, package_dir)
141+
except CalledProcessError as exc:
142+
logger.error(
143+
f"Unable to complete installation of additional packages {additional_installs} for {package_name}, check command output above."
144+
)
145+
config_results.append(False)
146+
break
147+
148+
# uninstall any configured packages from the optional config
149+
additional_uninstalls = config.get("uninstall", [])
150+
if additional_uninstalls:
151+
try:
152+
uninstall_from_venv(environment_exe, additional_uninstalls, package_dir)
153+
except CalledProcessError as exc:
154+
logger.error(
155+
f"Unable to complete removal of packages targeted for uninstall {additional_uninstalls} for {package_name}, check command output above."
156+
)
157+
config_results.append(False)
158+
break
159+
160+
self.pip_freeze(environment_exe)
161+
162+
# invoke tests
163+
log_level = os.getenv("PYTEST_LOG_LEVEL", "51")
164+
junit_path = os.path.join(package_dir, f"test-junit-optional-{env_name}.xml")
165+
166+
pytest_args = [
167+
"-rsfE",
168+
f"--junitxml={junit_path}",
169+
"--verbose",
170+
"--cov-branch",
171+
"--durations=10",
172+
"--ignore=azure",
173+
"--ignore=.tox",
174+
"--ignore-glob=.venv*",
175+
"--ignore=build",
176+
"--ignore=.eggs",
177+
"--ignore=samples",
178+
f"--log-cli-level={log_level}",
179+
]
180+
pytest_args.extend(config.get("additional_pytest_args", []))
181+
182+
logger.info(f"Invoking tests for package {package_name} and optional environment {env_name}")
183+
184+
pytest_command = ["-m", "pytest", *pytest_args]
185+
pytest_result = self.run_venv_command(
186+
environment_exe, pytest_command, cwd=package_dir, immediately_dump=True
187+
)
188+
189+
if pytest_result.returncode != 0:
190+
if pytest_result.returncode == 5 and is_error_code_5_allowed(package_dir, package_name):
191+
logger.info(
192+
"pytest exited with code 5 for %s, which is allowed for management or opt-out packages.",
193+
package_name,
194+
)
195+
# Align with tox: skip coverage when tests are skipped entirely
196+
continue
197+
logger.error(
198+
f"pytest failed for {package_name} and optional environment {env_name} with exit code {pytest_result.returncode}."
199+
)
200+
config_results.append(False)
201+
else:
202+
logger.info(f"pytest succeeded for {package_name} and optional environment {env_name}.")
203+
config_results.append(True)
204+
205+
if all(config_results):
206+
logger.info(f"All optional environment(s) for {package_name} completed successfully.")
207+
exit(0)
208+
else:
209+
for i, config in enumerate(optional_configs):
210+
if i >= len(config_results):
211+
break
212+
if not config_results[i]:
213+
config_name = config.get("name")
214+
logger.error(
215+
f"Optional environment {config_name} for {package_name} completed with non-zero exit-code. Check test results above."
216+
)
217+
exit(1)

0 commit comments

Comments
 (0)