Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,13 @@ jobs:

- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

# Signal that docker build completed successfully
docker-complete:
name: Docker build complete
needs: docker
runs-on: ubuntu-latest
if: github.event_name == 'release'
steps:
- name: Mark docker as done
run: echo "Docker images published successfully"
52 changes: 43 additions & 9 deletions .github/workflows/pypi_upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ jobs:
- name: Build wheel and source distributions
run: python -m build

- if: github.event_name == 'release'
name: Upload to PyPI via Twine
env:
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: twine upload --verbose -u '__token__' dist/*
# Upload the sdist and pure wheel as artifacts instead of pushing immediately
- name: Upload sdist and wheel as artifacts
uses: actions/upload-artifact@v5
with:
name: sdist-and-pure-wheel
path: dist/*

generate_wheels_matrix:
name: generate wheels matrix
Expand Down Expand Up @@ -105,15 +106,48 @@ jobs:
name: ${{ matrix.only }}-mypyc-wheels
path: ./wheelhouse/*.whl

- if: github.event_name == 'release'
name: Upload wheels to PyPI via Twine
# New job: publish everything to PyPI atomically
# NOTE: This waits for all wheels to build before pushing anything
# If any wheel fails, nothing gets published - keeps PyPI clean!
publish-to-pypi:
name: Publish everything to PyPI
needs: [main, mypyc]
runs-on: ubuntu-latest
if: github.event_name == 'release'
permissions:
contents: read

steps:
# Download all the wheels we built earlier
- name: Download all wheel artifacts
uses: actions/download-artifact@v5
with:
path: all-wheels/
pattern: "*-*"
merge-multiple: true

- name: Set up Python for publishing
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Install twine
run: python -m pip install --upgrade twine

# Quick check to see what we're about to upload
- name: List all artifacts
run: ls -lah all-wheels/

# Upload everything in one go - this is the atomic part!
- name: Publish all wheels to PyPI
env:
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: pipx run twine upload --verbose -u '__token__' wheelhouse/*.whl
run: |
twine upload --verbose -u '__token__' all-wheels/*

update-stable-branch:
name: Update stable branch
needs: [main, mypyc]
needs: [main, mypyc, publish-to-pypi]
runs-on: ubuntu-latest
if: github.event_name == 'release'
permissions:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/upload_binary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,12 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: dist/${{ matrix.asset_name }}

# Mark binaries as complete for release coordination
binaries-complete:
name: All binaries uploaded
needs: build
runs-on: ubuntu-latest
steps:
- name: Signal completion
run: echo "All platform binaries have been uploaded"
16 changes: 2 additions & 14 deletions src/black/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,6 @@ def read_pyproject_toml(
spellcheck_pyproject_toml_keys(ctx, list(config), value)
# Sanitize the values to be Click friendly. For more information please see:
# https://github.com/psf/black/issues/1458
# https://github.com/pallets/click/issues/1567
config = {
k: str(v) if not isinstance(v, (list, dict)) else v
for k, v in config.items()
}

target_version = config.get("target_version")
if target_version is not None and not isinstance(target_version, list):
raise click.BadOptionUsage(
"target-version", "Config key target-version must be a list"
)

exclude = config.get("exclude")
if exclude is not None and not isinstance(exclude, str):
raise click.BadOptionUsage("exclude", "Config key exclude must be a string")
Expand Down Expand Up @@ -856,7 +844,7 @@ def reformat_code(
except Exception as exc:
if report.verbose:
traceback.print_exc()
report.failed(path, str(exc))
report.failed(path, exc)


# diff-shades depends on being to monkeypatch this function to operate. I know it's
Expand Down Expand Up @@ -920,7 +908,7 @@ def reformat_one(
except Exception as exc:
if report.verbose:
traceback.print_exc()
report.failed(src, str(exc))
report.failed(src, exc)


def format_file_in_place(
Expand Down
26 changes: 24 additions & 2 deletions src/black/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
class InvalidInput(ValueError):
"""Raised when input source code fails all parse attempts."""

def __init__(
self,
msg: str,
*,
lineno: int | None = None,
column: int | None = None,
faulty_line: str | None = None,
) -> None:
self.msg = msg
self.lineno = lineno
self.column = column
self.faulty_line = faulty_line

def __str__(self) -> str:
return self.msg


def get_grammars(target_versions: set[TargetVersion]) -> list[Grammar]:
if not target_versions:
Expand Down Expand Up @@ -81,14 +97,20 @@ def lib2to3_parse(
except IndexError:
faulty_line = "<line number missing in source>"
errors[grammar.version] = InvalidInput(
f"Cannot parse{tv_str}: {lineno}:{column}: {faulty_line}"
f"Cannot parse{tv_str}: {lineno}:{column}: {faulty_line}",
lineno=lineno,
column=column,
faulty_line=faulty_line,
)

except TokenError as te:
# In edge cases these are raised; and typically don't have a "faulty_line".
lineno, column = te.args[1]
errors[grammar.version] = InvalidInput(
f"Cannot parse{tv_str}: {lineno}:{column}: {te.args[0]}"
f"Cannot parse{tv_str}: {lineno}:{column}: {te.args[0]}",
lineno=lineno,
column=column,
faulty_line=None,
)

else:
Expand Down
27 changes: 25 additions & 2 deletions src/black/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from click import style

from black.output import err, out
from black.parsing import InvalidInput


class Changed(Enum):
Expand Down Expand Up @@ -49,9 +50,31 @@ def done(self, src: Path, changed: Changed) -> None:
out(msg, bold=False)
self.same_count += 1

def failed(self, src: Path, message: str) -> None:
def failed(
self, src: Path, message: str | InvalidInput | Exception
) -> None:
"""Increment the counter for failed reformatting. Write out a message."""
err(f"error: cannot format {src}: {message}")
if isinstance(message, InvalidInput):
if message.lineno is not None and message.column is not None:
# Print user-friendly multi-line error message
err(f"Error: Cannot parse {src}")
err("")
err(
f"black's parser found a syntax error on or near "
f"line {message.lineno}."
)
err("")
err(f' File "{src}", line {message.lineno}:')
if message.faulty_line:
err(f" {message.faulty_line}")
pointer = " " * (message.column + 4) + "^"
err(pointer)
err("")
err("Please fix the syntax error before formatting.")
else:
err(f"error: cannot format {src}: {message}")
else:
err(f"error: cannot format {src}: {message}")
self.failure_count += 1

def path_ignored(self, path: Path, message: str) -> None:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_syntax_error_reporting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from click.testing import CliRunner

import black


def test_syntax_error_reporting(tmp_path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "bad_syntax.py"
src.write_text("def my_func()\n pass", encoding="utf-8")

runner = CliRunner()
result = runner.invoke(black.main, [str(src)])

assert result.exit_code == 123
assert "Error: Cannot parse" in result.output
assert "black's parser found a syntax error on or near line 1" in result.output
assert 'File "' in result.output
assert ', line 1:' in result.output
assert "def my_func()" in result.output
assert "^" in result.output