Skip to content

Commit e7bf496

Browse files
authored
Enable absolute % coverage enforcement (Azure#39696)
* enable absolute coverage enforcement. honoring absolute_cov and absolute_cov_percent keys respectively. defaults to 95% coverage required * ensure that we apply the coverage rc file when generating the coverage xml report * enable absolute_cov enforcement for azure-ai-ml
1 parent 5d2828f commit e7bf496

File tree

7 files changed

+84
-42
lines changed

7 files changed

+84
-42
lines changed

doc/eng_sys_checks.md

+12
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ sphinx = false
138138

139139
If a package does not yet have a `pyproject.toml`, creating one with just the section `[tool.azure-sdk-build]` will do no harm to the release of the package in question.
140140

141+
### Coverage Enforcement
142+
143+
This repository supports enforcement of an absolute coverage % per package. Set:
144+
145+
```
146+
[tool.azure-sdk-build]
147+
absolute_cov = true
148+
absolute_cov_percent = 75.00
149+
```
150+
151+
After it is implemented, the `relative_cov` key will enable the prevention of **negative** code coverage contributions.
152+
141153
## Environment variables important to CI
142154

143155
There are a few differences from a standard local invocation of `tox <env>`. Primarily, these differences adjust the checks to be friendly to parallel invocation. These adjustments are necessary to prevent random CI crashes.

eng/tox/run_coverage.py

+32-19
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,13 @@
77

88
from typing import Optional
99

10-
from ci_tools.parsing import ParsedSetup
10+
from ci_tools.parsing import ParsedSetup, get_config_setting
1111
from ci_tools.variables import in_ci
1212
from ci_tools.environment_exclusions import is_check_enabled
13-
from coverage.exceptions import NoDataError
13+
from ci_tools.functions import get_total_coverage
1414

15-
def get_total_coverage(coverage_file: str) -> Optional[float]:
16-
cov = coverage.Coverage(data_file=coverage_file)
17-
cov.load()
18-
try:
19-
report = cov.report()
20-
return report
21-
except NoDataError as e:
22-
logging.warning(f"This package did not generate any coverage output: {e}")
23-
return None
24-
except Exception as e:
25-
logging.error(f"An error occurred while generating the coverage report: {e}")
26-
return None
15+
logging.basicConfig(level=logging.INFO)
16+
coveragerc_file = os.path.join(os.path.dirname(__file__), "tox.ini")
2717

2818
if __name__ == "__main__":
2919
parser = argparse.ArgumentParser(
@@ -38,16 +28,24 @@ def get_total_coverage(coverage_file: str) -> Optional[float]:
3828
required=True,
3929
)
4030

31+
parser.add_argument(
32+
"-r",
33+
"--root",
34+
dest="repo_root",
35+
help="The root of the directory. Source paths are relative to this.",
36+
required=True,
37+
)
38+
4139
args = parser.parse_args()
4240
pkg_details = ParsedSetup.from_path(args.target_package)
4341

4442
possible_coverage_file = os.path.join(args.target_package, ".coverage")
4543

4644
if os.path.exists(possible_coverage_file):
47-
total_coverage = get_total_coverage(possible_coverage_file)
48-
if total_coverage is not None:
49-
logging.info(f"Total coverage for {pkg_details.name} is {total_coverage:.2f}%")
45+
total_coverage = get_total_coverage(possible_coverage_file, coveragerc_file, pkg_details.name, args.repo_root)
5046

47+
if total_coverage is not None:
48+
# log the metric for reporting before doing anything else
5149
if in_ci():
5250
metric_obj = {}
5351
metric_obj["value"] = total_coverage / 100
@@ -59,6 +57,21 @@ def get_total_coverage(coverage_file: str) -> Optional[float]:
5957
# before the 'logmetric' start string.
6058
print(f'logmetric: {json.dumps(metric_obj)}')
6159

62-
if is_check_enabled(args.target_package, "cov_enforcement", False):
63-
logging.info("Coverage enforcement is enabled for this package.")
60+
# todo: add relative coverage comparison after we generate eng/coverages.json file in main
61+
# when we add that, we will call the config setting relative_cov to check if this is enabled
62+
# there, we will only ever compare the baseline % against the generated %
63+
64+
# as a fallback, we can always check the absolute coverage % against the config setting
65+
# if the config setting is not set, we will not enforce any coverage
66+
if is_check_enabled(args.target_package, "absolute_cov", False):
67+
logging.info("Coverage enforcement is enabled for this package.")
68+
69+
# if this threshold is not set in config setting, the default will be very high
70+
cov_threshold = get_config_setting(args.target_package, "absolute_cov_percent", 95.0)
71+
if total_coverage < float(cov_threshold):
72+
logging.error(
73+
f"Coverage for {pkg_details.name} is below the threshold of {cov_threshold:.2f}% (actual: {total_coverage:.2f}%)"
74+
)
75+
exit(1)
76+
6477

eng/tox/tox.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ commands =
7373
python {repository_root}/eng/tox/create_package_and_install.py -d {envtmpdir} -p {tox_root} -w {envtmpdir}
7474
python -m pip freeze
7575
pytest {[pytest]default_args} {posargs} {tox_root}
76-
python {repository_root}/eng/tox/run_coverage.py -t {tox_root}
76+
python {repository_root}/eng/tox/run_coverage.py -t {tox_root} -r {repository_root}
7777

7878

7979
[testenv:pylint]

scripts/devops_tasks/create_coverage.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
root_dir = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..", ".."))
2121
coverage_dir = os.path.join(root_dir, "_coverage/")
22+
toxrc = os.path.join(root_dir, "eng","tox", "tox.ini")
2223

2324

2425
def collect_tox_coverage_files():
@@ -52,7 +53,7 @@ def collect_tox_coverage_files():
5253
def generate_coverage_xml():
5354
if os.path.exists(coverage_dir):
5455
logging.info("Generating coverage XML")
55-
commands = ["coverage", "xml", "-i"]
56+
commands = ["coverage", "xml", "-i", "--rcfile", toxrc]
5657
run_check_call(commands, root_dir, always_exit=False)
5758
else:
5859
logging.error("Coverage file is not available in {} to generate coverage XML".format(coverage_dir))

scripts/devops_tasks/tox_harness.py

-21
Original file line numberDiff line numberDiff line change
@@ -35,27 +35,6 @@
3535
dependency_tools_path = os.path.join(root_dir, "eng", "dependency_tools.txt")
3636

3737

38-
def combine_coverage_files(targeted_packages):
39-
# find tox.ini file. tox.ini is used to combine coverage paths to generate formatted report
40-
tox_ini_file = os.path.join(root_dir, "eng", "tox", "tox.ini")
41-
config_file_flag = "--rcfile={}".format(tox_ini_file)
42-
43-
if os.path.isfile(tox_ini_file):
44-
# for every individual coverage file, run coverage combine to combine path
45-
for package_dir in [package for package in targeted_packages]:
46-
coverage_file = os.path.join(package_dir, ".coverage")
47-
if os.path.isfile(coverage_file):
48-
cov_cmd_array = [sys.executable, "-m", "coverage", "combine"]
49-
# tox.ini file has coverage paths to combine
50-
# Pas tox.ini as coverage config file
51-
cov_cmd_array.extend([config_file_flag, coverage_file])
52-
run_check_call(cov_cmd_array, package_dir)
53-
else:
54-
# not a hard error at this point
55-
# this combine step is required only for modules if report has package name starts with .tox
56-
logging.error("tox.ini is not found in path {}".format(root_dir))
57-
58-
5938
def collect_tox_coverage_files(targeted_packages):
6039
root_coverage_dir = os.path.join(root_dir, "_coverage/")
6140

sdk/ml/azure-ai-ml/pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ pylint = true
66
mindependency = false
77
latestdependency = false
88
black = true
9+
absolute_cov = true
10+
absolute_cov_percent = 21.34
911

1012
[tool.isort]
1113
profile = "black"

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

+35
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from packaging.specifiers import SpecifierSet
99
from packaging.version import Version, parse, InvalidVersion
1010
from pkg_resources import Requirement
11+
import io
1112

1213
from ci_tools.variables import discover_repo_root, DEV_BUILD_IDENTIFIER, str_to_bool
1314
from ci_tools.parsing import ParsedSetup, get_config_setting, get_pyproject
@@ -695,6 +696,40 @@ def is_package_compatible(
695696
return True
696697

697698

699+
def get_total_coverage(coverage_file: str, coverage_config_file: str, package_name: str, repo_root: Optional[str] = None) -> Optional[float]:
700+
try:
701+
import coverage
702+
from coverage.exceptions import NoDataError
703+
except ImportError:
704+
logging.error("Coverage is not installed.")
705+
return None
706+
707+
cov = coverage.Coverage(data_file=coverage_file, config_file=coverage_config_file)
708+
cov.load()
709+
original = os.getcwd()
710+
output = io.StringIO()
711+
712+
old_stdout = sys.stdout
713+
sys.stdout = output
714+
715+
report = 0.0
716+
try:
717+
if repo_root:
718+
os.chdir(repo_root)
719+
logging.info(f"Running coverage report against \"{coverage_file}\" with \"{coverage_config_file}\" from \"{os.getcwd()}\".")
720+
report = cov.report()
721+
except NoDataError as e:
722+
logging.info(f"Package {package_name} did not generate any coverage output: {e}")
723+
except Exception as e:
724+
logging.error(f"An error occurred while generating the coverage report for {package_name}: {e}")
725+
finally:
726+
if repo_root:
727+
os.chdir(original)
728+
sys.stdout = old_stdout
729+
logging.info(f"Total coverage {report} for package {package_name}")
730+
return report
731+
732+
698733
def resolve_compatible_package(package_name: str, immutable_requirements: List[Requirement]) -> Optional[str]:
699734
"""
700735
This function attempts to resolve a compatible package version for whatever set of immutable_requirements that

0 commit comments

Comments
 (0)