Skip to content

Commit

Permalink
feat: remove global Black imports
Browse files Browse the repository at this point in the history
  • Loading branch information
akaihola committed Oct 18, 2024
1 parent 44ae494 commit 974ba53
Show file tree
Hide file tree
Showing 11 changed files with 342 additions and 261 deletions.
3 changes: 1 addition & 2 deletions src/darker/command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from functools import partial
from typing import List, Optional, Tuple

from black import TargetVersion

import darkgraylib.command_line
from darker import help as hlp
from darker.config import (
Expand All @@ -15,6 +13,7 @@
DarkerConfig,
OutputMode,
)
from darker.configuration.target_version import TargetVersion
from darker.formatters import get_formatter_names
from darker.version import __version__
from darkgraylib.command_line import add_parser_argument
Expand Down
1 change: 1 addition & 0 deletions src/darker/configuration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Configuration and command line handling."""
19 changes: 19 additions & 0 deletions src/darker/configuration/target_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Data structures configuring Darker and formatter plugin behavior."""

from enum import Enum


class TargetVersion(Enum):
"""Python version numbers."""

PY33 = 3
PY34 = 4
PY35 = 5
PY36 = 6
PY37 = 7
PY38 = 8
PY39 = 9
PY310 = 10
PY311 = 11
PY312 = 12
PY313 = 13
149 changes: 107 additions & 42 deletions src/darker/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,9 @@

from __future__ import annotations

import inspect
from typing import TYPE_CHECKING, Collection

from black import (
DEFAULT_EXCLUDES,
DEFAULT_INCLUDES,
Report,
err,
find_user_pyproject_toml,
gen_python_files,
re_compile_maybe_verbose,
)
import re
from functools import lru_cache
from typing import TYPE_CHECKING, Collection, Iterable, Iterator, Optional, Pattern

from darkgraylib.files import find_project_root

Expand All @@ -25,22 +16,112 @@

def find_pyproject_toml(path_search_start: tuple[str, ...]) -> str | None:
"""Find the absolute filepath to a pyproject.toml if it exists"""

path_project_root = find_project_root(path_search_start)
path_pyproject_toml = path_project_root / "pyproject.toml"
if path_pyproject_toml.is_file():
return str(path_pyproject_toml)
return None


DEFAULT_EXCLUDE_RE = re.compile(
r"/(\.direnv"
r"|\.eggs"
r"|\.git"
r"|\.hg"
r"|\.ipynb_checkpoints"
r"|\.mypy_cache"
r"|\.nox"
r"|\.pytest_cache"
r"|\.ruff_cache"
r"|\.tox"
r"|\.svn"
r"|\.venv"
r"|\.vscode"
r"|__pypackages__"
r"|_build"
r"|buck-out"
r"|build"
r"|dist"
r"|venv)/"
)
DEFAULT_INCLUDE_RE = re.compile(r"(\.pyi?|\.ipynb)$")


@lru_cache
def _cached_resolve(path: Path) -> Path:
return path.resolve()


def _resolves_outside_root_or_cannot_stat(path: Path, root: Path) -> bool:
"""Return whether path is a symlink that points outside the root directory.
Also returns True if we failed to resolve the path.
This function has been adapted from Black 24.10.0.
"""
try:
path_user_pyproject_toml = find_user_pyproject_toml()
return (
str(path_user_pyproject_toml)
if path_user_pyproject_toml.is_file()
else None
)
except (PermissionError, RuntimeError) as e:
# We do not have access to the user-level config directory, so ignore it.
err(f"Ignoring user configuration directory due to {e!r}")
return None
resolved_path = _cached_resolve(path)
except OSError:
return True
try:
resolved_path.relative_to(root)
except ValueError:
return True
return False


def _path_is_excluded(
normalized_path: str,
pattern: Optional[Pattern[str]],
) -> bool:
"""Return whether the path is excluded by the pattern.
This function has been adapted from Black 24.10.0.
"""
match = pattern.search(normalized_path) if pattern else None
return bool(match and match.group(0))


def _gen_python_files(
paths: Iterable[Path],
root: Path,
exclude: Pattern[str],
extend_exclude: Optional[Pattern[str]],
force_exclude: Optional[Pattern[str]],
) -> Iterator[Path]:
"""Generate all files under ``path`` whose paths are not excluded.
This function has been adapted from Black 24.10.0.
"""
assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
for child in paths:
assert child.is_absolute()
root_relative_path = child.relative_to(root).as_posix()

# Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
root_relative_path = f"/{root_relative_path}"
if child.is_dir():
root_relative_path = f"{root_relative_path}/"

if any(
_path_is_excluded(root_relative_path, x)
for x in [exclude, extend_exclude, force_exclude]
) or _resolves_outside_root_or_cannot_stat(child, root):
continue

if child.is_dir():
yield from _gen_python_files(
child.iterdir(), root, exclude, extend_exclude, force_exclude
)

