Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
417 changes: 205 additions & 212 deletions patches/pyink.patch

Large diffs are not rendered by default.

123 changes: 90 additions & 33 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Yes, we use the _Black_ style to format _Pyink_ code.
pyink = false
line-length = 88
target-version = ['py310']
target-version = ["py310"]
include = '\.pyi?$'
extend-exclude = 'tests/data'
unstable = true
Expand All @@ -20,40 +20,73 @@ requires-python = ">=3.10"
readme = "README.md"
authors = [{name = "The Pyink Maintainers", email = "pyink-maintainers@google.com"}]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Quality Assurance",
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Quality Assurance",
]
dependencies = [
"click>=8.0.0",
"mypy_extensions>=0.4.3",
"packaging>=22.0",
"pathspec>=0.9.0,<1.0.0",
"platformdirs>=2",
"pytokens>=0.3.0",
"tomli>=1.1.0; python_version < '3.11'",
"typing_extensions>=4.0.1; python_version < '3.11'",
"black==25.12.0",
"click>=8.0.0",
"mypy-extensions>=0.4.3",
"packaging>=22.0",
"pathspec>=1.0.0",
"platformdirs>=2",
"pytokens~=0.4.0",
"tomli>=1.1.0; python_version<'3.11'",
"typing-extensions>=4.0.1; python_version<'3.11'",
"black==26.3.1",
]
dynamic = ["version"]

