xref: /aosp_15_r20/external/pigweed/pw_presubmit/py/pw_presubmit/python_checks.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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