• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

psf / black / 7692220850

29 Jan 2024 06:46AM UTC coverage: 96.45%. Remained the same
7692220850

Pull #4192

github

web-flow
Bump peter-evans/create-or-update-comment from 3.1.0 to 4.0.0

Bumps [peter-evans/create-or-update-comment](https://github.com/peter-evans/create-or-update-comment) from 3.1.0 to 4.0.0.
- [Release notes](https://github.com/peter-evans/create-or-update-comment/releases)
- [Commits](https://github.com/peter-evans/create-or-update-comment/compare/23ff15729...71345be02)

---
updated-dependencies:
- dependency-name: peter-evans/create-or-update-comment
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Pull Request #4192: Bump peter-evans/create-or-update-comment from 3.1.0 to 4.0.0

3021 of 3232 branches covered (0.0%)

7145 of 7408 relevant lines covered (96.45%)

4.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.93
/src/black/files.py
1
import io
5✔
2
import os
5✔
3
import sys
5✔
4
from functools import lru_cache
5✔
5
from pathlib import Path
5✔
6
from typing import (
5✔
7
    TYPE_CHECKING,
8
    Any,
9
    Dict,
10
    Iterable,
11
    Iterator,
12
    List,
13
    Optional,
14
    Pattern,
15
    Sequence,
16
    Tuple,
17
    Union,
18
)
19

20
from mypy_extensions import mypyc_attr
5✔
21
from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet
5✔
22
from packaging.version import InvalidVersion, Version
5✔
23
from pathspec import PathSpec
5✔
24
from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
5✔
25

26
if sys.version_info >= (3, 11):
5✔
27
    try:
2✔
28
        import tomllib
2✔
29
    except ImportError:
×
30
        # Help users on older alphas
31
        if not TYPE_CHECKING:
×
32
            import tomli as tomllib
×
33
else:
34
    import tomli as tomllib
3✔
35

36
from black.handle_ipynb_magics import jupyter_dependencies_are_installed
5✔
37
from black.mode import TargetVersion
5✔
38
from black.output import err
5✔
39
from black.report import Report
5✔
40

41
if TYPE_CHECKING:
5!
42
    import colorama  # noqa: F401
×
43

44

45
@lru_cache
5✔
46
def find_project_root(
5✔
47
    srcs: Sequence[str], stdin_filename: Optional[str] = None
48
) -> Tuple[Path, str]:
49
    """Return a directory containing .git, .hg, or pyproject.toml.
50

51
    That directory will be a common parent of all files and directories
52
    passed in `srcs`.
53

54
    If no directory in the tree contains a marker that would specify it's the
55
    project root, the root of the file system is returned.
56

57
    Returns a two-tuple with the first element as the project root path and
58
    the second element as a string describing the method by which the
59
    project root was discovered.
60
    """
61
    if stdin_filename is not None:
5✔
62
        srcs = tuple(stdin_filename if s == "-" else s for s in srcs)
5✔
63
    if not srcs:
5✔
64
        srcs = [str(Path.cwd().resolve())]
5✔
65

66
    path_srcs = [Path(Path.cwd(), src).resolve() for src in srcs]
5✔
67

68
    # A list of lists of parents for each 'src'. 'src' is included as a
69
    # "parent" of itself if it is a directory
70
    src_parents = [
5✔
71
        list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs
72
    ]
73

74
    common_base = max(
5✔
75
        set.intersection(*(set(parents) for parents in src_parents)),
76
        key=lambda path: path.parts,
77
    )
78

79
    for directory in (common_base, *common_base.parents):
5✔
80
        if (directory / ".git").exists():
5✔
81
            return directory, ".git directory"
5✔
82

83
        if (directory / ".hg").is_dir():
5!
84
            return directory, ".hg directory"
×
85

86
        if (directory / "pyproject.toml").is_file():
5✔
87
            return directory, "pyproject.toml"
5✔
88

89
    return directory, "file system root"
5✔
90

91

92
def find_pyproject_toml(
5✔
93
    path_search_start: Tuple[str, ...], stdin_filename: Optional[str] = None
94
) -> Optional[str]:
95
    """Find the absolute filepath to a pyproject.toml if it exists"""
96
    path_project_root, _ = find_project_root(path_search_start, stdin_filename)
5✔
97
    path_pyproject_toml = path_project_root / "pyproject.toml"
5✔
98
    if path_pyproject_toml.is_file():
5✔
99
        return str(path_pyproject_toml)
5✔
100

101
    try:
5✔
102
        path_user_pyproject_toml = find_user_pyproject_toml()
5✔
103
        return (
5✔
104
            str(path_user_pyproject_toml)
105
            if path_user_pyproject_toml.is_file()
106
            else None
107
        )
108
    except (PermissionError, RuntimeError) as e:
5✔
109
        # We do not have access to the user-level config directory, so ignore it.
110
        err(f"Ignoring user configuration directory due to {e!r}")
5✔
111
        return None
5✔
112

113

114
@mypyc_attr(patchable=True)
5✔
115
def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
5✔
116
    """Parse a pyproject toml file, pulling out relevant parts for Black.
117

118
    If parsing fails, will raise a tomllib.TOMLDecodeError.
119
    """
120
    with open(path_config, "rb") as f:
5✔
121
        pyproject_toml = tomllib.load(f)
5✔
122
    config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {})
5✔
123
    config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
5✔
124

125
    if "target_version" not in config:
5✔
126
        inferred_target_version = infer_target_version(pyproject_toml)
5✔
127
        if inferred_target_version is not None:
5✔
128
            config["target_version"] = [v.name.lower() for v in inferred_target_version]
5✔
129

130
    return config
5✔
131

132

133
def infer_target_version(
5✔
134
    pyproject_toml: Dict[str, Any],
135
) -> Optional[List[TargetVersion]]:
136
    """Infer Black's target version from the project metadata in pyproject.toml.
137

138
    Supports the PyPA standard format (PEP 621):
139
    https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
140

141
    If the target version cannot be inferred, returns None.
142
    """
143
    project_metadata = pyproject_toml.get("project", {})
5✔
144
    requires_python = project_metadata.get("requires-python", None)
5✔
145
    if requires_python is not None:
5✔
146
        try:
5✔
147
            return parse_req_python_version(requires_python)
5✔
148
        except InvalidVersion:
5✔
149
            pass
5✔
150
        try:
5✔
151
            return parse_req_python_specifier(requires_python)
5✔
152
        except (InvalidSpecifier, InvalidVersion):
5✔
153
            pass
5✔
154

155
    return None
5✔
156

157

158
def parse_req_python_version(requires_python: str) -> Optional[List[TargetVersion]]:
5✔
159
    """Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion.
160

161
    If parsing fails, will raise a packaging.version.InvalidVersion error.
162
    If the parsed version cannot be mapped to a valid TargetVersion, returns None.
163
    """
164
    version = Version(requires_python)
5✔
165
    if version.release[0] != 3:
5✔
166
        return None
5✔
167
    try:
5✔
168
        return [TargetVersion(version.release[1])]
5✔
169
    except (IndexError, ValueError):
5✔
170
        return None
5✔
171

172

173
def parse_req_python_specifier(requires_python: str) -> Optional[List[TargetVersion]]:
5✔
174
    """Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion.
175

176
    If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error.
177
    If the parsed specifier cannot be mapped to a valid TargetVersion, returns None.
178
    """
179
    specifier_set = strip_specifier_set(SpecifierSet(requires_python))
5✔
180
    if not specifier_set:
5✔
181
        return None
5✔
182

183
    target_version_map = {f"3.{v.value}": v for v in TargetVersion}
5✔
184
    compatible_versions: List[str] = list(specifier_set.filter(target_version_map))
5✔
185
    if compatible_versions:
5✔
186
        return [target_version_map[v] for v in compatible_versions]
5✔
187
    return None
5✔
188

189

190
def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet:
5✔
191
    """Strip minor versions for some specifiers in the specifier set.
192

193
    For background on version specifiers, see PEP 440:
194
    https://peps.python.org/pep-0440/#version-specifiers
195
    """
196
    specifiers = []
5✔
197
    for s in specifier_set:
5✔
198
        if "*" in str(s):
5✔
199
            specifiers.append(s)
5✔
200
        elif s.operator in ["~=", "==", ">=", "==="]:
5✔
201
            version = Version(s.version)
5✔
202
            stripped = Specifier(f"{s.operator}{version.major}.{version.minor}")
5✔
203
            specifiers.append(stripped)
5✔
204
        elif s.operator == ">":
5✔
205
            version = Version(s.version)
5✔
206
            if len(version.release) > 2:
5✔
207
                s = Specifier(f">={version.major}.{version.minor}")
5✔
208
            specifiers.append(s)
5✔
209
        else:
210
            specifiers.append(s)
5✔
211

212
    return SpecifierSet(",".join(str(s) for s in specifiers))
5✔
213

214

215
@lru_cache
5✔
216
def find_user_pyproject_toml() -> Path:
5✔
217
    r"""Return the path to the top-level user configuration for black.
218

219
    This looks for ~\.black on Windows and ~/.config/black on Linux and other
220
    Unix systems.
221

222
    May raise:
223
    - RuntimeError: if the current user has no homedir
224
    - PermissionError: if the current process cannot access the user's homedir
225
    """
226
    if sys.platform == "win32":
5!
227
        # Windows
228
        user_config_path = Path.home() / ".black"
×
229
    else:
230
        config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config")
5✔
231
        user_config_path = Path(config_root).expanduser() / "black"
5✔
232
    return user_config_path.resolve()
5✔
233

234

235
@lru_cache
5✔
236
def get_gitignore(root: Path) -> PathSpec:
5✔
237
    """Return a PathSpec matching gitignore content if present."""
238
    gitignore = root / ".gitignore"
5✔
239
    lines: List[str] = []
5✔
240
    if gitignore.is_file():
5✔
241
        with gitignore.open(encoding="utf-8") as gf:
5✔
242
            lines = gf.readlines()
5✔
243
    try:
5✔
244
        return PathSpec.from_lines("gitwildmatch", lines)
5✔
245
    except GitWildMatchPatternError as e:
5✔
246
        err(f"Could not parse {gitignore}: {e}")
5✔
247
        raise
5✔
248

249

250
def normalize_path_maybe_ignore(
5✔
251
    path: Path,
252
    root: Path,
253
    report: Optional[Report] = None,
254
) -> Optional[str]:
255
    """Normalize `path`. May return `None` if `path` was ignored.
256

257
    `report` is where "path ignored" output goes.
258
    """
259
    try:
5✔
260
        abspath = path if path.is_absolute() else Path.cwd() / path
5✔
261
        normalized_path = abspath.resolve()
5✔
262
        root_relative_path = get_root_relative_path(normalized_path, root, report)
5✔
263

264
    except OSError as e:
×
265
        if report:
×
266
            report.path_ignored(path, f"cannot be read because {e}")
×
267
        return None
×
268

269
    return root_relative_path
5✔
270

271

272
def get_root_relative_path(
5✔
273
    path: Path,
274
    root: Path,
275
    report: Optional[Report] = None,
276
) -> Optional[str]:
277
    """Returns the file path relative to the 'root' directory"""
278
    try:
5✔
279
        root_relative_path = path.absolute().relative_to(root).as_posix()
5✔
280
    except ValueError:
5✔
281
        if report:
5!
282
            report.path_ignored(path, f"is a symbolic link that points outside {root}")
5✔
283
        return None
5✔
284
    return root_relative_path
5✔
285

286

287
def _path_is_ignored(
5✔
288
    root_relative_path: str,
289
    root: Path,
290
    gitignore_dict: Dict[Path, PathSpec],
291
) -> bool:
292
    path = root / root_relative_path
5✔
293
    # Note that this logic is sensitive to the ordering of gitignore_dict. Callers must
294
    # ensure that gitignore_dict is ordered from least specific to most specific.
295
    for gitignore_path, pattern in gitignore_dict.items():
5✔
296
        try:
5✔
297
            relative_path = path.relative_to(gitignore_path).as_posix()
5✔
298
        except ValueError:
5✔
299
            break
5✔
300
        if pattern.match_file(relative_path):
5✔
301
            return True
5✔
302
    return False
5✔
303

304

305
def path_is_excluded(
5✔
306
    normalized_path: str,
307
    pattern: Optional[Pattern[str]],
308
) -> bool:
309
    match = pattern.search(normalized_path) if pattern else None
5✔
310
    return bool(match and match.group(0))
5✔
311

312

313
def gen_python_files(
5✔
314
    paths: Iterable[Path],
315
    root: Path,
316
    include: Pattern[str],
317
    exclude: Pattern[str],
318
    extend_exclude: Optional[Pattern[str]],
319
    force_exclude: Optional[Pattern[str]],
320
    report: Report,
321
    gitignore_dict: Optional[Dict[Path, PathSpec]],
322
    *,
323
    verbose: bool,
324
    quiet: bool,
325
) -> Iterator[Path]:
326
    """Generate all files under `path` whose paths are not excluded by the
327
    `exclude_regex`, `extend_exclude`, or `force_exclude` regexes,
328
    but are included by the `include` regex.
329

330
    Symbolic links pointing outside of the `root` directory are ignored.
331

332
    `report` is where output about exclusions goes.
333
    """
334

335
    assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
5✔
336
    for child in paths:
5✔
337
        root_relative_path = child.absolute().relative_to(root).as_posix()
5✔
338

339
        # First ignore files matching .gitignore, if passed
340
        if gitignore_dict and _path_is_ignored(
5✔
341
            root_relative_path, root, gitignore_dict
342
        ):
343
            report.path_ignored(child, "matches a .gitignore file content")
5✔
344
            continue
5✔
345

346
        # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
347
        root_relative_path = "/" + root_relative_path
5✔
348
        if child.is_dir():
5✔
349
            root_relative_path += "/"
5✔
350

351
        if path_is_excluded(root_relative_path, exclude):
5✔
352
            report.path_ignored(child, "matches the --exclude regular expression")
5✔
353
            continue
5✔
354

355
        if path_is_excluded(root_relative_path, extend_exclude):
5✔
356
            report.path_ignored(
5✔
357
                child, "matches the --extend-exclude regular expression"
358
            )
359
            continue
5✔
360

361
        if path_is_excluded(root_relative_path, force_exclude):
5!
362
            report.path_ignored(child, "matches the --force-exclude regular expression")
×
363
            continue
×
364

365
        normalized_path = normalize_path_maybe_ignore(child, root, report)
5✔
366
        if normalized_path is None:
5✔
367
            continue
5✔
368

369
        if child.is_dir():
5✔
370
            # If gitignore is None, gitignore usage is disabled, while a Falsey
371
            # gitignore is when the directory doesn't have a .gitignore file.
372
            if gitignore_dict is not None:
5✔
373
                new_gitignore_dict = {
5✔
374
                    **gitignore_dict,
375
                    root / child: get_gitignore(child),
376
                }
377
            else:
378
                new_gitignore_dict = None
5✔
379
            yield from gen_python_files(
5✔
380
                child.iterdir(),
381
                root,
382
                include,
383
                exclude,
384
                extend_exclude,
385
                force_exclude,
386
                report,
387
                new_gitignore_dict,
388
                verbose=verbose,
389
                quiet=quiet,
390
            )
391

392
        elif child.is_file():
5✔
393
            if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed(
5✔
394
                warn=verbose or not quiet
395
            ):
396
                continue
5✔
397
            include_match = include.search(root_relative_path) if include else True
5✔
398
            if include_match:
5✔
399
                yield child
5✔
400

401

402
def wrap_stream_for_windows(
5✔
403
    f: io.TextIOWrapper,
404
) -> Union[io.TextIOWrapper, "colorama.AnsiToWin32"]:
405
    """
406
    Wrap stream with colorama's wrap_stream so colors are shown on Windows.
407

408
    If `colorama` is unavailable, the original stream is returned unmodified.
409
    Otherwise, the `wrap_stream()` function determines whether the stream needs
410
    to be wrapped for a Windows environment and will accordingly either return
411
    an `AnsiToWin32` wrapper or the original stream.
412
    """
413
    try:
5✔
414
        from colorama.initialise import wrap_stream
5✔
415
    except ImportError:
×
416
        return f
×
417
    else:
418
        # Set `strip=False` to avoid needing to modify test_express_diff_with_color.
419
        return wrap_stream(f, convert=None, strip=False, autoreset=False, wrap=True)
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc