diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4862c608d13..b63f9fc00c0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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" diff --git a/.github/workflows/pypi_upload.yml b/.github/workflows/pypi_upload.yml index 918429e2952..14800f6c385 100644 --- a/.github/workflows/pypi_upload.yml +++ b/.github/workflows/pypi_upload.yml @@ -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 @@ -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: diff --git a/.github/workflows/upload_binary.yml b/.github/workflows/upload_binary.yml index c49f84c7544..3d2c561175d 100644 --- a/.github/workflows/upload_binary.yml +++ b/.github/workflows/upload_binary.yml @@ -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" diff --git a/src/black/__init__.py b/src/black/__init__.py index 0cfed9d5282..fbfb513dbc0 100644 --- a/src/black/__init__.py +++ b/src/black/__init__.py @@ -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") @@ -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 @@ -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( diff --git a/src/black/parsing.py b/src/black/parsing.py index b6794e00f72..98fb956bae3 100644 --- a/src/black/parsing.py +++ b/src/black/parsing.py @@ -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: @@ -81,14 +97,20 @@ def lib2to3_parse( except IndexError: faulty_line = "" 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: diff --git a/src/black/report.py b/src/black/report.py index 89899f2f389..aa2d4092145 100644 --- a/src/black/report.py +++ b/src/black/report.py @@ -9,6 +9,7 @@ from click import style from black.output import err, out +from black.parsing import InvalidInput class Changed(Enum): @@ -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: diff --git a/tests/test_syntax_error_reporting.py b/tests/test_syntax_error_reporting.py new file mode 100644 index 00000000000..ff7adb5d22f --- /dev/null +++ b/tests/test_syntax_error_reporting.py @@ -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