[project.optional-dependencies]
colorama = ["colorama>=0.4.3"]
uvloop = ["uvloop>=0.15.2"]
jupyter = [
"ipython>=7.8.0",
"tokenize-rt>=3.2.0",
uvloop = [
"uvloop>=0.15.2; sys_platform != 'win32'",
"winloop>=0.5.0; sys_platform == 'win32'"
]
d = ["aiohttp>=3.10"]
jupyter = ["ipython>=7.8.0", "tokenize-rt>=3.2.0"]

[dependency-groups]
build = ["hatch==1.15.1", "hatch-fancy-pypi-readme", "hatch-vcs>=0.3.0", "virtualenv<21.0.0"]
wheels = ["cibuildwheel==3.3.1", "pypyp"]
binary = ["pyinstaller", "wheel>=0.45.1"]

dev = [{ include-group = "cov-tests" }, { include-group = "tox" }, "pre-commit"]
cov-tests = [
{ include-group = "coverage" },
{ include-group = "tests" },
"pytest-cov>=4.1.0",
]
docs = [
"docutils==0.21.2",
"furo==2025.12.19",
"myst-parser==4.0.1",
"sphinx-copybutton==0.5.2",
"sphinx==8.2.3",
"sphinxcontrib-programoutput==0.19",
]

tox = ["tox>=4.22"]
tests = ["pytest>=7", "pytest-xdist>=3.0.2"]
coverage = ["coverage>=5.3"]

fuzz = [{ include-group = "coverage" }, "hypothesis", "hypothesmith"]
diff-shades = [
"diff-shades @ https://github.com/ichard26/diff-shades/archive/stable.zip",
]
diff-shades-comment = ["click>=8.1.7", "packaging>=22.0", "urllib3"]

width-table = ["wcwidth==0.2.14"]

[project.scripts]
pyink = "pyink:patched_main"
Expand All @@ -71,9 +104,9 @@ source = "vcs"

[tool.hatch.build.hooks.vcs]
version-file = "src/_pyink_version.py"
template = '''
template = """
version = "{version}"
'''
"""

[tool.hatch.build.targets.wheel]
only-include = ["src"]
Expand All @@ -85,10 +118,34 @@ macos-max-compat = true
# Option below requires `tests/optional.py`
addopts = "--strict-config --strict-markers"
optional-tests = [
"no_jupyter: run when `jupyter` extra NOT installed",
]
markers = [
"incompatible_with_mypyc: run when testing mypyc compiled black"
"no_jupyter: run when `jupyter` extra NOT installed",
]
markers = ["incompatible_with_mypyc: run when testing mypyc compiled black"]
xfail_strict = true
filterwarnings = ["error"]


[tool.mypy]
# Specify the target platform details in config, so your developers are
# free to run mypy on Windows, Linux, or macOS and get consistent
# results.
python_version = "3.10"
mypy_path = "src"
strict = true
strict_bytes = true
local_partial_types = true
# Unreachable blocks have been an issue when compiling mypyc, let's try to avoid 'em in the first place.
warn_unreachable = true
implicit_reexport = true
show_error_codes = true
show_column_numbers = true

[[tool.mypy.overrides]]
module = ["pathspec.*", "IPython.*", "colorama.*", "tokenize_rt.*", "uvloop.*"]
ignore_missing_imports = true

# CI only checks src/, but in case users are running LSP or similar we explicitly ignore
# errors in test data files.
[[tool.mypy.overrides]]
module = ["tests.data.*"]
ignore_errors = true
139 changes: 82 additions & 57 deletions src/pyink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
import click
from click.core import ParameterSource
from mypy_extensions import mypyc_attr
from pathspec import PathSpec
from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
from pathspec import GitIgnoreSpec
from pathspec.patterns.gitignore import GitIgnorePatternError

from _pyink_version import version as __version__
from pyink.cache import Cache
Expand Down Expand Up @@ -209,6 +209,27 @@ def target_version_option_callback(
return [TargetVersion[val.upper()] for val in v]


def _target_versions_exceed_runtime(
target_versions: set[TargetVersion],
) -> bool:
if not target_versions:
return False
max_target_minor = max(tv.value for tv in target_versions)
return max_target_minor > sys.version_info[1]


def _version_mismatch_message(target_versions: set[TargetVersion]) -> str:
max_target = max(target_versions, key=lambda tv: tv.value)
runtime = f"{sys.version_info[0]}.{sys.version_info[1]}"
return (
f"Python {runtime} cannot parse code formatted for"
f" {max_target.pretty()}. To fix this: run Black with"
f" {max_target.pretty()}, set --target-version to"
f" py3{sys.version_info[1]}, or use --fast to skip the safety"
" check."
)


def enable_unstable_feature_callback(
c: click.Context, p: click.Option | click.Parameter, v: tuple[str, ...]
) -> list[Preview]:
Expand Down Expand Up @@ -719,6 +740,14 @@ def main(
),
)

if not fast and _target_versions_exceed_runtime(versions):
err(
f"Warning: {_version_mismatch_message(versions)} Black's safety"
" check verifies equivalence by parsing the AST, which fails"
" when the running Python is older than the target version.",
fg="yellow",
)

lines: list[tuple[int, int]] = []
if line_ranges:
if ipynb:
Expand Down Expand Up @@ -762,7 +791,7 @@ def main(
report=report,
stdin_filename=stdin_filename,
)
except GitWildMatchPatternError:
except GitIgnorePatternError:
ctx.exit(1)

if not sources:
Expand Down Expand Up @@ -826,7 +855,7 @@ def get_sources(
assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
using_default_exclude = exclude is None
exclude = re_compile_maybe_verbose(DEFAULT_EXCLUDES) if exclude is None else exclude
gitignore: dict[Path, PathSpec] | None = None
gitignore: dict[Path, GitIgnoreSpec] | None = None
root_gitignore = get_gitignore(root)

for s in src:
Expand Down Expand Up @@ -1087,10 +1116,8 @@ def format_stdin_to_stdout(

if content is None:
src, encoding, newline = decode_bytes(sys.stdin.buffer.read(), mode)
elif Preview.normalize_cr_newlines in mode:
src, encoding, newline = content, "utf-8", "\n"
else:
src, encoding, newline = content, "utf-8", ""
src, encoding, newline = content, "utf-8", "\n"

dst = src
try:
Expand All @@ -1106,12 +1133,8 @@ def format_stdin_to_stdout(
)
if write_back == WriteBack.YES:
# Make sure there's a newline after the content
if Preview.normalize_cr_newlines in mode:
if dst and dst[-1] != "\n" and dst[-1] != "\r":
dst += newline
else:
if dst and dst[-1] != "\n":
dst += "\n"
if dst and dst[-1] != "\n" and dst[-1] != "\r":
dst += newline
f.write(dst)
elif write_back in (WriteBack.DIFF, WriteBack.COLOR_DIFF):
now = datetime.now(timezone.utc)
Expand All @@ -1138,7 +1161,15 @@ def check_stability_and_equivalence(
equivalent, or if a second pass of the formatter would format the
content differently.
"""
assert_equivalent(src_contents, dst_contents)
try:
assert_equivalent(src_contents, dst_contents)
except ASTSafetyError:
if _target_versions_exceed_runtime(mode.target_versions):
raise ASTSafetyError(
"failed to verify equivalence of the formatted output:"
f" {_version_mismatch_message(mode.target_versions)}"
) from None
raise
assert_stable(src_contents, dst_contents, mode=mode, lines=lines)


Expand Down Expand Up @@ -1314,16 +1345,15 @@ def f(
def _format_str_once(
src_contents: str, *, mode: Mode, lines: Collection[tuple[int, int]] = ()
) -> str:
if Preview.normalize_cr_newlines in mode:
normalized_contents, _, newline_type = decode_bytes(
src_contents.encode("utf-8"), mode
)
# Use the encoding overwrite since the src_contents may contain a different
# magic encoding comment than utf-8
normalized_contents, _, newline_type = decode_bytes(
src_contents.encode("utf-8"), mode, encoding_overwrite="utf-8"
)

src_node = lib2to3_parse(
normalized_contents.lstrip(), target_versions=mode.target_versions
)
else:
src_node = lib2to3_parse(src_contents.lstrip(), mode.target_versions)
src_node = lib2to3_parse(
normalized_contents.lstrip(), target_versions=mode.target_versions
)

dst_blocks: list[LinesBlock] = []
if mode.target_versions:
Expand Down Expand Up @@ -1372,53 +1402,48 @@ def _format_str_once(
for block in dst_blocks:
dst_contents.extend(block.all_lines())
if not dst_contents:
if Preview.normalize_cr_newlines in mode:
if "\n" in normalized_contents:
return newline_type
else:
# Use decode_bytes to retrieve the correct source newline (CRLF or LF),
# and check if normalized_content has more than one line
normalized_content, _, newline = decode_bytes(
src_contents.encode("utf-8"), mode
)
if "\n" in normalized_content:
return newline
return ""
if Preview.normalize_cr_newlines in mode:
return "".join(dst_contents).replace("\n", newline_type)
else:
return "".join(dst_contents)
if "\n" in normalized_contents:
return newline_type
return "".join(dst_contents).replace("\n", newline_type)


def decode_bytes(src: bytes, mode: Mode) -> tuple[FileContent, Encoding, NewLine]:
def decode_bytes(
src: bytes, mode: Mode, *, encoding_overwrite: str | None = None
) -> tuple[FileContent, Encoding, NewLine]:
"""Return a tuple of (decoded_contents, encoding, newline).

`newline` is either CRLF or LF but `decoded_contents` is decoded with
`newline` is either CRLF, LF, or CR, but `decoded_contents` is decoded with
universal newlines (i.e. only contains LF).

Use the keyword only encoding_overwrite argument if the bytes are encoded
differently to their possible encoding magic comment.
"""
srcbuf = io.BytesIO(src)

# Still use detect encoding even if overrite set because otherwise lines
# might be different
encoding, lines = tokenize.detect_encoding(srcbuf.readline)
if encoding_overwrite is not None:
encoding = encoding_overwrite

if not lines:
return "", encoding, "\n"

if Preview.normalize_cr_newlines in mode:
if lines[0][-2:] == b"\r\n":
if b"\r" in lines[0][:-2]:
newline = "\r"
else:
newline = "\r\n"
elif lines[0][-1:] == b"\n":
if b"\r" in lines[0][:-1]:
newline = "\r"
else:
newline = "\n"
if lines[0][-2:] == b"\r\n":
if b"\r" in lines[0][:-2]:
newline = "\r"
else:
if b"\r" in lines[0]:
newline = "\r"
else:
newline = "\n"
newline = "\r\n"
elif lines[0][-1:] == b"\n":
if b"\r" in lines[0][:-1]:
newline = "\r"
else:
newline = "\n"
else:
newline = "\r\n" if lines[0][-2:] == b"\r\n" else "\n"
if b"\r" in lines[0]:
newline = "\r"
else:
newline = "\n"

srcbuf.seek(0)
with io.TextIOWrapper(srcbuf, encoding) as tiow:
Expand Down
Loading
Loading