1# Copyright 2020 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Preconfigured checks for Python code. 15 16These checks assume that they are running in a preconfigured Python environment. 17""" 18 19import difflib 20import json 21import logging 22from pathlib import Path 23import platform 24import re 25import shutil 26import sys 27from tempfile import TemporaryDirectory 28import venv 29 30from pw_cli.diff import colorize_diff_line 31from pw_env_setup import python_packages 32 33from pw_presubmit.presubmit import ( 34 call, 35 Check, 36 filter_paths, 37) 38from pw_presubmit.git_repo import LoggingGitRepo 39from pw_presubmit.presubmit_context import ( 40 PresubmitContext, 41 PresubmitFailure, 42) 43from pw_presubmit import build 44from pw_presubmit.tools import log_run 45 46_LOG = logging.getLogger(__name__) 47 48_PYTHON_EXTENSIONS = ('.py', '.gn', '.gni') 49 50_PYTHON_PACKAGE_EXTENSIONS = ( 51 'setup.cfg', 52 'constraint.list', 53 'requirements.txt', 54) 55 56_PYTHON_IS_3_9_OR_HIGHER = sys.version_info >= ( 57 3, 58 9, 59) 60 61 62@filter_paths(endswith=_PYTHON_EXTENSIONS) 63def gn_python_check(ctx: PresubmitContext): 64 build.gn_gen(ctx) 65 build.ninja(ctx, 'python.tests', 'python.lint') 66 67 68def _transform_lcov_file_paths(lcov_file: Path, repo_root: Path) -> str: 69 """Modify file paths in an lcov file to be relative to the repo root. 70 71 See `man geninfo` for info on the lcov format.""" 72 73 lcov_input = lcov_file.read_text() 74 lcov_output = '' 75 76 if not _PYTHON_IS_3_9_OR_HIGHER: 77 return lcov_input 78 79 for line in lcov_input.splitlines(): 80 if not line.startswith('SF:'): 81 lcov_output += line + '\n' 82 continue 83 84 # Get the file path after SF: 85 file_string = line[3:].rstrip() 86 source_file_path = Path(file_string) 87 88 # Attempt to map a generated Python package source file to the root 89 # source tree. 90 # pylint: disable=no-member 91 if not source_file_path.is_relative_to( 92 repo_root # type: ignore[attr-defined] 93 ): 94 # pylint: enable=no-member 95 source_file_path = repo_root / str(source_file_path).replace( 96 'python/gen/', '' 97 ).replace('py.generated_python_package/', '') 98 99 # If mapping fails don't modify this line. 100 # pylint: disable=no-member 101 if not source_file_path.is_relative_to( 102 repo_root # type: ignore[attr-defined] 103 ): 104 # pylint: enable=no-member 105 lcov_output += line + '\n' 106 continue 107 108 source_file_path = source_file_path.relative_to(repo_root) 109 lcov_output += f'SF:{source_file_path}\n' 110 111 return lcov_output 112 113 114@filter_paths(endswith=_PYTHON_EXTENSIONS) 115def gn_python_test_coverage(ctx: PresubmitContext): 116 """Run Python tests with coverage and create reports.""" 117 build.gn_gen(ctx, pw_build_PYTHON_TEST_COVERAGE=True) 118 build.ninja(ctx, 'python.tests') 119 120 # Find coverage data files 121 coverage_data_files = list(ctx.output_dir.glob('**/*.coverage')) 122 if not coverage_data_files: 123 return 124 125 # Merge coverage data files to out/.coverage 126 call( 127 'coverage', 128 'combine', 129 # Leave existing coverage files in place; by default they are deleted. 130 '--keep', 131 *coverage_data_files, 132 cwd=ctx.output_dir, 133 ) 134 combined_data_file = ctx.output_dir / '.coverage' 135 _LOG.info('Coverage data saved to: %s', combined_data_file.resolve()) 136 137 # Always ignore generated proto python and setup.py files. 138 coverage_omit_patterns = '--omit=*_pb2.py,*/setup.py' 139 140 # Output coverage percentage summary to the terminal of changed files. 141 changed_python_files = list( 142 str(p) for p in ctx.paths if str(p).endswith('.py') 143 ) 144 report_args = [ 145 'coverage', 146 'report', 147 '--ignore-errors', 148 coverage_omit_patterns, 149 ] 150 report_args += changed_python_files 151 log_run(report_args, check=False, cwd=ctx.output_dir) 152 153 # Generate a json report 154 call('coverage', 'lcov', coverage_omit_patterns, cwd=ctx.output_dir) 155 lcov_data_file = ctx.output_dir / 'coverage.lcov' 156 lcov_data_file.write_text( 157 _transform_lcov_file_paths(lcov_data_file, repo_root=ctx.root) 158 ) 159 _LOG.info('Coverage lcov saved to: %s', lcov_data_file.resolve()) 160 161 # Generate an html report 162 call('coverage', 'html', coverage_omit_patterns, cwd=ctx.output_dir) 163 html_report = ctx.output_dir / 'htmlcov' / 'index.html' 164 _LOG.info('Coverage html report saved to: %s', html_report.resolve()) 165 166 167@filter_paths(endswith=_PYTHON_PACKAGE_EXTENSIONS) 168def vendor_python_wheels(ctx: PresubmitContext) -> None: 169 """Download Python packages locally for the current platform.""" 170 build.gn_gen(ctx) 171 build.ninja(ctx, 'pip_vendor_wheels') 172 173 download_log = ( 174 ctx.output_dir 175 / 'python/gen/pw_env_setup/pigweed_build_venv.vendor_wheels' 176 / 'pip_download_log.txt' 177 ) 178 _LOG.info('Python package download log: %s', download_log) 179 180 wheel_output = ( 181 ctx.output_dir 182 / 'python/gen/pw_env_setup' 183 / 'pigweed_build_venv.vendor_wheels/wheels/' 184 ) 185 wheel_destination = ctx.output_dir / 'python_wheels' 186 shutil.rmtree(wheel_destination, ignore_errors=True) 187 shutil.copytree(wheel_output, wheel_destination, dirs_exist_ok=True) 188 189 _LOG.info('Python packages downloaded to: %s', wheel_destination) 190 191 192SETUP_CFG_VERSION_REGEX = re.compile( 193 r'^version = (?P<version>' 194 r'(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)' 195 r')$', 196 re.MULTILINE, 197) 198 199 200def _find_existing_setup_cfg_version(setup_cfg: Path) -> re.Match: 201 version_match = SETUP_CFG_VERSION_REGEX.search(setup_cfg.read_text()) 202 if not version_match: 203 raise PresubmitFailure( 204 f'Unable to find "version = x.x.x" line in {setup_cfg}' 205 ) 206 return version_match 207 208 209def _version_bump_setup_cfg( 210 repo_root: Path, 211 setup_cfg: Path, 212) -> str: 213 """Increment the version patch number of a setup.cfg 214 215 Skips modifying if there are modifications since origin/main. 216 217 Returns: 218 The version number as a string 219 """ 220 repo = LoggingGitRepo(repo_root) 221 setup_cfg = repo_root / setup_cfg 222 223 # Grab the current version string. 224 _LOG.info('Checking the version patch number in: %s', setup_cfg) 225 setup_cfg_text = setup_cfg.read_text() 226 version_match = _find_existing_setup_cfg_version(setup_cfg) 227 _LOG.info('Found: %s', version_match[0]) 228 version_number = version_match['version'] 229 230 # Skip modifying the version if it is different compared to origin/main. 231 modified_files = repo.list_files(commit='origin/main') 232 modify_setup_cfg = True 233 if setup_cfg in modified_files: 234 # Don't update the file 235 modify_setup_cfg = False 236 _LOG.warning( 237 '%s is already modified, skipping version update.', setup_cfg 238 ) 239 240 if modify_setup_cfg: 241 # Increment the patch number. 242 try: 243 patch_number = int(version_match['patch']) + 1 244 except ValueError as err: 245 raise PresubmitFailure( 246 f"Unable to increment patch number: '{version_match['patch']}' " 247 f"for version line: '{version_match[0]}'" 248 ) from err 249 250 version_number = ( 251 f"{version_match['major']}.{version_match['minor']}.{patch_number}" 252 ) 253 new_line = f'version = {version_number}' 254 new_text = SETUP_CFG_VERSION_REGEX.sub( 255 new_line, 256 setup_cfg_text, 257 count=1, 258 ) 259 260 # Print the diff 261 setup_cfg_diff = list( 262 difflib.unified_diff( 263 setup_cfg_text.splitlines(), 264 new_text.splitlines(), 265 fromfile=str(setup_cfg) + ' (original)', 266 tofile=str(setup_cfg) + ' (updated)', 267 lineterm='', 268 n=1, 269 ) 270 ) 271 if setup_cfg_diff: 272 for line in setup_cfg_diff: 273 print(colorize_diff_line(line)) 274 275 # Update the file. 276 setup_cfg.write_text(new_text, encoding='utf-8') 277 278 return version_number 279 280 281def version_bump_pigweed_pypi_distribution(ctx: PresubmitContext) -> None: 282 """Update the version patch number in //pw_env_setup/pypi_common_setup.cfg 283 284 This presubmit creates a new git branch, updates the version and makes a new 285 commit with the standard version bump subject line. 286 """ 287 repo = LoggingGitRepo(ctx.root) 288 289 # Check there are no uncommitted changes. 290 modified_files = repo.list_files(commit='HEAD') 291 if modified_files: 292 raise PresubmitFailure('There must be no modified files to proceed.') 293 294 # Checkout a new branch for the version bump. Resets an existing branch if 295 # it already exists. 296 log_run( 297 [ 298 'git', 299 'checkout', 300 '-B', 301 'pypi-version-bump', 302 '--no-track', 303 'origin/main', 304 ], 305 check=True, 306 cwd=ctx.root, 307 ) 308 309 # Update the version number. 310 setup_cfg = 'pw_env_setup/pypi_common_setup.cfg' 311 version_number = _version_bump_setup_cfg( 312 repo_root=ctx.root, 313 setup_cfg=ctx.root / 'pw_env_setup/pypi_common_setup.cfg', 314 ) 315 316 # Add and commit changes. 317 log_run(['git', 'add', setup_cfg], check=True, cwd=ctx.root) 318 git_commit_args = [ 319 'git', 320 'commit', 321 '-m', 322 f'pw_env_setup: PyPI version bump to {version_number}', 323 ] 324 log_run(git_commit_args, check=True, cwd=ctx.root) 325 _LOG.info('Version bump commit created in branch "pypi-version-bump"') 326 _LOG.info('Upload with:\n git push origin HEAD:refs/for/main') 327 328 329def upload_pigweed_pypi_distribution( 330 ctx: PresubmitContext, 331) -> None: 332 """Upload the pigweed pypi distribution to pypi.org. 333 334 This requires an API token to be setup for the current user. See also: 335 https://pypi.org/help/#apitoken 336 https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#create-an-account 337 """ 338 version_match = _find_existing_setup_cfg_version( 339 Path(ctx.root / 'pw_env_setup/pypi_common_setup.cfg'), 340 ) 341 version_number = version_match['version'] 342 343 _LOG.info('Cleaning any existing build artifacts.') 344 build.gn_gen(ctx) 345 346 dist_output_path = ( 347 ctx.output_dir 348 / 'python/obj/pw_env_setup/pypi_pigweed_python_source_tree' 349 ) 350 351 # Always remove any existing build artifacts before generating a 352 # new distribution. 'python -m build' leaves a 'dist' directory without 353 # cleaning up. 354 shutil.rmtree(dist_output_path, ignore_errors=True) 355 build.ninja(ctx, 'pigweed_pypi_distribution', '-t', 'clean') 356 357 # Generate the distribution 358 _LOG.info('Running the ninja build.') 359 build.ninja(ctx, 'pigweed_pypi_distribution') 360 361 # Check the output is in the right place. 362 if any( 363 not (dist_output_path / f).is_file() for f in ['README.md', 'LICENSE'] 364 ): 365 raise PresubmitFailure( 366 f'Missing pypi distribution files in: {dist_output_path}' 367 ) 368 369 # Create a new venv for building and uploading. 370 venv_path = ctx.output_dir / 'pypi_upload_venv' 371 _LOG.info('Creating venv for uploading in: %s', venv_path) 372 shutil.rmtree(venv_path, ignore_errors=True) 373 venv.create(venv_path, symlinks=True, with_pip=True) 374 py_bin = venv_path / 'bin/python' 375 376 # Install upload tools. 377 _LOG.info('Running: pip install --upgrade pip %s', venv_path) 378 log_run( 379 [py_bin, '-m', 'pip', 'install', '--upgrade', 'pip'], 380 check=True, 381 cwd=ctx.output_dir, 382 ) 383 _LOG.info('Running: pip install --upgrade build twine %s', venv_path) 384 log_run( 385 [py_bin, '-m', 'pip', 'install', '--upgrade', 'build', 'twine'], 386 check=True, 387 cwd=ctx.output_dir, 388 ) 389 390 # Create upload artifacts 391 _LOG.info('Running: python -m build') 392 log_run([py_bin, '-m', 'build'], check=True, cwd=dist_output_path) 393 394 dist_path = dist_output_path / 'dist' 395 upload_files = list(dist_path.glob('*')) 396 expected_files = [ 397 dist_path / f'pigweed-{version_number}.tar.gz', 398 dist_path / f'pigweed-{version_number}-py3-none-any.whl', 399 ] 400 if upload_files != expected_files: 401 raise PresubmitFailure( 402 'Unexpected dist files found for upload. Skipping upload.\n' 403 f'Found:\n {upload_files}\n' 404 f'Expected:\n {expected_files}\n' 405 ) 406 407 # Upload to pypi.org 408 upload_args = [py_bin, '-m', 'twine', 'upload'] 409 upload_args.extend(expected_files) 410 log_run(upload_args, check=True, cwd=dist_output_path) 411 412 413def _generate_constraint_with_hashes( 414 ctx: PresubmitContext, input_file: Path, output_file: Path 415) -> None: 416 assert input_file.is_file() 417 418 call( 419 "pip-compile", 420 input_file, 421 "--generate-hashes", 422 "--reuse-hashes", 423 "--resolver=backtracking", 424 "--strip-extras", 425 # Force pinning pip and setuptools 426 "--allow-unsafe", 427 "-o", 428 output_file, 429 ) 430 431 # Remove absolute paths from comments 432 output_text = output_file.read_text() 433 output_text = output_text.replace(str(ctx.output_dir), '') 434 output_text = output_text.replace(str(ctx.root), '') 435 output_text = output_text.replace(str(output_file.parent), '') 436 437 final_output_text = '' 438 for line in output_text.splitlines(keepends=True): 439 # Remove --find-links lines 440 if line.startswith('--find-links'): 441 continue 442 # Remove blank lines 443 if line == '\n': 444 continue 445 final_output_text += line 446 447 output_file.write_text(final_output_text) 448 449 450def _update_upstream_python_constraints( 451 ctx: PresubmitContext, 452 update_files: bool = False, 453) -> None: 454 """Regenerate platform specific Python constraint files with hashes.""" 455 with TemporaryDirectory() as tmpdirname: 456 out_dir = Path(tmpdirname) 457 build.gn_gen( 458 ctx, 459 pw_build_PIP_REQUIREMENTS=[], 460 # Use the constraint file without hashes as the input. This is where 461 # new packages are added by developers. 462 pw_build_PIP_CONSTRAINTS=[ 463 '//pw_env_setup/py/pw_env_setup/virtualenv_setup/' 464 'constraint.list', 465 ], 466 # This should always be set to false when regenrating constraints. 467 pw_build_PYTHON_PIP_INSTALL_REQUIRE_HASHES=False, 468 ) 469 build.ninja(ctx, 'pip_constraint_update') 470 471 # Either: darwin, linux or windows 472 platform_name = platform.system().lower() 473 474 constraint_hashes_filename = f'constraint_hashes_{platform_name}.list' 475 constraint_hashes_original = ( 476 ctx.root 477 / 'pw_env_setup/py/pw_env_setup/virtualenv_setup' 478 / constraint_hashes_filename 479 ) 480 constraint_hashes_tmp_out = out_dir / constraint_hashes_filename 481 _generate_constraint_with_hashes( 482 ctx, 483 input_file=( 484 ctx.output_dir 485 / 'python/gen/pw_env_setup/pigweed_build_venv' 486 / 'compiled_requirements.txt' 487 ), 488 output_file=constraint_hashes_tmp_out, 489 ) 490 491 build.gn_gen( 492 ctx, 493 # This should always be set to false when regenrating constraints. 494 pw_build_PYTHON_PIP_INSTALL_REQUIRE_HASHES=False, 495 ) 496 build.ninja(ctx, 'pip_constraint_update') 497 498 upstream_requirements_lock_filename = ( 499 f'upstream_requirements_{platform_name}_lock.txt' 500 ) 501 upstream_requirements_lock_original = ( 502 ctx.root 503 / 'pw_env_setup/py/pw_env_setup/virtualenv_setup' 504 / upstream_requirements_lock_filename 505 ) 506 upstream_requirements_lock_tmp_out = ( 507 out_dir / upstream_requirements_lock_filename 508 ) 509 _generate_constraint_with_hashes( 510 ctx, 511 input_file=( 512 ctx.output_dir 513 / 'python/gen/pw_env_setup/pigweed_build_venv' 514 / 'compiled_requirements.txt' 515 ), 516 output_file=upstream_requirements_lock_tmp_out, 517 ) 518 519 if update_files: 520 constraint_hashes_original.write_text( 521 constraint_hashes_tmp_out.read_text() 522 ) 523 _LOG.info('Updated: %s', constraint_hashes_original) 524 upstream_requirements_lock_original.write_text( 525 upstream_requirements_lock_tmp_out.read_text() 526 ) 527 _LOG.info('Updated: %s', upstream_requirements_lock_original) 528 return 529 530 # Make a diff of required changes 531 constraint_hashes_diff = list( 532 difflib.unified_diff( 533 constraint_hashes_original.read_text( 534 'utf-8', errors='replace' 535 ).splitlines(), 536 constraint_hashes_tmp_out.read_text( 537 'utf-8', errors='replace' 538 ).splitlines(), 539 fromfile=str(constraint_hashes_original) + ' (original)', 540 tofile=str(constraint_hashes_original) + ' (updated)', 541 lineterm='', 542 n=1, 543 ) 544 ) 545 upstream_requirements_lock_diff = list( 546 difflib.unified_diff( 547 upstream_requirements_lock_original.read_text( 548 'utf-8', errors='replace' 549 ).splitlines(), 550 upstream_requirements_lock_tmp_out.read_text( 551 'utf-8', errors='replace' 552 ).splitlines(), 553 fromfile=str(upstream_requirements_lock_original) 554 + ' (original)', 555 tofile=str(upstream_requirements_lock_original) + ' (updated)', 556 lineterm='', 557 n=1, 558 ) 559 ) 560 if constraint_hashes_diff: 561 for line in constraint_hashes_diff: 562 print(colorize_diff_line(line)) 563 if upstream_requirements_lock_diff: 564 for line in upstream_requirements_lock_diff: 565 print(colorize_diff_line(line)) 566 if constraint_hashes_diff or upstream_requirements_lock_diff: 567 raise PresubmitFailure( 568 'Please run:\n' 569 '\n' 570 ' pw presubmit --step update_upstream_python_constraints' 571 ) 572 573 574@filter_paths(endswith=_PYTHON_PACKAGE_EXTENSIONS) 575def check_upstream_python_constraints(ctx: PresubmitContext) -> None: 576 _update_upstream_python_constraints(ctx, update_files=False) 577 578 579@filter_paths(endswith=_PYTHON_PACKAGE_EXTENSIONS) 580def update_upstream_python_constraints(ctx: PresubmitContext) -> None: 581 _update_upstream_python_constraints(ctx, update_files=True) 582 583 584@filter_paths(endswith=_PYTHON_EXTENSIONS + ('.pylintrc',)) 585def gn_python_lint(ctx: PresubmitContext) -> None: 586 build.gn_gen(ctx) 587 build.ninja(ctx, 'python.lint') 588 589 590@Check 591def check_python_versions(ctx: PresubmitContext): 592 """Checks that the list of installed packages is as expected.""" 593 594 build.gn_gen(ctx) 595 constraint_file: str | None = None 596 requirement_file: str | None = None 597 try: 598 for arg in build.get_gn_args(ctx.output_dir): 599 if arg['name'] == 'pw_build_PIP_CONSTRAINTS': 600 constraint_file = json.loads(arg['current']['value'])[0].strip( 601 '/' 602 ) 603 if arg['name'] == 'pw_build_PIP_REQUIREMENTS': 604 requirement_file = json.loads(arg['current']['value'])[0].strip( 605 '/' 606 ) 607 except json.JSONDecodeError: 608 _LOG.warning('failed to parse GN args json') 609 return 610 611 if not constraint_file: 612 _LOG.warning('could not find pw_build_PIP_CONSTRAINTS GN arg') 613 return 614 ignored_requirements_arg = None 615 if requirement_file: 616 ignored_requirements_arg = [(ctx.root / requirement_file)] 617 618 if ( 619 python_packages.diff( 620 expected=(ctx.root / constraint_file), 621 ignore_requirements_file=ignored_requirements_arg, 622 ) 623 != 0 624 ): 625 raise PresubmitFailure 626