• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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