elif child.is_file():
include_match = DEFAULT_INCLUDE_RE.search(root_relative_path)
if include_match:
yield child


def filter_python_files(
Expand All @@ -58,32 +139,16 @@ def filter_python_files(
``black_config``, relative to ``root``.
"""
sig = inspect.signature(gen_python_files)
# those two exist and are required in black>=21.7b1.dev9
kwargs = {"verbose": False, "quiet": False} if "verbose" in sig.parameters else {}
# `gitignore=` was replaced with `gitignore_dict=` in black==22.10.1.dev19+gffaaf48
for param in sig.parameters:
if param == "gitignore":
kwargs[param] = None # type: ignore[assignment]
elif param == "gitignore_dict":
kwargs[param] = {} # type: ignore[assignment]
absolute_paths = {p.resolve() for p in paths}
directories = {p for p in absolute_paths if p.is_dir()}
files = {p for p in absolute_paths if p not in directories}
files_from_directories = set(
gen_python_files(
_gen_python_files(
directories,
root,
include=DEFAULT_INCLUDE_RE,
exclude=formatter.get_exclude(DEFAULT_EXCLUDE_RE),
extend_exclude=formatter.get_extend_exclude(),
force_exclude=formatter.get_force_exclude(),
report=Report(),
**kwargs, # type: ignore[arg-type]
formatter.get_exclude(DEFAULT_EXCLUDE_RE),
formatter.get_extend_exclude(),
formatter.get_force_exclude(),
)
)
return {p.resolve().relative_to(root) for p in files_from_directories | files}


DEFAULT_EXCLUDE_RE = re_compile_maybe_verbose(DEFAULT_EXCLUDES)
DEFAULT_INCLUDE_RE = re_compile_maybe_verbose(DEFAULT_INCLUDES)
23 changes: 14 additions & 9 deletions src/darker/formatters/black_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,6 @@
import logging
from typing import TYPE_CHECKING, TypedDict

from black import FileMode as Mode
from black import (
TargetVersion,
format_str,
parse_pyproject_toml,
re_compile_maybe_verbose,
)

from darker.files import find_pyproject_toml
from darker.formatters.base_formatter import BaseFormatter
from darkgraylib.config import ConfigurationError
Expand All @@ -56,9 +48,11 @@
from argparse import Namespace
from typing import Pattern

from black import FileMode as Mode
from black import TargetVersion

from darker.formatters.formatter_config import BlackConfig

__all__ = ["Mode"]

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -96,6 +90,9 @@ def read_config(self, src: tuple[str, ...], args: Namespace) -> None:
self._read_cli_args(args)

def _read_config_file(self, config_path: str) -> None: # noqa: C901
# Local import so Darker can be run without Black installed
from black import parse_pyproject_toml, re_compile_maybe_verbose

Check failure on line 94 in src/darker/formatters/black_formatter.py

View workflow job for this annotation

GitHub Actions / Pylint

src/darker/formatters/black_formatter.py#L94

Import outside toplevel (black.parse_pyproject_toml, black.re_compile_maybe_verbose) (import-outside-toplevel, C0415)

raw_config = parse_pyproject_toml(config_path)
if "line_length" in raw_config:
self.config["line_length"] = raw_config["line_length"]
Expand Down Expand Up @@ -153,6 +150,9 @@ def run(self, content: TextDocument) -> TextDocument:
:return: The reformatted content
"""
# Local import so Darker can be run without Black installed
from black import format_str

Check failure on line 154 in src/darker/formatters/black_formatter.py

View workflow job for this annotation

GitHub Actions / Pylint

src/darker/formatters/black_formatter.py#L154

Import outside toplevel (black.format_str) (import-outside-toplevel, C0415)

contents_for_black = content.string_with_newline("\n")
if contents_for_black.strip():
dst_contents = format_str(
Expand All @@ -173,6 +173,11 @@ def _make_black_options(self) -> Mode:
# Collect relevant Black configuration options from ``self.config`` in order to
# pass them to Black's ``format_str()``. File exclusion options aren't needed
# since at this point we already have a single file's content to work on.

# Local import so Darker can be run without Black installed
from black import FileMode as Mode

Check failure on line 178 in src/darker/formatters/black_formatter.py

View workflow job for this annotation

GitHub Actions / Pylint

src/darker/formatters/black_formatter.py#L178

Import outside toplevel (black.FileMode) (import-outside-toplevel, C0415)
from black import TargetVersion

Check failure on line 179 in src/darker/formatters/black_formatter.py

View workflow job for this annotation

GitHub Actions / Pylint

src/darker/formatters/black_formatter.py#L179

Import outside toplevel (black.TargetVersion) (import-outside-toplevel, C0415)

mode = BlackModeAttributes()
if "line_length" in self.config:
mode["line_length"] = self.config["line_length"]
Expand Down
3 changes: 1 addition & 2 deletions src/darker/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

from textwrap import dedent

from black import TargetVersion

from darker.configuration.target_version import TargetVersion
from darker.formatters import get_formatter_names


Expand Down
15 changes: 7 additions & 8 deletions src/darker/tests/test_command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@

import pytest
import toml
from black import TargetVersion
from black import FileMode, TargetVersion

import darker.help
from darker.__main__ import main
from darker.command_line import make_argument_parser, parse_command_line
from darker.config import Exclusions
from darker.formatters import black_formatter
from darker.formatters.black_formatter import BlackFormatter
from darker.tests.helpers import flynt_present, isort_present
from darkgraylib.config import ConfigurationError
Expand Down Expand Up @@ -546,9 +545,8 @@ def test_black_options(monkeypatch, tmpdir, git_repo, options, expect):
{"main.py": 'print("Hello World!")\n'}, commit="Initial commit"
)
added_files["main.py"].write_bytes(b'print ("Hello World!")\n')
with patch.object(
black_formatter, "Mode", wraps=black_formatter.Mode
) as file_mode_class:
with patch("black.FileMode", wraps=FileMode) as file_mode_class:
# end of test setup, now call the function under test

main(options + [str(path) for path in added_files.values()])

Expand Down Expand Up @@ -661,11 +659,12 @@ def test_black_config_file_and_options(git_repo, config, options, expect):
commit="Initial commit",
)
added_files["main.py"].write_bytes(b"a = [1, 2,]")
mode_class_mock = Mock(wraps=black_formatter.Mode)
mode_class_mock = Mock(wraps=FileMode)
# Speed up tests by mocking `format_str` to skip running Black
format_str = Mock(return_value="a = [1, 2,]")
with patch.multiple(black_formatter, Mode=mode_class_mock, format_str=format_str):

with patch("black.FileMode", mode_class_mock), patch(
"black.format_str", format_str
):
main(options + [str(path) for path in added_files.values()])

assert mode_class_mock.call_args_list == [expect]
Expand Down
35 changes: 23 additions & 12 deletions src/darker/tests/test_files.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
"""Test for the `darker.files` module."""

import io
from contextlib import redirect_stderr
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from darker import files


@patch("darker.files.find_user_pyproject_toml")
def test_find_pyproject_toml(find_user_pyproject_toml: MagicMock) -> None:
@pytest.mark.kwparametrize(
dict(start="only_pyproject/subdir", expect="only_pyproject/pyproject.toml"),

Check failure on line 11 in src/darker/tests/test_files.py

View workflow job for this annotation

GitHub Actions / Pylint

src/darker/tests/test_files.py#L11

Consider using '{"start": 'only_pyproject/subdir', "expect": 'only_pyproject/pyproject.toml', ... }' instead of a call to 'dict'. (use-dict-literal, R1735)
dict(start="only_git/subdir", expect=None),

Check failure on line 12 in src/darker/tests/test_files.py

View workflow job for this annotation

GitHub Actions / Pylint

src/darker/tests/test_files.py#L12

Consider using '{"start": 'only_git/subdir', "expect": None}' instead of a call to 'dict'. (use-dict-literal, R1735)
dict(start="git_and_pyproject/subdir", expect="git_and_pyproject/pyproject.toml"),

Check failure on line 13 in src/darker/tests/test_files.py

View workflow job for this annotation

GitHub Actions / Pylint

src/darker/tests/test_files.py#L13

Consider using '{"start": 'git_and_pyproject/subdir', "expect": 'git_and_pyproject/pyproject.toml', ... }' instead of a call to 'dict'. (use-dict-literal, R1735)
)
def test_find_pyproject_toml(tmp_path: Path, start: str, expect: str) -> None:
"""Test `files.find_pyproject_toml` with no user home directory."""
find_user_pyproject_toml.side_effect = RuntimeError()
with redirect_stderr(io.StringIO()) as stderr:
# end of test setup
(tmp_path / "only_pyproject").mkdir()
(tmp_path / "only_pyproject" / "pyproject.toml").touch()
(tmp_path / "only_pyproject" / "subdir").mkdir()
(tmp_path / "only_git").mkdir()
(tmp_path / "only_git" / ".git").mkdir()
(tmp_path / "only_git" / "subdir").mkdir()
(tmp_path / "git_and_pyproject").mkdir()
(tmp_path / "git_and_pyproject" / ".git").mkdir()
(tmp_path / "git_and_pyproject" / "pyproject.toml").touch()
(tmp_path / "git_and_pyproject" / "subdir").mkdir()

result = files.find_pyproject_toml(path_search_start=(str(Path.cwd().root),))
result = files.find_pyproject_toml(path_search_start=(str(tmp_path / start),))

assert result is None
err = stderr.getvalue()
assert "Ignoring user configuration" in err
if not expect:
assert result is None
else:
assert result == str(tmp_path / expect)
Loading

0 comments on commit 974ba53

Please sign in to comment.