diff --git a/.github/workflows/release_tests.yml b/.github/workflows/release_tests.yml new file mode 100644 index 00000000000..74729445052 --- /dev/null +++ b/.github/workflows/release_tests.yml @@ -0,0 +1,56 @@ +name: Release tool CI + +on: + push: + paths: + - .github/workflows/release_tests.yml + - release.py + - release_tests.py + pull_request: + paths: + - .github/workflows/release_tests.yml + - release.py + - release_tests.py + +jobs: + build: + # We want to run on external PRs, but not on our own internal PRs as they'll be run + # by the push to the branch. Without this if check, checks are duplicated since + # internal PRs match both the push and pull_request events. + if: + github.event_name == 'push' || github.event.pull_request.head.repo.full_name != + github.repository + + name: Running python ${{ matrix.python-version }} on ${{matrix.os}} + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.12"] + os: [macOS-latest, ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + with: + # Give us all history, branches and tags + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Print Python Version + run: python --version --version && which python + + - name: Print Git Version + run: git --version && which git + + - name: Update pip, setuptools + wheels + run: | + python -m pip install --upgrade pip setuptools wheel + + - name: Run unit tests via coverage + print report + run: | + python -m pip install coverage + coverage run scripts/release_tests.py + coverage report --show-missing diff --git a/CHANGES.md b/CHANGES.md index 6c5a5c3397b..d4948e722cd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,8 @@ - Multiline dictionaries and lists that are the sole argument to a function are now indented less (#3964) +- Multiline list and dict unpacking as the sole argument to a function is now also + indented less (#3992) ### Configuration diff --git a/README.md b/README.md index b9829ece177..fb8170b626a 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ take previous formatting into account (see for exceptions). Our documentation covers the current _Black_ code style, but planned changes to it are -also documented. They're both worth taking a look: +also documented. They're both worth taking a look at: - [The _Black_ Code Style: Current style](https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html) - [The _Black_ Code Style: Future style](https://black.readthedocs.io/en/stable/the_black_code_style/future_style.html) diff --git a/docs/contributing/release_process.md b/docs/contributing/release_process.md index 02865d6f4bd..c66ffae8ace 100644 --- a/docs/contributing/release_process.md +++ b/docs/contributing/release_process.md @@ -32,21 +32,29 @@ The 10,000 foot view of the release process is that you prepare a release PR and publish a [GitHub Release]. This triggers [release automation](#release-workflows) that builds all release artifacts and publishes them to the various platforms we publish to. +We now have a `scripts/release.py` script to help with cutting the release PRs. + +- `python3 scripts/release.py --help` is your friend. + - `release.py` has only been tested in Python 3.12 (so get with the times :D) + To cut a release: 1. Determine the release's version number - **_Black_ follows the [CalVer] versioning standard using the `YY.M.N` format** - So unless there already has been a release during this month, `N` should be `0` - Example: the first release in January, 2022 → `22.1.0` + - `release.py` will calculate this and log to stderr for you copy paste pleasure 1. File a PR editing `CHANGES.md` and the docs to version the latest changes + - Run `python3 scripts/release.py [--debug]` to generate most changes + - Sub headings in the template, if they have no bullet points need manual removal + _PR welcome to improve :D_ +1. If `release.py` fail manually edit; otherwise, yay, skip this step! 1. Replace the `## Unreleased` header with the version number 1. Remove any empty sections for the current release 1. (_optional_) Read through and copy-edit the changelog (eg. by moving entries, fixing typos, or rephrasing entries) 1. Double-check that no changelog entries since the last release were put in the wrong section (e.g., run `git diff CHANGES.md`) - 1. Add a new empty template for the next release above - ([template below](#changelog-template)) 1. Update references to the latest version in {doc}`/integrations/source_version_control` and {doc}`/usage_and_configuration/the_basics` @@ -63,6 +71,11 @@ To cut a release: description box 1. Publish the GitHub Release, triggering [release automation](#release-workflows) that will handle the rest +1. Once CI is done add + commit (git push - No review) a new empty template for the next + release to CHANGES.md _(Template is able to be copy pasted from release.py should we + fail)_ + 1. `python3 scripts/release.py --add-changes-template|-a [--debug]` + 1. Should that fail, please return to copy + paste 1. At this point, you're basically done. It's good practice to go and [watch and verify that all the release workflows pass][black-actions], although you will receive a GitHub notification should something fail. @@ -81,59 +94,6 @@ release is probably unnecessary. In the end, use your best judgement and ask other maintainers for their thoughts. ``` -### Changelog template - -Use the following template for a clean changelog after the release: - -``` -## Unreleased - -### Highlights - - - -### Stable style - - - -### Preview style - - - -### Configuration - - - -### Packaging - - - -### Parser - - - -### Performance - - - -### Output - - - -### _Blackd_ - - - -### Integrations - - - -### Documentation - - -``` - ## Release workflows All of _Black_'s release automation uses [GitHub Actions]. All workflows are therefore diff --git a/docs/the_black_code_style/future_style.md b/docs/the_black_code_style/future_style.md index c744902577d..944ffad033e 100644 --- a/docs/the_black_code_style/future_style.md +++ b/docs/the_black_code_style/future_style.md @@ -139,6 +139,26 @@ foo([ ]) ``` +This also applies to list and dictionary unpacking: + +```python +foo( + *[ + a_long_function_name(a_long_variable_name) + for a_long_variable_name in some_generator + ] +) +``` + +will become: + +```python +foo(*[ + a_long_function_name(a_long_variable_name) + for a_long_variable_name in some_generator +]) +``` + You can use a magic trailing comma to avoid this compacting behavior; by default, _Black_ will not reformat the following code: diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 00000000000..d588429c2d3 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +""" +Tool to help automate changes needed in commits during and after releases +""" + +import argparse +import logging +import sys +from datetime import datetime +from pathlib import Path +from subprocess import PIPE, run +from typing import List + +LOG = logging.getLogger(__name__) +NEW_VERSION_CHANGELOG_TEMPLATE = """\ +## Unreleased + +### Highlights + + + +### Stable style + + + +### Preview style + + + +### Configuration + + + +### Packaging + + + +### Parser + + + +### Performance + + + +### Output + + + +### _Blackd_ + + + +### Integrations + + + +### Documentation + + +""" + + +class NoGitTagsError(Exception): ... # noqa: E701,E761 + + +# TODO: Do better with alpha + beta releases +# Maybe we vendor packaging library +def get_git_tags(versions_only: bool = True) -> List[str]: + """Pull out all tags or calvers only""" + cp = run(["git", "tag"], stdout=PIPE, stderr=PIPE, check=True, encoding="utf8") + if not cp.stdout: + LOG.error(f"Returned no git tags stdout: {cp.stderr}") + raise NoGitTagsError + git_tags = cp.stdout.splitlines() + if versions_only: + return [t for t in git_tags if t[0].isdigit()] + return git_tags + + +# TODO: Support sorting alhpa/beta releases correctly +def tuple_calver(calver: str) -> tuple[int, ...]: # mypy can't notice maxsplit below + """Convert a calver string into a tuple of ints for sorting""" + try: + return tuple(map(int, calver.split(".", maxsplit=2))) + except ValueError: + return (0, 0, 0) + + +class SourceFiles: + def __init__(self, black_repo_dir: Path): + # File path fun all pathlib to be platform agnostic + self.black_repo_path = black_repo_dir + self.changes_path = self.black_repo_path / "CHANGES.md" + self.docs_path = self.black_repo_path / "docs" + self.version_doc_paths = ( + self.docs_path / "integrations" / "source_version_control.md", + self.docs_path / "usage_and_configuration" / "the_basics.md", + ) + self.current_version = self.get_current_version() + self.next_version = self.get_next_version() + + def __str__(self) -> str: + return f"""\ +> SourceFiles ENV: + Repo path: {self.black_repo_path} + CHANGES.md path: {self.changes_path} + docs path: {self.docs_path} + Current version: {self.current_version} + Next version: {self.next_version} +""" + + def add_template_to_changes(self) -> int: + """Add the template to CHANGES.md if it does not exist""" + LOG.info(f"Adding template to {self.changes_path}") + + with self.changes_path.open("r") as cfp: + changes_string = cfp.read() + + if "## Unreleased" in changes_string: + LOG.error(f"{self.changes_path} already has unreleased template") + return 1 + + templated_changes_string = changes_string.replace( + "# Change Log\n", + f"# Change Log\n\n{NEW_VERSION_CHANGELOG_TEMPLATE}", + ) + + with self.changes_path.open("w") as cfp: + cfp.write(templated_changes_string) + + LOG.info(f"Added template to {self.changes_path}") + return 0 + + def cleanup_changes_template_for_release(self) -> None: + LOG.info(f"Cleaning up {self.changes_path}") + + with self.changes_path.open("r") as cfp: + changes_string = cfp.read() + + # Change Unreleased to next version + versioned_changes = changes_string.replace( + "## Unreleased", f"## {self.next_version}" + ) + + # Remove all comments (subheadings are harder - Human required still) + no_comments_changes = [] + for line in versioned_changes.splitlines(): + if line.startswith(""): + continue + no_comments_changes.append(line) + + with self.changes_path.open("w") as cfp: + cfp.write("\n".join(no_comments_changes) + "\n") + + LOG.debug(f"Finished Cleaning up {self.changes_path}") + + def get_current_version(self) -> str: + """Get the latest git (version) tag as latest version""" + return sorted(get_git_tags(), key=lambda k: tuple_calver(k))[-1] + + def get_next_version(self) -> str: + """Workout the year and month + version number we need to move to""" + base_calver = datetime.today().strftime("%y.%m") + calver_parts = base_calver.split(".") + base_calver = f"{calver_parts[0]}.{int(calver_parts[1])}" # Remove leading 0 + git_tags = get_git_tags() + same_month_releases = [t for t in git_tags if t.startswith(base_calver)] + if len(same_month_releases) < 1: + return f"{base_calver}.0" + same_month_version = same_month_releases[-1].split(".", 2)[-1] + return f"{base_calver}.{int(same_month_version) + 1}" + + def update_repo_for_release(self) -> int: + """Update CHANGES.md + doc files ready for release""" + self.cleanup_changes_template_for_release() + self.update_version_in_docs() + return 0 # return 0 if no exceptions hit + + def update_version_in_docs(self) -> None: + for doc_path in self.version_doc_paths: + LOG.info(f"Updating black version to {self.next_version} in {doc_path}") + + with doc_path.open("r") as dfp: + doc_string = dfp.read() + + next_version_doc = doc_string.replace( + self.current_version, self.next_version + ) + + with doc_path.open("w") as dfp: + dfp.write(next_version_doc) + + LOG.debug( + f"Finished updating black version to {self.next_version} in {doc_path}" + ) + + +def _handle_debug(debug: bool) -> None: + """Turn on debugging if asked otherwise INFO default""" + log_level = logging.DEBUG if debug else logging.INFO + logging.basicConfig( + format="[%(asctime)s] %(levelname)s: %(message)s (%(filename)s:%(lineno)d)", + level=log_level, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "-a", + "--add-changes-template", + action="store_true", + help="Add the Unreleased template to CHANGES.md", + ) + parser.add_argument( + "-d", "--debug", action="store_true", help="Verbose debug output" + ) + args = parser.parse_args() + _handle_debug(args.debug) + return args + + +def main() -> int: + args = parse_args() + + # Need parent.parent cause script is in scripts/ directory + sf = SourceFiles(Path(__file__).parent.parent) + + if args.add_changes_template: + return sf.add_template_to_changes() + + LOG.info(f"Current version detected to be {sf.current_version}") + LOG.info(f"Next version will be {sf.next_version}") + return sf.update_repo_for_release() + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/scripts/release_tests.py b/scripts/release_tests.py new file mode 100644 index 00000000000..bd72cb4b48a --- /dev/null +++ b/scripts/release_tests.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import unittest +from pathlib import Path +from shutil import rmtree +from tempfile import TemporaryDirectory +from typing import Any +from unittest.mock import Mock, patch + +from release import SourceFiles, tuple_calver # type: ignore + + +class FakeDateTime: + """Used to mock the date to test generating next calver function""" + + def today(*args: Any, **kwargs: Any) -> "FakeDateTime": # noqa + return FakeDateTime() + + # Add leading 0 on purpose to ensure we remove it + def strftime(*args: Any, **kwargs: Any) -> str: # noqa + return "69.01" + + +class TestRelease(unittest.TestCase): + def setUp(self) -> None: + # We only test on >= 3.12 + self.tempdir = TemporaryDirectory(delete=False) # type: ignore + self.tempdir_path = Path(self.tempdir.name) + self.sf = SourceFiles(self.tempdir_path) + + def tearDown(self) -> None: + rmtree(self.tempdir.name) + return super().tearDown() + + @patch("release.get_git_tags") + def test_get_current_version(self, mocked_git_tags: Mock) -> None: + mocked_git_tags.return_value = ["1.1.0", "69.1.0", "69.1.1", "2.2.0"] + self.assertEqual("69.1.1", self.sf.get_current_version()) + + @patch("release.get_git_tags") + @patch("release.datetime", FakeDateTime) + def test_get_next_version(self, mocked_git_tags: Mock) -> None: + # test we handle no args + mocked_git_tags.return_value = [] + self.assertEqual( + "69.1.0", + self.sf.get_next_version(), + "Unable to get correct next version with no git tags", + ) + + # test we handle + mocked_git_tags.return_value = ["1.1.0", "69.1.0", "69.1.1", "2.2.0"] + self.assertEqual( + "69.1.2", + self.sf.get_next_version(), + "Unable to get correct version with 2 previous versions released this" + " month", + ) + + def test_tuple_calver(self) -> None: + first_month_release = tuple_calver("69.1.0") + second_month_release = tuple_calver("69.1.1") + self.assertEqual((69, 1, 0), first_month_release) + self.assertEqual((0, 0, 0), tuple_calver("69.1.1a0")) # Hack for alphas/betas + self.assertTrue(first_month_release < second_month_release) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/black/cache.py b/src/black/cache.py index 6baa096baca..6a332304981 100644 --- a/src/black/cache.py +++ b/src/black/cache.py @@ -124,9 +124,9 @@ def filtered_cached(self, sources: Iterable[Path]) -> Tuple[Set[Path], Set[Path] def write(self, sources: Iterable[Path]) -> None: """Update the cache file data and write a new cache file.""" - self.file_data.update( - **{str(src.resolve()): Cache.get_file_data(src) for src in sources} - ) + self.file_data.update(**{ + str(src.resolve()): Cache.get_file_data(src) for src in sources + }) try: CACHE_DIR.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( diff --git a/src/black/linegen.py b/src/black/linegen.py index 5f5a69152d5..43bc08efbbd 100644 --- a/src/black/linegen.py +++ b/src/black/linegen.py @@ -817,16 +817,17 @@ def _first_right_hand_split( head_leaves.reverse() if Preview.hug_parens_with_braces_and_square_brackets in line.mode: + is_unpacking = 1 if body_leaves[0].type in [token.STAR, token.DOUBLESTAR] else 0 if ( tail_leaves[0].type == token.RPAR and tail_leaves[0].value and tail_leaves[0].opening_bracket is head_leaves[-1] and body_leaves[-1].type in [token.RBRACE, token.RSQB] - and body_leaves[-1].opening_bracket is body_leaves[0] + and body_leaves[-1].opening_bracket is body_leaves[is_unpacking] ): - head_leaves = head_leaves + body_leaves[:1] + head_leaves = head_leaves + body_leaves[: 1 + is_unpacking] tail_leaves = body_leaves[-1:] + tail_leaves - body_leaves = body_leaves[1:-1] + body_leaves = body_leaves[1 + is_unpacking : -1] head = bracket_split_build_line( head_leaves, line, opening_bracket, component=_BracketSplitComponent.head diff --git a/tests/data/cases/preview_hug_parens_with_braces_and_square_brackets.py b/tests/data/cases/preview_hug_parens_with_braces_and_square_brackets.py index 6d10518133c..51fe516add5 100644 --- a/tests/data/cases/preview_hug_parens_with_braces_and_square_brackets.py +++ b/tests/data/cases/preview_hug_parens_with_braces_and_square_brackets.py @@ -137,6 +137,21 @@ def foo_square_brackets(request): [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], {x}, "a string", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] ) +foo(*["long long long long long line", "long long long long long line", "long long long long long line"]) + +foo(*[str(i) for i in range(100000000000000000000000000000000000000000000000000000000000)]) + +foo( + **{ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": 1, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": 2, + "ccccccccccccccccccccccccccccccccc": 3, + **other, + } +) + +foo(**{x: y for x, y in enumerate(["long long long long line","long long long long line"])}) + # output def foo_brackets(request): return JsonResponse({ @@ -287,3 +302,24 @@ def foo_square_brackets(request): baaaaaaaaaaaaar( [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], {x}, "a string", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] ) + +foo(*[ + "long long long long long line", + "long long long long long line", + "long long long long long line", +]) + +foo(*[ + str(i) for i in range(100000000000000000000000000000000000000000000000000000000000) +]) + +foo(**{ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": 1, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": 2, + "ccccccccccccccccccccccccccccccccc": 3, + **other, +}) + +foo(**{ + x: y for x, y in enumerate(["long long long long line", "long long long long line"]) +})