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"""General purpose tools for running presubmit checks.""" 15 16import collections.abc 17from collections import Counter, defaultdict 18import logging 19import os 20from pathlib import Path 21import shlex 22import subprocess 23from typing import ( 24 Any, 25 Dict, 26 Iterable, 27 Iterator, 28 List, 29 Optional, 30 Sequence, 31 Pattern, 32 Tuple, 33) 34 35_LOG: logging.Logger = logging.getLogger(__name__) 36 37 38def plural( 39 items_or_count, 40 singular: str, 41 count_format='', 42 these: bool = False, 43 number: bool = True, 44 are: bool = False, 45) -> str: 46 """Returns the singular or plural form of a word based on a count.""" 47 48 try: 49 count = len(items_or_count) 50 except TypeError: 51 count = items_or_count 52 53 prefix = ('this ' if count == 1 else 'these ') if these else '' 54 num = f'{count:{count_format}} ' if number else '' 55 suffix = (' is' if count == 1 else ' are') if are else '' 56 57 if singular.endswith('y'): 58 result = f'{singular[:-1]}{"y" if count == 1 else "ies"}' 59 elif singular.endswith('s'): 60 result = f'{singular}{"" if count == 1 else "es"}' 61 else: 62 result = f'{singular}{"" if count == 1 else "s"}' 63 64 return f'{prefix}{num}{result}{suffix}' 65 66 67def make_box(section_alignments: Sequence[str]) -> str: 68 indices = [i + 1 for i in range(len(section_alignments))] 69 top_sections = '{2}'.join('{1:{1}^{width%d}}' % i for i in indices) 70 mid_sections = '{5}'.join( 71 '{section%d:%s{width%d}}' % (i, section_alignments[i - 1], i) 72 for i in indices 73 ) 74 bot_sections = '{9}'.join('{8:{8}^{width%d}}' % i for i in indices) 75 76 return ''.join( 77 [ 78 '{0}', 79 *top_sections, 80 '{3}\n', 81 '{4}', 82 *mid_sections, 83 '{6}\n', 84 '{7}', 85 *bot_sections, 86 '{10}', 87 ] 88 ) 89 90 91def file_summary( 92 paths: Iterable[Path], 93 levels: int = 2, 94 max_lines: int = 12, 95 max_types: int = 3, 96 pad: str = ' ', 97 pad_start: str = ' ', 98 pad_end: str = ' ', 99) -> List[str]: 100 """Summarizes a list of files by the file types in each directory.""" 101 102 # Count the file types in each directory. 103 all_counts: Dict[Any, Counter] = defaultdict(Counter) 104 105 for path in paths: 106 parent = path.parents[max(len(path.parents) - levels, 0)] 107 all_counts[parent][path.suffix] += 1 108 109 # If there are too many lines, condense directories with the fewest files. 110 if len(all_counts) > max_lines: 111 counts = sorted( 112 all_counts.items(), key=lambda item: -sum(item[1].values()) 113 ) 114 counts, others = ( 115 sorted(counts[: max_lines - 1]), 116 counts[max_lines - 1 :], 117 ) 118 counts.append( 119 ( 120 f'({plural(others, "other")})', 121 sum((c for _, c in others), Counter()), 122 ) 123 ) 124 else: 125 counts = sorted(all_counts.items()) 126 127 width = max(len(str(d)) + len(os.sep) for d, _ in counts) if counts else 0 128 width += len(pad_start) 129 130 # Prepare the output. 131 output = [] 132 for path, files in counts: 133 total = sum(files.values()) 134 del files[''] # Never display no-extension files individually. 135 136 if files: 137 extensions = files.most_common(max_types) 138 other_extensions = total - sum(count for _, count in extensions) 139 if other_extensions: 140 extensions.append(('other', other_extensions)) 141 142 types = ' (' + ', '.join(f'{c} {e}' for e, c in extensions) + ')' 143 else: 144 types = '' 145 146 root = f'{path}{os.sep}{pad_start}'.ljust(width, pad) 147 output.append(f'{root}{pad_end}{plural(total, "file")}{types}') 148 149 return output 150 151 152def relative_paths(paths: Iterable[Path], start: Path) -> Iterable[Path]: 153 """Returns relative Paths calculated with os.path.relpath.""" 154 for path in paths: 155 yield Path(os.path.relpath(path, start)) 156 157 158def exclude_paths( 159 exclusions: Iterable[Pattern[str]], 160 paths: Iterable[Path], 161 relative_to: Optional[Path] = None, 162) -> Iterable[Path]: 163 """Excludes paths based on a series of regular expressions.""" 164 if relative_to: 165 relpath = lambda path: Path(os.path.relpath(path, relative_to)) 166 else: 167 relpath = lambda path: path 168 169 for path in paths: 170 if not any(e.search(relpath(path).as_posix()) for e in exclusions): 171 yield path 172 173 174def _truncate(value, length: int = 60) -> str: 175 value = str(value) 176 return (value[: length - 5] + '[...]') if len(value) > length else value 177 178 179def format_command(args: Sequence, kwargs: dict) -> Tuple[str, str]: 180 attr = ', '.join(f'{k}={_truncate(v)}' for k, v in sorted(kwargs.items())) 181 return attr, ' '.join(shlex.quote(str(arg)) for arg in args) 182 183 184def log_run(args, **kwargs) -> subprocess.CompletedProcess: 185 """Logs a command then runs it with subprocess.run. 186 187 Takes the same arguments as subprocess.run. 188 """ 189 _LOG.debug('[COMMAND] %s\n%s', *format_command(args, kwargs)) 190 return subprocess.run(args, **kwargs) 191 192 193def flatten(*items) -> Iterator: 194 """Yields items from a series of items and nested iterables. 195 196 This function is used to flatten arbitrarily nested lists. str and bytes 197 are kept intact. 198 """ 199 200 for item in items: 201 if isinstance(item, collections.abc.Iterable) and not isinstance( 202 item, (str, bytes, bytearray) 203 ): 204 yield from flatten(*item) 205 else: 206 yield item 207