Skip to content

Commit 05b029a

Browse files
JennyPngCopilot
andauthored
Add Samples Check to azpysdk (#44417)
* base * more * more * doc * use executable * unnecessary * jk it is necessary * link fix * Update doc/tool_usage_guide.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * unused import * comment and install fix --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent bbb97a1 commit 05b029a

File tree

4 files changed

+356
-2
lines changed

4 files changed

+356
-2
lines changed

doc/dev/sample_guide.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,17 @@ The given `START`/`END` keywords can be used in a [sphinx literalinclude][sphinx
5454
[Literalinclude example][literalinclude]
5555

5656
The rendered code snippets are sensitive to the indentation in the sample file. Sphinx will adjust the dedent accordingly to ensure the sample is captured accurately and not accidentally trimmed.
57-
You can preview how published reference documentation will look by running [tox][tox]: `tox run -e sphinx -c ../../../eng/tox/tox.ini --root <path to python package>`.
57+
You can preview how published reference documentation will look by running either
58+
- [tox][tox]: `tox run -e sphinx -c ../../../eng/tox/tox.ini --root <path to python package>`.
59+
- [azpysdk](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/tool_usage_guide.md): run `azpysdk sphinx .` in the package directory.
5860

5961
## Test run samples in CI live tests
6062
Per the [Python guidelines][snippet_guidelines], sample code and snippets should be test run in CI to ensure they remain functional. Samples should be run in the package's live test pipeline which is scheduled to run daily.
6163
To ensure samples do get tested as part of regular CI runs, add these [lines][live_tests] to the package's tests.yml.
6264

63-
You can test this CI step locally first, by utilizing [tox][tox] and running `tox run -e samples -c ../../../eng/tox/tox.ini --root <path to python package>`.
65+
You can test this CI step locally first with tox or azpysdk:
66+
- To use [tox][tox], run `tox run -e samples -c ../../../eng/tox/tox.ini --root <path to python package>`.
67+
- To use [azpysdk](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/tool_usage_guide.md), run `azpysdk samples .` in the package directory.
6468

6569
The `Test Samples` step in CI will rely on the resources provisioned and environment variables used for running the package's tests.
6670

doc/tool_usage_guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ This repo is currently migrating all checks from a slower `tox`-based framework,
3030
|`import_all`| Installs the package w/ default dependencies, then attempts to `import *` from the base namespace. Ensures that all imports will resolve after a base install and import. | `azpysdk import_all .` |
3131
|`generate`| Regenerates the code. | `azpysdk generate .` |
3232
|`breaking`| Checks for breaking changes. | `azpysdk breaking .` |
33+
|`samples`| Runs the package's samples. | `azpysdk samples .` |
3334
|`devtest`| Tests a package against dependencies installed from a dev index. | `azpysdk devtest .` |
3435

3536
## Common arguments

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .verify_keywords import verify_keywords
3333
from .generate import generate
3434
from .breaking import breaking
35+
from .samples import samples
3536
from .devtest import devtest
3637

3738
from ci_tools.logging import configure_logging, logger
@@ -96,6 +97,7 @@ def build_parser() -> argparse.ArgumentParser:
9697
verify_keywords().register(subparsers, [common])
9798
generate().register(subparsers, [common])
9899
breaking().register(subparsers, [common])
100+
samples().register(subparsers, [common])
99101
devtest().register(subparsers, [common])
100102

101103
return parser
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import argparse
2+
import sys
3+
import os
4+
from fnmatch import fnmatch
5+
6+
from typing import Optional, List
7+
8+
from .Check import Check
9+
from ci_tools.functions import install_into_venv
10+
from ci_tools.scenario.generation import create_package_and_install
11+
from ci_tools.variables import discover_repo_root, set_envvar_defaults
12+
from ci_tools.logging import logger
13+
14+
from subprocess import TimeoutExpired, check_call, CalledProcessError
15+
from ci_tools.functions import compare_python_version
16+
17+
REPO_ROOT = discover_repo_root()
18+
19+
common_task_path = os.path.abspath(os.path.join(REPO_ROOT, "scripts", "devops_tasks"))
20+
sys.path.append(common_task_path)
21+
from common_tasks import run_check_call
22+
23+
MINIMUM_TESTED_PYTHON_VERSION = ">=3.8.0"
24+
25+
"""
26+
Some samples may "run forever" or need to be timed out after a period of time. Add them here in the following format:
27+
TIMEOUT_SAMPLES = {
28+
"<package-name>": {
29+
"<sample_file_name.py>": (<timeout (seconds)>, <pass if timeout? (bool, default: True)>)
30+
}
31+
}
32+
"""
33+
TIMEOUT_SAMPLES = {
34+
"azure-eventgrid": {
35+
"consume_cloud_events_from_storage_queue.py": (10),
36+
},
37+
"azure-eventhub": {
38+
"receive_batch_with_checkpoint.py": (10),
39+
"recv.py": (10),
40+
"recv_track_last_enqueued_event_prop.py": (10),
41+
"recv_with_checkpoint_by_event_count.py": (10),
42+
"recv_with_checkpoint_by_time_interval.py": (10),
43+
"recv_with_checkpoint_store.py": (10),
44+
"recv_with_custom_starting_position.py": (10),
45+
"sample_code_eventhub.py": (10),
46+
"receive_batch_with_checkpoint_async.py": (10),
47+
"recv_async.py": (10),
48+
"recv_track_last_enqueued_event_prop_async.py": (10),
49+
"recv_with_checkpoint_by_event_count_async.py": (10),
50+
"recv_with_checkpoint_by_time_interval_async.py": (10),
51+
"recv_with_checkpoint_store_async.py": (10),
52+
"recv_with_custom_starting_position_async.py": (10),
53+
"sample_code_eventhub_async.py": (10),
54+
},
55+
"azure-eventhub-checkpointstoreblob": {
56+
"receive_events_using_checkpoint_store.py": (10),
57+
"receive_events_using_checkpoint_store_storage_api_version.py": (10),
58+
},
59+
"azure-eventhub-checkpointstoreblob-aio": {
60+
"receive_events_using_checkpoint_store_async.py": (10),
61+
"receive_events_using_checkpoint_store_storage_api_version_async.py": (10),
62+
},
63+
"azure-servicebus": {
64+
"failure_and_recovery.py": (10),
65+
"receive_iterator_queue.py": (10),
66+
"sample_code_servicebus.py": (30),
67+
"session_pool_receive.py": (20),
68+
"receive_iterator_queue_async.py": (10),
69+
"sample_code_servicebus_async.py": (30),
70+
"session_pool_receive_async.py": (20),
71+
},
72+
}
73+
74+
75+
# Add your library + sample file if you do not want a particular sample to be run
76+
IGNORED_SAMPLES = {
77+
"azure-appconfiguration-provider": [
78+
"key_vault_reference_customized_clients_sample.py",
79+
"aad_sample.py",
80+
"key_vault_reference_sample.py",
81+
],
82+
"azure-ai-ml": ["ml_samples_authentication_sovereign_cloud.py"],
83+
"azure-eventgrid": [
84+
"__init__.py",
85+
"consume_cloud_events_from_eventhub.py",
86+
"consume_eventgrid_events_from_service_bus_queue.py",
87+
"sample_publish_events_to_a_topic_using_sas_credential.py",
88+
"sample_publish_events_to_a_topic_using_sas_credential_async.py",
89+
],
90+
"azure-eventhub": [
91+
"client_identity_authentication.py",
92+
"client_identity_authentication_async.py",
93+
"connection_to_custom_endpoint_address.py",
94+
"proxy.py",
95+
"connection_to_custom_endpoint_address_async.py",
96+
"iot_hub_connection_string_receive_async.py",
97+
"proxy_async.py",
98+
"send_stream.py",
99+
"send_stream_async.py",
100+
"send_buffered_mode.py",
101+
"send_buffered_mode_async.py",
102+
"send_and_receive_amqp_annotated_message.py",
103+
"send_and_receive_amqp_annotated_message_async.py",
104+
],
105+
"azure-eventhub-checkpointstoretable": ["receive_events_using_checkpoint_store.py"],
106+
"azure-servicebus": [
107+
"connection_to_custom_endpoint_address.py",
108+
"mgmt_queue.py",
109+
"mgmt_rule.py",
110+
"mgmt_subscription.py",
111+
"mgmt_topic.py",
112+
"proxy.py",
113+
"receive_deferred_message_queue.py",
114+
"connection_to_custom_endpoint_address_async.py",
115+
"mgmt_queue_async.py",
116+
"mgmt_rule_async.py",
117+
"mgmt_subscription_async.py",
118+
"mgmt_topic_async.py",
119+
"proxy_async.py",
120+
"receive_deferred_message_queue_async.py",
121+
],
122+
"azure-communication-chat": [
123+
"chat_client_sample_async.py",
124+
"chat_client_sample.py",
125+
"chat_thread_client_sample_async.py",
126+
"chat_thread_client_sample.py",
127+
],
128+
"azure-communication-phonenumbers": [
129+
"purchase_phone_number_sample_async.py",
130+
"purchase_phone_number_sample.py",
131+
"release_phone_number_sample_async.py",
132+
"release_phone_number_sample.py",
133+
],
134+
"azure-ai-translation-document": [
135+
"sample_list_document_statuses_with_filters_async.py",
136+
"sample_list_translations_with_filters_async.py",
137+
"sample_list_document_statuses_with_filters.py",
138+
"sample_list_translations_with_filters.py",
139+
"sample_translation_with_custom_model.py",
140+
"sample_translation_with_custom_model_async.py",
141+
"sample_begin_translation_with_filters.py",
142+
"sample_begin_translation_with_filters_async.py",
143+
],
144+
"azure-ai-language-questionanswering": ["sample_export_import_project.py", "sample_export_import_project_async.py"],
145+
"azure-ai-textanalytics": [
146+
"sample_analyze_healthcare_entities_with_cancellation.py",
147+
"sample_analyze_healthcare_entities_with_cancellation_async.py",
148+
"sample_abstract_summary.py",
149+
"sample_abstract_summary_async.py",
150+
],
151+
"azure-storage-blob": [
152+
"blob_samples_proxy_configuration.py",
153+
"blob_samples_container_access_policy.py",
154+
"blob_samples_container_access_policy_async.py",
155+
"blob_samples_client_side_encryption_keyvault.py",
156+
],
157+
}
158+
159+
160+
def run_check_call_with_timeout(
161+
command_array,
162+
working_directory,
163+
timeout,
164+
pass_if_timeout,
165+
acceptable_return_codes=[],
166+
always_exit=False,
167+
):
168+
"""
169+
Run a subprocess command with a timeout.
170+
"""
171+
172+
try:
173+
logger.info("Command Array: {0}, Target Working Directory: {1}".format(command_array, working_directory))
174+
check_call(command_array, cwd=working_directory, timeout=timeout)
175+
except CalledProcessError as err:
176+
if err.returncode not in acceptable_return_codes:
177+
logger.error(err) # , file = sys.stderr
178+
if always_exit:
179+
exit(1)
180+
else:
181+
return err
182+
except TimeoutExpired as err:
183+
if pass_if_timeout:
184+
logger.info("Sample timed out successfully")
185+
else:
186+
logger.info("Fail: Sample timed out")
187+
return err
188+
189+
190+
def execute_sample(sample, samples_errors, timed, executable):
191+
timeout = None
192+
pass_if_timeout = True
193+
194+
if isinstance(sample, tuple):
195+
sample, timeout, pass_if_timeout = sample
196+
197+
if sys.version_info < (3, 5) and sample.endswith("_async.py"):
198+
return
199+
200+
logger.info("Testing {}".format(sample))
201+
command_array = [executable, sample]
202+
203+
if not timed:
204+
errors = run_check_call(command_array, REPO_ROOT, always_exit=False)
205+
else:
206+
errors = run_check_call_with_timeout(command_array, REPO_ROOT, timeout, pass_if_timeout)
207+
208+
sample_name = os.path.basename(sample)
209+
if errors:
210+
samples_errors.append(sample_name)
211+
logger.info("ERROR: {}".format(sample_name))
212+
else:
213+
logger.info("SUCCESS: {}.".format(sample_name))
214+
215+
216+
def resolve_sample_ignore(sample_file, package_name):
217+
ignored_files = [(f, ">=2.7") if not isinstance(f, tuple) else f for f in IGNORED_SAMPLES.get(package_name, [])]
218+
ignored_files_dict = {key: value for (key, value) in ignored_files}
219+
220+
if sample_file in ignored_files_dict and compare_python_version(ignored_files_dict[sample_file]):
221+
return False
222+
else:
223+
return True
224+
225+
226+
def run_samples(executable: str, targeted_package: str) -> None:
227+
logger.info("running samples for {}".format(targeted_package))
228+
229+
samples_errors = []
230+
sample_paths = []
231+
timed_sample_paths = []
232+
233+
samples_dir_path = os.path.abspath(os.path.join(targeted_package, "samples"))
234+
package_name = os.path.basename(targeted_package)
235+
samples_need_timeout = TIMEOUT_SAMPLES.get(package_name, {})
236+
237+
# install extra dependencies for samples if needed
238+
try:
239+
with open(samples_dir_path + "/sample_dev_requirements.txt") as sample_dev_reqs:
240+
logger.info("Installing extra dependencies for samples from sample_dev_requirements.txt")
241+
for dep in sample_dev_reqs.readlines():
242+
try:
243+
install_into_venv(executable, [dep.strip()], targeted_package)
244+
except Exception as e:
245+
logger.error(f"Failed to install dependency {dep.strip()}: {e}")
246+
except IOError:
247+
pass
248+
249+
for path, subdirs, files in os.walk(samples_dir_path):
250+
for name in files:
251+
if fnmatch(name, "*.py") and name in samples_need_timeout:
252+
timeout = samples_need_timeout[name]
253+
# timeout, pass_if_timeout is True by default if nothing passed in
254+
if isinstance(timeout, tuple):
255+
timeout, pass_if_timeout = timeout
256+
else:
257+
pass_if_timeout = True
258+
timed_sample_paths.append(
259+
(
260+
os.path.abspath(os.path.join(path, name)),
261+
timeout,
262+
pass_if_timeout,
263+
)
264+
)
265+
elif fnmatch(name, "*.py") and resolve_sample_ignore(name, package_name):
266+
sample_paths.append(os.path.abspath(os.path.join(path, name)))
267+
268+
if not sample_paths and not timed_sample_paths:
269+
logger.info("No samples found in {}".format(targeted_package))
270+
exit(0)
271+
272+
for sample in sample_paths:
273+
execute_sample(sample, samples_errors, timed=False, executable=executable)
274+
275+
for sample in timed_sample_paths:
276+
execute_sample(sample, samples_errors, timed=True, executable=executable)
277+
278+
if samples_errors:
279+
logger.error("Sample(s) that ran with errors: {}".format(samples_errors))
280+
exit(1)
281+
282+
logger.info("All samples ran successfully in {}".format(targeted_package))
283+
284+
285+
class samples(Check):
286+
def __init__(self) -> None:
287+
super().__init__()
288+
289+
def register(
290+
self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None
291+
) -> None:
292+
"""Register the samples check. The samples check runs a package's samples."""
293+
parents = parent_parsers or []
294+
p = subparsers.add_parser(
295+
"samples",
296+
parents=parents,
297+
help="Run a package's samples. Installs dependencies and packages, tests Azure packages' samples, called from DevOps YAML pipeline.",
298+
)
299+
p.set_defaults(func=self.run)
300+
301+
def run(self, args: argparse.Namespace) -> int:
302+
"""Run the samples check command."""
303+
logger.info("Running samples check...")
304+
305+
set_envvar_defaults({"PROXY_URL": "http://localhost:5003"})
306+
targeted = self.get_targeted_directories(args)
307+
308+
results: List[int] = []
309+
310+
for parsed in targeted:
311+
package_dir = parsed.folder
312+
package_name = parsed.name
313+
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir)
314+
logger.info(f"Processing {package_name} for samples check")
315+
316+
# install dependencies
317+
self.install_dev_reqs(executable, args, package_dir)
318+
319+
# build and install the package
320+
create_package_and_install(
321+
distribution_directory=staging_directory,
322+
target_setup=package_dir,
323+
skip_install=False,
324+
cache_dir=None,
325+
work_dir=staging_directory,
326+
force_create=False,
327+
package_type="sdist",
328+
pre_download_disabled=False,
329+
python_executable=executable,
330+
)
331+
332+
self.pip_freeze(executable)
333+
334+
service_dir = os.path.join("sdk", package_dir)
335+
target_dir = os.path.join(REPO_ROOT, service_dir)
336+
337+
if compare_python_version(MINIMUM_TESTED_PYTHON_VERSION):
338+
try:
339+
logger.info(
340+
f"User opted to run samples for {package_name}, and package version is greater than minimum supported."
341+
)
342+
run_samples(executable, target_dir)
343+
except Exception as e:
344+
logger.error(f"An error occurred while running samples for {package_name}: {e}")
345+
results.append(1)
346+
347+
return max(results) if results else 0

0 commit comments

Comments
 (0)