diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..3ed1b92 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +extend-ignore = C408,E203,F841,W503 +max-complexity = 10 +max-line-length = 88 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6e9a9f8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + assignees: + - "ezio-melotti" + open-pull-requests-limit: 10 + + # Maintain dependencies for Python + - package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + assignees: + - "ezio-melotti" + open-pull-requests-limit: 10 diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..9d1e098 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot + - pre-commit-ci diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6dc1929 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,79 @@ +name: Build package + +on: + push: + pull_request: + release: + types: + - published + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_COLOR: 1 + +jobs: + # Always build & lint package. + build-package: + name: Build & verify package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: hynek/build-and-inspect-python-package@v2 + + # Publish to Test PyPI on every commit on main. + release-test-pypi: + name: Publish in-dev package to test.pypi.org + if: | + github.repository_owner == 'python' + && github.event_name == 'push' + && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + needs: build-package + + permissions: + id-token: write + + steps: + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v4 + with: + name: Packages + path: dist + + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true + repository-url: https://test.pypi.org/legacy/ + + # Publish to PyPI on GitHub Releases. + release-pypi: + name: Publish to PyPI + # Only run for published releases. + if: | + github.repository_owner == 'python' + && github.event.action == 'published' + runs-on: ubuntu-latest + needs: build-package + + permissions: + id-token: write + + steps: + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v4 + with: + name: Packages + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..7af199e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Lint + +on: [push, pull_request, workflow_dispatch] + +env: + FORCE_COLOR: 1 + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + cache-dependency-path: .github/workflows/lint.yml + - uses: pre-commit/action@v3.0.1 + - name: Install dependencies + run: | + python -m pip install --upgrade pip wheel + python -m pip install --upgrade safety + python -m pip install --editable . + # Ignore 70612 / CVE-2019-8341, Jinja2 is a safety dep, not ours + - run: safety check --ignore 70612 diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml deleted file mode 100644 index ec8e540..0000000 --- a/.github/workflows/lint_python.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: lint_python -on: [pull_request, push, workflow_dispatch] -jobs: - lint_python: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - with: - cache: pip - cache-dependency-path: .github/workflows/lint_python.yml - - run: pip install --upgrade pip wheel - - run: pip install bandit black codespell flake8 flake8-bugbear - flake8-comprehensions isort mypy pyupgrade safety - - run: bandit --recursive --skip B101,B404,B603 . - - run: black --diff . - - run: codespell --ignore-words-list="commitish" - - run: flake8 . --count --ignore=C408,E203,F841,W503 --max-complexity=10 - --max-line-length=143 --show-source --statistics - - run: isort --check-only --profile black . - - run: pip install --editable . - - run: mypy --ignore-missing-imports --install-types --non-interactive . - - run: shopt -s globstar && pyupgrade --py37-plus **/*.py || true - - run: safety check diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48c10e6..a3fb835 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,68 +2,46 @@ name: tests on: [push, pull_request, workflow_dispatch] +permissions: + contents: read + env: FORCE_COLOR: 1 jobs: test: + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python: - - 3.7 - - 3.8 - - 3.9 - - "3.10" - - "3.11-dev" - platform: - - ubuntu-latest - - macos-latest - - windows-latest - runs-on: ${{ matrix.platform }} + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + os: [windows-latest, macos-latest, ubuntu-latest] + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: # fetch all branches and tags # ref actions/checkout#448 fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python }} + python-version: ${{ matrix.python-version }} + allow-prereleases: true cache: pip cache-dependency-path: pyproject.toml + - name: Install tox run: | python -m pip install tox + - name: Run tests run: tox -e py + - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} - - release: - needs: test - if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: "3.10" - cache: pip - cache-dependency-path: .github/workflows/main.yml - - name: Install tools - run: | - python -m pip install build twine - - name: Release - run: | - build . - twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + token: ${{ secrets.CODECOV_ORG_TOKEN }} diff --git a/.gitignore b/.gitignore index 8b6ca4e..18a1ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -399,3 +399,6 @@ $RECYCLE.BIN/ *.lnk # End of https://www.gitignore.io/api/git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs + +# hatch-vcs +cherry_picker/_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2a532db --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,80 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.8 + hooks: + - id: ruff + args: [--exit-non-zero-on-fix] + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 24.8.0 + hooks: + - id: black + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: forbid-submodules + - id: trailing-whitespace + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.29.2 + hooks: + - id: check-dependabot + - id: check-github-workflows + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.2 + hooks: + - id: actionlint + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + args: + [ + --ignore-missing-imports, + --install-types, + --non-interactive, + --pretty, + --show-error-codes, + ., + ] + pass_filenames: false + + - repo: https://github.com/tox-dev/pyproject-fmt + rev: 2.2.4 + hooks: + - id: pyproject-fmt + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.20.2 + hooks: + - id: validate-pyproject + + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: 1.4.1 + hooks: + - id: tox-ini-fmt + + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: [--ignore-words-list=commitish] + + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes + +ci: + autoupdate_schedule: quarterly diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9ed5b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,105 @@ +# Changelog + +## 2.3.0 + +- Add support for Python 3.13 + ([PR 127](https://github.com/python/cherry-picker/pull/127), + [PR 134](https://github.com/python/cherry-picker/pull/134)) +- Drop support for EOL Python 3.8 + ([PR 133](https://github.com/python/cherry-picker/pull/133), + [PR 137](https://github.com/python/cherry-picker/pull/137)) +- Resolve usernames when the remote ends with a trailing slash ([PR 110](https://github.com/python/cherry-picker/pull/110)) +- Optimize `validate_sha()` with `--max-count=1` ([PR 111](https://github.com/python/cherry-picker/pull/111)) +- Make # replacing more strict ([PR 115](https://github.com/python/cherry-picker/pull/115)) +- Remove multiple commit prefixes ([PR 118](https://github.com/python/cherry-picker/pull/118)) +- Handle whitespace when calculating usernames ([PR 132](https://github.com/python/cherry-picker/pull/132)) +- Publish to PyPI using Trusted Publishers ([PR 94](https://github.com/python/cherry-picker/pull/94)) +- Generate digital attestations for PyPI ([PEP 740](https://peps.python.org/pep-0740/)) + ([PR 135](https://github.com/python/cherry-picker/pull/135)) + +## 2.2.0 + +- Add log messages +- Fix for conflict handling, get the state correctly ([PR 88](https://github.com/python/cherry-picker/pull/88)) +- Drop support for Python 3.7 ([PR 90](https://github.com/python/cherry-picker/pull/90)) + +## 2.1.0 + +- Mix fixes: #28, #29, #31, #32, #33, #34, #36 + +## 2.0.0 + +- Support the `main` branch by default ([PR 23](https://github.com/python/cherry-picker/pull/23)). + To use a different default branch, please configure it in the + `.cherry-picker.toml` file. + + - Renamed `cherry-picker`'s own default branch to `main` + +## 1.3.2 + +- Use `--no-tags` option when fetching upstream ([PR 319](https://github.com/python/core-workflow/pull/319)) + +## 1.3.1 + +- Modernize cherry_picker's pyproject.toml file ([PR #316](https://github.com/python/core-workflow/pull/316)) + +- Remove the `BACKPORT_COMPLETE` state. Unset the states when backport is completed + ([PR #315](https://github.com/python/core-workflow/pull/315)) + +- Run Travis CI test on Windows ([PR #311](https://github.com/python/core-workflow/pull/311)) + +## 1.3.0 + +- Implement state machine and storing reference to the config + used at the beginning of the backport process using commit sha + and a repo-local Git config. + ([PR #295](https://github.com/python/core-workflow/pull/295)) + +## 1.2.2 + +- Relaxed click dependency ([PR #302](https://github.com/python/core-workflow/pull/302)) + +## 1.2.1 + +- Validate the branch name to operate on with `--continue` and fail early if the branch could not + have been created by cherry_picker ([PR #266](https://github.com/python/core-workflow/pull/266)) + +- Bugfix: Allow `--continue` to support version branches that have dashes in them. This is + a bugfix of the additional branch versioning schemes introduced in 1.2.0. + ([PR #265](https://github.com/python/core-workflow/pull/265)). + +- Bugfix: Be explicit about the branch name on the remote to push the cherry pick to. This allows + cherry_picker to work correctly when the user has a git push strategy other than the default + configured ([PR #264](https://github.com/python/core-workflow/pull/264)). + +## 1.2.0 + +- Add `default_branch` configuration item. The default is `master`, which + is the default branch for CPython. It can be configured to other branches like, + `devel`, or `develop`. The default branch is the branch cherry_picker + will return to after backporting ([PR #254](https://github.com/python/core-workflow/pull/254) + and [Issue #250](https://github.com/python/core-workflow/issues/250)). + +- Support additional branch versioning schemes, such as `something-X.Y`, + or `X.Y-somethingelse`. ([PR #253](https://github.com/python/core-workflow/pull/253) + and [Issue #251](https://github.com/python/core-workflow/issues/251)). + +## 1.1.1 + +- Change the calls to `subprocess` to use lists instead of strings. This fixes + the bug that affects users in Windows + ([PR #238](https://github.com/python/core-workflow/pull/238)). + +## 1.1.0 + +- Add `fix_commit_msg` configuration item. Setting fix_commit_msg to `true` + will replace the issue number in the commit message, from `#` to `GH-`. + This is the default behavior for CPython. Other projects can opt out by + setting it to `false` ([PR #233](https://github.com/python/core-workflow/pull/233) + and [aiohttp issue #2853](https://github.com/aio-libs/aiohttp/issues/2853)). + +## 1.0.0 + +- Support configuration file by using `--config-path` option, or by adding + `.cherry-picker.toml` file to the root of the project + ([Issue #225](https://github.com/python/core-workflow/issues/225)) diff --git a/README.md b/README.md new file mode 100644 index 0000000..617f3c9 --- /dev/null +++ b/README.md @@ -0,0 +1,364 @@ +# cherry_picker + +[![PyPI version](https://img.shields.io/pypi/v/cherry-picker.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/cherry-picker) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/cherry-picker.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/cherry-picker) +[![tests](https://github.com/python/cherry-picker/actions/workflows/main.yml/badge.svg)](https://github.com/python/cherry-picker/actions/workflows/main.yml) + +Usage (from a cloned CPython directory): + +``` +Usage: cherry_picker [OPTIONS] [COMMIT_SHA1] [BRANCHES]... + + cherry-pick COMMIT_SHA1 into target BRANCHES. + +Options: + --version Show the version and exit. + --dry-run Prints out the commands, but not executed. + --pr-remote REMOTE git remote to use for PR branches + --upstream-remote REMOTE git remote to use for upstream branches + --abort Abort current cherry-pick and clean up branch + --continue Continue cherry-pick, push, and clean up branch + --status Get the status of cherry-pick + --push / --no-push Changes won't be pushed to remote + --auto-pr / --no-auto-pr If auto PR is enabled, cherry-picker will + automatically open a PR through API if GH_AUTH + env var is set, or automatically open the PR + creation page in the web browser otherwise. + --config-path CONFIG-PATH Path to config file, .cherry_picker.toml from + project root by default. You can prepend a colon- + separated Git 'commitish' reference. + -h, --help Show this message and exit. +``` + +## About + +This tool is used to backport CPython changes from `main` into one or more +of the maintenance branches (e.g. `3.12`, `3.11`). + +`cherry_picker` can be configured to backport other projects with similar +workflow as CPython. See the configuration file options below for more details. + +The maintenance branch names should contain some sort of version number (`X.Y`). +For example: `3.12`, `stable-3.12`, `1.5`, `1.5-lts`, are all supported branch +names. + +It will prefix the commit message with the branch, e.g. `[3.12]`, and then +open up the pull request page. + +Write tests using [pytest](https://docs.pytest.org/). + + +## Setup info + +Requires Python 3.8+. + +```console +$ python3 -m venv venv +$ source venv/bin/activate +(venv) $ python -m pip install cherry_picker +``` + +The cherry picking script assumes that if an `upstream` remote is defined, then +it should be used as the source of upstream changes and as the base for +cherry-pick branches. Otherwise, `origin` is used for that purpose. +You can override this behavior with the `--upstream-remote` option +(e.g. `--upstream-remote python` to use a remote named `python`). + +Verify that an `upstream` remote is set to the CPython repository: + +```console +$ git remote -v +... +upstream https://github.com/python/cpython (fetch) +upstream https://github.com/python/cpython (push) +``` + +If needed, create the `upstream` remote: + +```console +$ git remote add upstream https://github.com/python/cpython.git +``` + +By default, the PR branches used to submit pull requests back to the main +repository are pushed to `origin`. If this is incorrect, then the correct +remote will need be specified using the `--pr-remote` option (e.g. +`--pr-remote pr` to use a remote named `pr`). + + +## Cherry-picking 🐍🍒⛏️ + +(Setup first! See previous section.) + +From the cloned CPython directory: + +```console +(venv) $ cherry_picker [--pr-remote REMOTE] [--upstream-remote REMOTE] [--dry-run] [--config-path CONFIG-PATH] [--abort/--continue] [--status] [--push/--no-push] [--auto-pr/--no-auto-pr] +``` + +### Commit sha1 + +The commit sha1 for cherry-picking is the squashed commit that was merged to +the `main` branch. On the merged pull request, scroll to the bottom of the +page. Find the event that says something like: + +``` + merged commit into python:main ago. +``` + +By following the link to ``, you will get the full commit hash. +Use the full commit hash for `cherry_picker.py`. + + +### Options + +``` +--dry-run Dry Run Mode. Prints out the commands, but not executed. +--pr-remote REMOTE Specify the git remote to push into. Default is 'origin'. +--upstream-remote REMOTE Specify the git remote to use for upstream branches. + Default is 'upstream' or 'origin' if the former doesn't exist. +--status Do `git status` in cpython directory. +``` + +Additional options: + +``` +--abort Abort current cherry-pick and clean up branch +--continue Continue cherry-pick, push, and clean up branch +--no-push Changes won't be pushed to remote +--no-auto-pr PR creation page won't be automatically opened in the web browser or + if GH_AUTH is set, the PR won't be automatically opened through API. +--config-path Path to config file + (`.cherry_picker.toml` from project root by default) +``` + +Configuration file example: + +```toml +team = "aio-libs" +repo = "aiohttp" +check_sha = "f382b5ffc445e45a110734f5396728da7914aeb6" +fix_commit_msg = false +default_branch = "devel" +require_version_in_branch_name = false +``` + +Available config options: + +``` +team github organization or individual nick, + e.g "aio-libs" for https://github.com/aio-libs/aiohttp + ("python" by default) + +repo github project name, + e.g "aiohttp" for https://github.com/aio-libs/aiohttp + ("cpython" by default) + +check_sha A long hash for any commit from the repo, + e.g. a sha1 hash from the very first initial commit + ("7f777ed95a19224294949e1b4ce56bbffcb1fe9f" by default) + +fix_commit_msg Replace # with GH- in cherry-picked commit message. + It is the default behavior for CPython because of external + Roundup bug tracker (https://bugs.python.org) behavior: + #xxxx should point on issue xxxx but GH-xxxx points + on pull-request xxxx. + For projects using GitHub Issues, this option can be disabled. + +default_branch Project's default branch name, + e.g "devel" for https://github.com/ansible/ansible + ("main" by default) + +require_version_in_branch_name Allow backporting to branches whose names don't contain + something that resembles a version number + (i.e. at least two dot-separated numbers). +``` + +To customize the tool for used by other project: + +1. Create a file called `.cherry_picker.toml` in the project's root + folder (alongside with `.git` folder). + +2. Add `team`, `repo`, `fix_commit_msg`, `check_sha` and + `default_branch` config values as described above. + +3. Use `git add .cherry_picker.toml` / `git commit` to add the config + into Git. + +4. Add `cherry_picker` to development dependencies or install it + by `pip install cherry_picker` + +5. Now everything is ready, use `cherry_picker + ` for cherry-picking changes from `` into + maintenance branches. + Branch name should contain at least major and minor version numbers + and may have some prefix or suffix. + Only the first version-like substring is matched when the version + is extracted from branch name. + +### Demo + +- Installation: https://asciinema.org/a/125254 + +- Backport: https://asciinema.org/a/125256 + + +### Example + +For example, to cherry-pick `6de2b7817f-some-commit-sha1-d064` into +`3.12` and `3.11`, run the following command from the cloned CPython +directory: + +```console +(venv) $ cherry_picker 6de2b7817f-some-commit-sha1-d064 3.12 3.11 +``` + +What this will do: + +```console +(venv) $ git fetch upstream + +(venv) $ git checkout -b backport-6de2b78-3.12 upstream/3.12 +(venv) $ git cherry-pick -x 6de2b7817f-some-commit-sha1-d064 +(venv) $ git push origin backport-6de2b78-3.12 +(venv) $ git checkout main +(venv) $ git branch -D backport-6de2b78-3.12 + +(venv) $ git checkout -b backport-6de2b78-3.11 upstream/3.11 +(venv) $ git cherry-pick -x 6de2b7817f-some-commit-sha1-d064 +(venv) $ git push origin backport-6de2b78-3.11 +(venv) $ git checkout main +(venv) $ git branch -D backport-6de2b78-3.11 +``` + +In case of merge conflicts or errors, the following message will be displayed: + +``` +Failed to cherry-pick 554626ada769abf82a5dabe6966afa4265acb6a6 into 2.7 :frowning_face: +... Stopping here. + +To continue and resolve the conflict: + $ cherry_picker --status # to find out which files need attention + # Fix the conflict + $ cherry_picker --status # should now say 'all conflict fixed' + $ cherry_picker --continue + +To abort the cherry-pick and cleanup: + $ cherry_picker --abort +``` + +Passing the `--dry-run` option will cause the script to print out all the +steps it would execute without actually executing any of them. For example: + +```console +$ cherry_picker --dry-run --pr-remote pr 1e32a1be4a1705e34011770026cb64ada2d340b5 3.12 3.11 +Dry run requested, listing expected command sequence +fetching upstream ... +dry_run: git fetch origin +Now backporting '1e32a1be4a1705e34011770026cb64ada2d340b5' into '3.12' +dry_run: git checkout -b backport-1e32a1b-3.12 origin/3.12 +dry_run: git cherry-pick -x 1e32a1be4a1705e34011770026cb64ada2d340b5 +dry_run: git push pr backport-1e32a1b-3.12 +dry_run: Create new PR: https://github.com/python/cpython/compare/3.12...ncoghlan:backport-1e32a1b-3.12?expand=1 +dry_run: git checkout main +dry_run: git branch -D backport-1e32a1b-3.12 +Now backporting '1e32a1be4a1705e34011770026cb64ada2d340b5' into '3.11' +dry_run: git checkout -b backport-1e32a1b-3.11 origin/3.11 +dry_run: git cherry-pick -x 1e32a1be4a1705e34011770026cb64ada2d340b5 +dry_run: git push pr backport-1e32a1b-3.11 +dry_run: Create new PR: https://github.com/python/cpython/compare/3.11...ncoghlan:backport-1e32a1b-3.11?expand=1 +dry_run: git checkout main +dry_run: git branch -D backport-1e32a1b-3.11 +``` + +### `--pr-remote` option + +This will generate pull requests through a remote other than `origin` +(e.g. `pr`) + +### `--upstream-remote` option + +This will generate branches from a remote other than `upstream`/`origin` +(e.g. `python`) + +### `--status` option + +This will do `git status` for the CPython directory. + +### `--abort` option + +Cancels the current cherry-pick and cleans up the cherry-pick branch. + +### `--continue` option + +Continues the current cherry-pick, commits, pushes the current branch to +`origin`, opens the PR page, and cleans up the branch. + +### `--no-push` option + +Changes won't be pushed to remote. This allows you to test and make additional +changes. Once you're satisfied with local changes, use `--continue` to complete +the backport, or `--abort` to cancel and clean up the branch. You can also +cherry-pick additional commits, by: + +```console +$ git cherry-pick -x +``` + +### `--no-auto-pr` option + +PR creation page won't be automatically opened in the web browser or +if GH_AUTH is set, the PR won't be automatically opened through API. +This can be useful if your terminal is not capable of opening a useful web browser, +or if you use cherry-picker with a different Git hosting than GitHub. + +### `--config-path` option + +Allows to override default config file path +(`/.cherry_picker.toml`) with a custom one. This allows cherry_picker +to backport projects other than CPython. + + +## Creating pull requests + +When a cherry-pick was applied successfully, this script will open up a browser +tab that points to the pull request creation page. + +The url of the pull request page looks similar to the following: + +``` +https://github.com/python/cpython/compare/3.12...:backport-6de2b78-3.12?expand=1 +``` + +Press the `Create Pull Request` button. + +Bedevere will then remove the `needs backport to ...` label from the original +pull request against `main`. + + +## Running tests + +```console +$ # Install pytest +$ pip install -U pytest +$ # Run tests +$ pytest +``` + +Tests require your local version of Git to be 2.28.0+. + +## Publishing to PyPI + +- See the [release checklist](https://github.com/python/cherry-picker/blob/main/RELEASING.md). + + +## Local installation + +In the directory where `pyproject.toml` exists: + +```console +$ pip install +``` + +## Changelog + +See the [changelog](https://github.com/python/cherry-picker/blob/main/CHANGELOG.md). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..bd3fecd --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,29 @@ +# Release Checklist + +- [ ] check tests pass on [GitHub Actions](https://github.com/python/cherry-picker/actions) + [![GitHub Actions status](https://github.com/python/cherry-picker/actions/workflows/main.yml/badge.svg)](https://github.com/python/cherry-picker/actions/workflows/main.yml) + +- [ ] Update [changelog](https://github.com/python/cherry-picker/blob/main/CHANGELOG.md) + +- [ ] Go to the [Releases page](https://github.com/python/cherry-picker/releases) and + + - [ ] Click "Draft a new release" + + - [ ] Click "Choose a tag" + + - [ ] Type the next `cherry-picker-vX.Y.Z` version and select "**Create new tag: cherry-picker-vX.Y.Z** on publish" + + - [ ] Leave the "Release title" blank (it will be autofilled) + + - [ ] Click "Generate release notes" and amend as required + + - [ ] Click "Publish release" + +- [ ] Check the tagged [GitHub Actions build](https://github.com/python/cherry-picker/actions/workflows/deploy.yml) + has deployed to [PyPI](https://pypi.org/project/cherry_picker/#history) + +- [ ] Check installation: + + ```bash + python -m pip uninstall -y cherry_picker && python -m pip install -U cherry_picker && cherry_picker --version + ``` diff --git a/cherry_picker/__init__.py b/cherry_picker/__init__.py index bc80576..5a6ec37 100644 --- a/cherry_picker/__init__.py +++ b/cherry_picker/__init__.py @@ -1,2 +1,7 @@ """Backport CPython changes from main to maintenance branches.""" -__version__ = "2.1.0" + +from __future__ import annotations + +from ._version import __version__ + +__all__ = ["__version__"] diff --git a/cherry_picker/__main__.py b/cherry_picker/__main__.py index cc02b31..b5ff54f 100644 --- a/cherry_picker/__main__.py +++ b/cherry_picker/__main__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .cherry_picker import cherry_pick_cli if __name__ == "__main__": diff --git a/cherry_picker/cherry_picker.py b/cherry_picker/cherry_picker.py index 80921a7..2bc5a81 100755 --- a/cherry_picker/cherry_picker.py +++ b/cherry_picker/cherry_picker.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +from __future__ import annotations + import collections import enum import functools @@ -36,7 +38,7 @@ WORKFLOW_STATES = enum.Enum( - "Workflow states", + "WORKFLOW_STATES", """ FETCHING_UPSTREAM FETCHED_UPSTREAM @@ -93,7 +95,6 @@ class InvalidRepoException(Exception): class CherryPicker: - ALLOWED_STATES = WORKFLOW_STATES.BACKPORT_PAUSED, WORKFLOW_STATES.UNSET """The list of states expected at the start of the app.""" @@ -111,7 +112,6 @@ def __init__( chosen_config_path=None, auto_pr=True, ): - self.chosen_config_path = chosen_config_path """The config reference used in the current runtime. @@ -122,7 +122,6 @@ def __init__( self.config = config self.check_repo() # may raise InvalidRepoException - self.initial_state = self.get_state_and_verify() """The runtime state loaded from the config. Used to verify that we resume the process from the valid @@ -155,7 +154,7 @@ def set_paused_state(self): set_state(WORKFLOW_STATES.BACKPORT_PAUSED) def remember_previous_branch(self): - """Save the current branch into Git config to be able to get back to it later.""" + """Save the current branch into Git config, to be used later.""" current_branch = get_current_branch() save_cfg_vals_to_git_cfg(previous_branch=current_branch) @@ -164,7 +163,8 @@ def upstream(self): """Get the remote name to use for upstream branches Uses the remote passed to `--upstream-remote`. - If this flag wasn't passed, it uses "upstream" if it exists or "origin" otherwise. + If this flag wasn't passed, it uses "upstream" if it exists or "origin" + otherwise. """ # the cached calculated value of the property if self._upstream is not None: @@ -175,7 +175,7 @@ def upstream(self): cmd[-1] = self.upstream_remote try: - self.run_cmd(cmd) + self.run_cmd(cmd, required_real_result=True) except subprocess.CalledProcessError: if self.upstream_remote is not None: raise ValueError(f"There is no remote with name {cmd[-1]!r}.") @@ -200,16 +200,19 @@ def sorted_branches(self): @property def username(self): cmd = ["git", "config", "--get", f"remote.{self.pr_remote}.url"] - result = self.run_cmd(cmd) + result = self.run_cmd(cmd, required_real_result=True).strip() # implicit ssh URIs use : to separate host from user, others just use / - username = result.replace(":", "/").split("/")[-2] + username = result.replace(":", "/").rstrip("/").split("/")[-2] return username def get_cherry_pick_branch(self, maint_branch): return f"backport-{self.commit_sha1[:7]}-{maint_branch}" def get_pr_url(self, base_branch, head_branch): - return f"https://github.com/{self.config['team']}/{self.config['repo']}/compare/{base_branch}...{self.username}:{head_branch}?expand=1" + return ( + f"https://github.com/{self.config['team']}/{self.config['repo']}" + f"/compare/{base_branch}...{self.username}:{head_branch}?expand=1" + ) def fetch_upstream(self): """git fetch """ @@ -218,9 +221,9 @@ def fetch_upstream(self): self.run_cmd(cmd) set_state(WORKFLOW_STATES.FETCHED_UPSTREAM) - def run_cmd(self, cmd): + def run_cmd(self, cmd, required_real_result=False): assert not isinstance(cmd, str) - if self.dry_run: + if not required_real_result and self.dry_run: click.echo(f" dry-run: {' '.join(cmd)}") return output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) @@ -243,11 +246,11 @@ def checkout_branch(self, branch_name, *, create_branch=False): try: self.run_cmd(cmd) except subprocess.CalledProcessError as err: - click.echo( - f"Error checking out the branch {checked_out_branch!r}." - ) + click.echo(f"Error checking out the branch {checked_out_branch!r}.") click.echo(err.output) raise BranchCheckoutException(checked_out_branch) + if create_branch: + self.unset_upstream(checked_out_branch) def get_commit_message(self, commit_sha): """ @@ -256,13 +259,18 @@ def get_commit_message(self, commit_sha): """ cmd = ["git", "show", "-s", "--format=%B", commit_sha] try: - message = self.run_cmd(cmd).strip() + message = self.run_cmd(cmd, required_real_result=True).strip() except subprocess.CalledProcessError as err: click.echo(f"Error getting commit message for {commit_sha}") click.echo(err.output) raise CherryPickException(f"Error getting commit message for {commit_sha}") if self.config["fix_commit_msg"]: - return message.replace("#", "GH-") + # Only replace "#" with "GH-" with the following conditions: + # * "#" is separated from the previous word + # * "#" is followed by at least 5-digit number that + # does not start with 0 + # * the number is separated from the following word + return re.sub(r"\B#(?=[1-9][0-9]{4,}\b)", "GH-", message) else: return message @@ -326,12 +334,11 @@ def get_updated_commit_message(self, cherry_pick_branch): """ # Get the original commit message and prefix it with the branch name # if that's enabled. - commit_prefix = "" + updated_commit_message = self.get_commit_message(self.commit_sha1) if self.prefix_commit: - commit_prefix = ( - f"[{get_base_branch(cherry_pick_branch, config=self.config)}] " - ) - updated_commit_message = f"{commit_prefix}{self.get_commit_message(self.commit_sha1)}" + updated_commit_message = remove_commit_prefix(updated_commit_message) + base_branch = get_base_branch(cherry_pick_branch, config=self.config) + updated_commit_message = f"[{base_branch}] {updated_commit_message}" # Add '(cherry picked from commit ...)' to the message # and add new Co-authored-by trailer if necessary. @@ -357,7 +364,9 @@ def get_updated_commit_message(self, cherry_pick_branch): # # This needs to be done because `git interpret-trailers` required us to add `:` # to `cherry_pick_information` when we don't actually want it. - before, after = output.strip().decode().rsplit(f"\n{cherry_pick_information}", 1) + before, after = ( + output.strip().decode().rsplit(f"\n{cherry_pick_information}", 1) + ) if not before.endswith("\n"): # ensure that we still have a newline between cherry pick information # and commit headline @@ -367,7 +376,7 @@ def get_updated_commit_message(self, cherry_pick_branch): return updated_commit_message def amend_commit_message(self, cherry_pick_branch): - """ prefix the commit message with (X.Y) """ + """Prefix the commit message with (X.Y)""" updated_commit_message = self.get_updated_commit_message(cherry_pick_branch) if self.dry_run: @@ -437,6 +446,7 @@ def create_gh_pr(self, base_branch, head_branch, *, commit_message, gh_auth): request_headers = sansio.create_headers(self.username, oauth_token=gh_auth) title, body = normalize_commit_message(commit_message) if not self.prefix_commit: + title = remove_commit_prefix(title) title = f"[{base_branch}] {title}" data = { "title": title, @@ -446,11 +456,11 @@ def create_gh_pr(self, base_branch, head_branch, *, commit_message, gh_auth): "maintainer_can_modify": True, } url = CREATE_PR_URL_TEMPLATE.format(config=self.config) - response = requests.post(url, headers=request_headers, json=data) + response = requests.post(url, headers=request_headers, json=data, timeout=10) if response.status_code == requests.codes.created: response_data = response.json() click.echo(f"Backport PR created at {response_data['html_url']}") - self.pr_number = response_data['number'] + self.pr_number = response_data["number"] else: click.echo(response.status_code) click.echo(response.text) @@ -491,6 +501,13 @@ def cleanup_branch(self, branch): click.echo(f"branch {branch} has been deleted.") set_state(WORKFLOW_STATES.REMOVED_BACKPORT_BRANCH) + def unset_upstream(self, branch): + cmd = ["git", "branch", "--unset-upstream", branch] + try: + return self.run_cmd(cmd) + except subprocess.CalledProcessError as cpe: + click.echo(cpe.output) + def backport(self): if not self.branches: raise click.UsageError("At least one branch must be specified.") @@ -540,8 +557,13 @@ def abort_cherry_pick(self): """ run `git cherry-pick --abort` and then clean up the branch """ - if self.initial_state != WORKFLOW_STATES.BACKPORT_PAUSED: - raise ValueError("One can only abort a paused process.") + state = self.get_state_and_verify() + if state != WORKFLOW_STATES.BACKPORT_PAUSED: + raise ValueError( + f"One can only abort a paused process. " + f"Current state: {state}. " + f"Expected state: {WORKFLOW_STATES.BACKPORT_PAUSED}" + ) try: validate_sha("CHERRY_PICK_HEAD") @@ -570,8 +592,13 @@ def continue_cherry_pick(self): open the PR clean up branch """ - if self.initial_state != WORKFLOW_STATES.BACKPORT_PAUSED: - raise ValueError("One can only continue a paused process.") + state = self.get_state_and_verify() + if state != WORKFLOW_STATES.BACKPORT_PAUSED: + raise ValueError( + "One can only continue a paused process. " + f"Current state: {state}. " + f"Expected state: {WORKFLOW_STATES.BACKPORT_PAUSED}" + ) cherry_pick_branch = get_current_branch() if cherry_pick_branch.startswith("backport-"): @@ -618,7 +645,8 @@ def continue_cherry_pick(self): else: click.echo( - f"Current branch ({cherry_pick_branch}) is not a backport branch. Will not continue. \U0001F61B" + f"Current branch ({cherry_pick_branch}) is not a backport branch. " + "Will not continue. \U0001F61B" ) set_state(WORKFLOW_STATES.CONTINUATION_FAILED) @@ -630,13 +658,14 @@ def check_repo(self): """ Check that the repository is for the project we're configured to operate on. - This function performs the check by making sure that the sha specified in the config - is present in the repository that we're operating on. + This function performs the check by making sure that the sha specified in the + config is present in the repository that we're operating on. """ try: validate_sha(self.config["check_sha"]) - except ValueError: - raise InvalidRepoException() + self.get_state_and_verify() + except ValueError as ve: + raise InvalidRepoException(ve.args[0]) def get_state_and_verify(self): """Return the run progress state stored in the Git config. @@ -671,13 +700,13 @@ def is_mirror(self) -> bool: cmd = ["git", "config", "--local", "--get", "remote.origin.mirror"] try: - out = self.run_cmd(cmd) + out = self.run_cmd(cmd, required_real_result=True) except subprocess.CalledProcessError: return False return out.startswith("true") -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} @click.command(context_settings=CONTEXT_SETTINGS) @@ -817,7 +846,8 @@ def get_base_branch(cherry_pick_branch, *, config): if prefix != "backport": raise ValueError( - 'branch name is not prefixed with "backport-". Is this a cherry_picker branch?' + 'branch name is not prefixed with "backport-". ' + "Is this a cherry_picker branch?" ) if not re.match("[0-9a-f]{7,40}", sha): @@ -840,12 +870,13 @@ def validate_sha(sha): raises ValueError if the sha does not reference a commit within the repo """ - cmd = ["git", "log", "-r", sha] + cmd = ["git", "log", "--max-count=1", "-r", sha] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.SubprocessError: raise ValueError( - f"The sha listed in the branch name, {sha}, is not present in the repository" + f"The sha listed in the branch name, {sha}, " + "is not present in the repository" ) @@ -861,22 +892,20 @@ def compute_version_sort_key(config, branch): to oldest version. Branches without version information come second and are sorted alphabetically. """ - match = re.match(r"^.*(?P\d+(\.\d+)+).*$", branch) - try: - raw_version = match.groupdict()["version"].split(".") - except AttributeError as attr_err: - if not branch: - raise ValueError("Branch name is an empty string.") from attr_err - if config["require_version_in_branch_name"]: - raise ValueError( - f"Branch {branch} seems to not have a version in its name." - ) from attr_err - # Use 1 to sort regular branch names *after* version numbers - return (1, branch) - else: + m = re.search(r"\d+(?:\.\d+)+", branch) + if m: + raw_version = m[0].split(".") # Use 0 to sort version numbers *before* regular branch names return (0, *(-int(x) for x in raw_version)) + if not branch: + raise ValueError("Branch name is an empty string.") + if config["require_version_in_branch_name"]: + raise ValueError(f"Branch {branch} seems to not have a version in its name.") + + # Use 1 to sort regular branch names *after* version numbers + return (1, branch) + def get_current_branch(): """ @@ -912,12 +941,21 @@ def normalize_commit_message(commit_message): """ Return a tuple of title and body from the commit message """ - split_commit_message = commit_message.split("\n") - title = split_commit_message[0] - body = "\n".join(split_commit_message[1:]) + title, _, body = commit_message.partition("\n") return title, body.lstrip("\n") +def remove_commit_prefix(commit_message): + """ + Remove prefix "[X.Y] " from the commit message + """ + while True: + m = re.match(r"\[\d+(?:\.\d+)+\] *", commit_message) + if not m: + return commit_message + commit_message = commit_message[m.end() :] + + def is_git_repo(): """Check whether the current folder is a Git repo.""" cmd = "git", "rev-parse", "--git-dir" diff --git a/cherry_picker/test_cherry_picker.py b/cherry_picker/test_cherry_picker.py index 7e3c300..1c071f1 100644 --- a/cherry_picker/test_cherry_picker.py +++ b/cherry_picker/test_cherry_picker.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import os import pathlib +import re import subprocess import warnings from collections import ChainMap @@ -27,6 +30,7 @@ load_config, load_val_from_git_cfg, normalize_commit_message, + remove_commit_prefix, reset_state, reset_stored_config_ref, set_state, @@ -130,11 +134,12 @@ def tmp_git_repo_dir(tmpdir, cd, git_init, git_commit, git_config): except subprocess.CalledProcessError: version = subprocess.run(("git", "--version"), capture_output=True) # the output looks like "git version 2.34.1" - v = version.stdout.decode("utf-8").removeprefix('git version ').split('.') + v = version.stdout.decode("utf-8").removeprefix("git version ").split(".") if (int(v[0]), int(v[1])) < (2, 28): warnings.warn( "You need git 2.28.0 or newer to run the full test suite.", UserWarning, + stacklevel=2, ) git_config("--local", "user.name", "Monty Python") git_config("--local", "user.email", "bot@python.org") @@ -290,7 +295,9 @@ def test_get_cherry_pick_branch(os_path_exists, config): ("python", "python"), ), ) -def test_upstream_name(remote_name, upstream_remote, config, tmp_git_repo_dir, git_remote): +def test_upstream_name( + remote_name, upstream_remote, config, tmp_git_repo_dir, git_remote +): git_remote("add", remote_name, "https://github.com/python/cpython.git") if remote_name != "origin": git_remote("add", "origin", "https://github.com/miss-islington/cpython.git") @@ -318,10 +325,14 @@ def test_upstream_name(remote_name, upstream_remote, config, tmp_git_repo_dir, g (None, "python", None), ), ) -def test_error_on_missing_remote(remote_to_add, remote_name, upstream_remote, config, tmp_git_repo_dir, git_remote): +def test_error_on_missing_remote( + remote_to_add, remote_name, upstream_remote, config, tmp_git_repo_dir, git_remote +): git_remote("add", "some-remote-name", "https://github.com/python/cpython.git") if remote_to_add is not None: - git_remote("add", remote_to_add, "https://github.com/miss-islington/cpython.git") + git_remote( + "add", remote_to_add, "https://github.com/miss-islington/cpython.git" + ) branches = ["3.6"] with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): @@ -361,10 +372,17 @@ def test_get_pr_url(config): [ b"git@github.com:mock_user/cpython.git", b"git@github.com:mock_user/cpython", + b"git@github.com:mock_user/cpython/", b"ssh://git@github.com/mock_user/cpython.git", b"ssh://git@github.com/mock_user/cpython", + b"ssh://git@github.com/mock_user/cpython/", b"https://github.com/mock_user/cpython.git", b"https://github.com/mock_user/cpython", + b"https://github.com/mock_user/cpython/", + # test trailing whitespace + b"https://github.com/mock_user/cpython.git\n", + b"https://github.com/mock_user/cpython\n", + b"https://github.com/mock_user/cpython/\n", ], ) def test_username(url, config): @@ -382,12 +400,16 @@ def test_get_updated_commit_message(config): "origin", "22a594a0047d7706537ff2ac676cdc0f1dcb329c", branches, config=config ) with mock.patch( - "subprocess.check_output", return_value=b"bpo-123: Fix Spam Module (#113)" + "subprocess.check_output", + return_value=b"bpo-123: Fix#12345 #1234 #12345Number Sign (#01234) (#11345)", ): actual_commit_message = cp.get_commit_message( "22a594a0047d7706537ff2ac676cdc0f1dcb329c" ) - assert actual_commit_message == "bpo-123: Fix Spam Module (GH-113)" + assert ( + actual_commit_message + == "bpo-123: Fix#12345 #1234 #12345Number Sign (#01234) (GH-11345)" + ) def test_get_updated_commit_message_without_links_replacement(config): @@ -407,7 +429,8 @@ def test_get_updated_commit_message_without_links_replacement(config): @mock.patch("subprocess.check_output") def test_is_cpython_repo(subprocess_check_output): - subprocess_check_output.return_value = """commit 7f777ed95a19224294949e1b4ce56bbffcb1fe9f + subprocess_check_output.return_value = """\ +commit 7f777ed95a19224294949e1b4ce56bbffcb1fe9f Author: Guido van Rossum Date: Thu Aug 9 14:25:15 1990 +0000 @@ -524,7 +547,8 @@ def test_load_config_no_head_sha(tmp_git_repo_dir, git_add, git_commit): def test_normalize_long_commit_message(): - commit_message = """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) + commit_message = """\ +[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) The `Show Source` was broken because of a change made in sphinx 1.5.1 In Sphinx 1.4.9, the sourcename was "index.txt". @@ -550,7 +574,8 @@ def test_normalize_long_commit_message(): def test_normalize_short_commit_message(): - commit_message = """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) + commit_message = """\ +[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) @@ -569,6 +594,19 @@ def test_normalize_short_commit_message(): ) +@pytest.mark.parametrize( + "commit_message, expected", + [ + ("[3.12] Fix something (GH-3113)", "Fix something (GH-3113)"), + ("[3.11] [3.12] Fix something (GH-3113)", "Fix something (GH-3113)"), + ("Fix something (GH-3113)", "Fix something (GH-3113)"), + ("[WIP] Fix something (GH-3113)", "[WIP] Fix something (GH-3113)"), + ], +) +def test_remove_commit_prefix(commit_message, expected): + assert remove_commit_prefix(commit_message) == expected + + @pytest.mark.parametrize( "commit_message,expected_commit_message", ( @@ -637,24 +675,40 @@ def test_normalize_short_commit_message(): Co-authored-by: PR Author Co-authored-by: PR Co-Author """, ), + # ensure the existing commit prefix is replaced + ( + "[3.7] [3.8] Fix broken `Show Source` links on documentation " + "pages (GH-3113) (GH-3114) (GH-3115)", + """[3.6] Fix broken `Show Source` links on documentation """ + """pages (GH-3113) (GH-3114) (GH-3115) +(cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) + +Co-authored-by: PR Author """, + ), ), ) -def test_get_updated_commit_message_with_trailers(commit_message, expected_commit_message): +def test_get_updated_commit_message_with_trailers( + commit_message, expected_commit_message +): cherry_pick_branch = "backport-22a594a-3.6" commit = "b9ff498793611d1c6a9b99df464812931a1e2d69" with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): cherry_picker = CherryPicker("origin", commit, []) - with mock.patch( - "cherry_picker.cherry_picker.validate_sha", return_value=True - ), mock.patch.object( - cherry_picker, "get_commit_message", return_value=commit_message - ), mock.patch( - "cherry_picker.cherry_picker.get_author_info_from_short_sha", - return_value="PR Author ", + with ( + mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True), + mock.patch.object( + cherry_picker, "get_commit_message", return_value=commit_message + ), + mock.patch( + "cherry_picker.cherry_picker.get_author_info_from_short_sha", + return_value="PR Author ", + ), ): - updated_commit_message = cherry_picker.get_updated_commit_message(cherry_pick_branch) + updated_commit_message = cherry_picker.get_updated_commit_message( + cherry_pick_branch + ) assert updated_commit_message == expected_commit_message @@ -772,7 +826,7 @@ def test_start_end_states(method_name, start_state, end_state, tmp_git_repo_dir) cherry_picker.remember_previous_branch() assert get_state() == WORKFLOW_STATES.UNSET - def _fetch(cmd): + def _fetch(cmd, *args, **kwargs): assert get_state() == start_state with mock.patch.object(cherry_picker, "run_cmd", _fetch): @@ -793,7 +847,9 @@ def test_cleanup_branch(tmp_git_repo_dir, git_checkout): assert get_current_branch() == "main" -def test_cleanup_branch_checkout_previous_branch(tmp_git_repo_dir, git_checkout, git_worktree): +def test_cleanup_branch_checkout_previous_branch( + tmp_git_repo_dir, git_checkout, git_worktree +): assert get_state() == WORKFLOW_STATES.UNSET with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): @@ -819,7 +875,9 @@ def test_cleanup_branch_fail(tmp_git_repo_dir): assert get_state() == WORKFLOW_STATES.REMOVING_BACKPORT_BRANCH_FAILED -def test_cleanup_branch_checkout_fail(tmp_git_repo_dir, tmpdir, git_checkout, git_worktree): +def test_cleanup_branch_checkout_fail( + tmp_git_repo_dir, tmpdir, git_checkout, git_worktree +): assert get_state() == WORKFLOW_STATES.UNSET with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): @@ -874,7 +932,7 @@ class tested_state: set_state(tested_state) expected_msg_regexp = ( - fr"^Run state cherry-picker.state={tested_state.name} in Git config " + rf"^Run state cherry-picker.state={tested_state.name} in Git config " r"is not known." "\n" r"Perhaps it has been set by a newer " @@ -889,10 +947,11 @@ class tested_state: r"stored in Git config using the following command: " r"`git config --local --remove-section cherry-picker`" ) - with mock.patch( - "cherry_picker.cherry_picker.validate_sha", return_value=True - ), pytest.raises(ValueError, match=expected_msg_regexp): - cherry_picker = CherryPicker("origin", "xxx", []) + with ( + mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True), + pytest.raises(InvalidRepoException, match=expected_msg_regexp), + ): + CherryPicker("origin", "xxx", []) def test_push_to_remote_fail(tmp_git_repo_dir): @@ -907,9 +966,11 @@ def test_push_to_remote_interactive(tmp_git_repo_dir): with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): cherry_picker = CherryPicker("origin", "xxx", []) - with mock.patch.object(cherry_picker, "run_cmd"), mock.patch.object( - cherry_picker, "open_pr" - ), mock.patch.object(cherry_picker, "get_pr_url", return_value="https://pr_url"): + with ( + mock.patch.object(cherry_picker, "run_cmd"), + mock.patch.object(cherry_picker, "open_pr"), + mock.patch.object(cherry_picker, "get_pr_url", return_value="https://pr_url"), + ): cherry_picker.push_to_remote("main", "backport-branch-test") assert get_state() == WORKFLOW_STATES.PR_OPENING @@ -919,8 +980,9 @@ def test_push_to_remote_botflow(tmp_git_repo_dir, monkeypatch): with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): cherry_picker = CherryPicker("origin", "xxx", []) - with mock.patch.object(cherry_picker, "run_cmd"), mock.patch.object( - cherry_picker, "create_gh_pr" + with ( + mock.patch.object(cherry_picker, "run_cmd"), + mock.patch.object(cherry_picker, "create_gh_pr"), ): cherry_picker.push_to_remote("main", "backport-branch-test") assert get_state() == WORKFLOW_STATES.PR_CREATING @@ -931,8 +993,9 @@ def test_push_to_remote_no_auto_pr(tmp_git_repo_dir, monkeypatch): with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): cherry_picker = CherryPicker("origin", "xxx", [], auto_pr=False) - with mock.patch.object(cherry_picker, "run_cmd"), mock.patch.object( - cherry_picker, "create_gh_pr" + with ( + mock.patch.object(cherry_picker, "run_cmd"), + mock.patch.object(cherry_picker, "create_gh_pr"), ): cherry_picker.push_to_remote("main", "backport-branch-test") assert get_state() == WORKFLOW_STATES.PUSHED_TO_REMOTE @@ -970,10 +1033,13 @@ def test_backport_cherry_pick_fail( pr_remote, scm_revision, cherry_pick_target_branches ) - with pytest.raises(CherryPickException), mock.patch.object( - cherry_picker, "checkout_branch" - ), mock.patch.object(cherry_picker, "fetch_upstream"), mock.patch.object( - cherry_picker, "cherry_pick", side_effect=CherryPickException + with ( + pytest.raises(CherryPickException), + mock.patch.object(cherry_picker, "checkout_branch"), + mock.patch.object(cherry_picker, "fetch_upstream"), + mock.patch.object( + cherry_picker, "cherry_pick", side_effect=CherryPickException + ), ): cherry_picker.backport() @@ -1002,13 +1068,16 @@ def test_backport_cherry_pick_crash_ignored( pr_remote, scm_revision, cherry_pick_target_branches ) - with mock.patch.object(cherry_picker, "checkout_branch"), mock.patch.object( - cherry_picker, "fetch_upstream" - ), mock.patch.object(cherry_picker, "cherry_pick"), mock.patch.object( - cherry_picker, - "amend_commit_message", - side_effect=subprocess.CalledProcessError( - 1, ("git", "commit", "-am", "new commit message") + with ( + mock.patch.object(cherry_picker, "checkout_branch"), + mock.patch.object(cherry_picker, "fetch_upstream"), + mock.patch.object(cherry_picker, "cherry_pick"), + mock.patch.object( + cherry_picker, + "amend_commit_message", + side_effect=subprocess.CalledProcessError( + 1, ("git", "commit", "-am", "new commit message") + ), ), ): cherry_picker.backport() @@ -1037,12 +1106,15 @@ def test_backport_cherry_pick_branch_already_exists( pr_remote, scm_revision, cherry_pick_target_branches ) - backport_branch_name = cherry_picker.get_cherry_pick_branch(cherry_pick_target_branches[0]) + backport_branch_name = cherry_picker.get_cherry_pick_branch( + cherry_pick_target_branches[0] + ) git_branch(backport_branch_name) - with mock.patch.object(cherry_picker, "fetch_upstream"), pytest.raises( - BranchCheckoutException - ) as exc_info: + with ( + mock.patch.object(cherry_picker, "fetch_upstream"), + pytest.raises(BranchCheckoutException) as exc_info, + ): cherry_picker.backport() assert exc_info.value.branch_name == backport_branch_name @@ -1071,10 +1143,12 @@ def test_backport_success( pr_remote, scm_revision, cherry_pick_target_branches ) - with mock.patch.object(cherry_picker, "checkout_branch"), mock.patch.object( - cherry_picker, "fetch_upstream" - ), mock.patch.object( - cherry_picker, "amend_commit_message", return_value="commit message" + with ( + mock.patch.object(cherry_picker, "checkout_branch"), + mock.patch.object(cherry_picker, "fetch_upstream"), + mock.patch.object( + cherry_picker, "amend_commit_message", return_value="commit message" + ), ): cherry_picker.backport() @@ -1084,7 +1158,15 @@ def test_backport_success( @pytest.mark.parametrize("already_committed", (True, False)) @pytest.mark.parametrize("push", (True, False)) def test_backport_pause_and_continue( - tmp_git_repo_dir, git_branch, git_add, git_commit, git_checkout, git_reset, git_remote, already_committed, push + tmp_git_repo_dir, + git_branch, + git_add, + git_commit, + git_checkout, + git_reset, + git_remote, + already_committed, + push, ): cherry_pick_target_branches = ("3.8",) pr_remote = "origin" @@ -1106,8 +1188,11 @@ def test_backport_pause_and_continue( pr_remote, scm_revision, cherry_pick_target_branches, push=False ) - with mock.patch.object(cherry_picker, "fetch_upstream"), mock.patch.object( - cherry_picker, "amend_commit_message", return_value="commit message" + with ( + mock.patch.object(cherry_picker, "fetch_upstream"), + mock.patch.object( + cherry_picker, "amend_commit_message", return_value="commit message" + ), ): cherry_picker.backport() @@ -1116,7 +1201,9 @@ def test_backport_pause_and_continue( if not already_committed: git_reset("HEAD~1") - assert len(get_commits_from_backport_branch(cherry_pick_target_branches[0])) == 0 + assert ( + len(get_commits_from_backport_branch(cherry_pick_target_branches[0])) == 0 + ) with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): cherry_picker = CherryPicker(pr_remote, "", [], push=push) @@ -1127,26 +1214,26 @@ def test_backport_pause_and_continue( Co-authored-by: Author Name """ - with mock.patch( - "cherry_picker.cherry_picker.wipe_cfg_vals_from_git_cfg" - ), mock.patch( - "cherry_picker.cherry_picker.get_full_sha_from_short", - return_value="xxxxxxyyyyyy", - ), mock.patch( - "cherry_picker.cherry_picker.get_base_branch", return_value="3.8" - ), mock.patch( - "cherry_picker.cherry_picker.get_current_branch", - return_value="backport-xxx-3.8", - ), mock.patch.object( - cherry_picker, "amend_commit_message", return_value=commit_message - ) as amend_commit_message, mock.patch.object( - cherry_picker, "get_updated_commit_message", return_value=commit_message - ) as get_updated_commit_message, mock.patch.object( - cherry_picker, "checkout_branch" - ), mock.patch.object( - cherry_picker, "fetch_upstream" - ), mock.patch.object( - cherry_picker, "cleanup_branch" + with ( + mock.patch("cherry_picker.cherry_picker.wipe_cfg_vals_from_git_cfg"), + mock.patch( + "cherry_picker.cherry_picker.get_full_sha_from_short", + return_value="xxxxxxyyyyyy", + ), + mock.patch("cherry_picker.cherry_picker.get_base_branch", return_value="3.8"), + mock.patch( + "cherry_picker.cherry_picker.get_current_branch", + return_value="backport-xxx-3.8", + ), + mock.patch.object( + cherry_picker, "amend_commit_message", return_value=commit_message + ) as amend_commit_message, + mock.patch.object( + cherry_picker, "get_updated_commit_message", return_value=commit_message + ) as get_updated_commit_message, + mock.patch.object(cherry_picker, "checkout_branch"), + mock.patch.object(cherry_picker, "fetch_upstream"), + mock.patch.object(cherry_picker, "cleanup_branch"), ): cherry_picker.continue_cherry_pick() @@ -1171,7 +1258,9 @@ def test_continue_cherry_pick_invalid_state(tmp_git_repo_dir): assert get_state() == WORKFLOW_STATES.UNSET - with pytest.raises(ValueError, match=r"^One can only continue a paused process.$"): + with pytest.raises( + ValueError, match=re.compile(r"^One can only continue a paused process.") + ): cherry_picker.continue_cherry_pick() assert get_state() == WORKFLOW_STATES.UNSET # success @@ -1197,7 +1286,9 @@ def test_abort_cherry_pick_invalid_state(tmp_git_repo_dir): assert get_state() == WORKFLOW_STATES.UNSET - with pytest.raises(ValueError, match=r"^One can only abort a paused process.$"): + with pytest.raises( + ValueError, match=re.compile(r"^One can only abort a paused process.") + ): cherry_picker.abort_cherry_pick() diff --git a/pyproject.toml b/pyproject.toml index b531444..6c3d7c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,77 @@ [build-system] -requires = ["flit"] -build-backend = "flit.buildapi" +build-backend = "hatchling.build" +requires = [ + "hatch-vcs", + "hatchling", +] -[tool.flit.metadata] -module = "cherry_picker" -author = "Mariatta Wijaya" -author-email = "mariatta@python.org" -maintainer = "Python Core Developers" -maintainer-email = "core-workflow@python.org" -home-page = "https://github.com/python/cherry_picker" -requires = ["click>=6.0", "gidgethub", "requests", "tomli>=1.1.0;python_version<'3.11'"] -description-file = "readme.rst" -classifiers = ["Programming Language :: Python :: 3.7", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License"] -requires-python = ">=3.7" +[project] +name = "cherry-picker" +description = "Backport CPython changes from main to maintenance branches" +readme = "README.md" +maintainers = [ { name = "Python Core Developers", email = "core-workflow@python.org" } ] +authors = [ { name = "Mariatta Wijaya", email = "mariatta@python.org" } ] +requires-python = ">=3.9" +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dynamic = [ "version" ] +dependencies = [ + "click>=6", + "gidgethub", + "requests", + "tomli>=1.1; python_version<'3.11'", +] +optional-dependencies.dev = [ + "pytest", + "pytest-cov", +] +urls.Homepage = "https://github.com/python/cherry-picker" +scripts.cherry_picker = "cherry_picker.cherry_picker:cherry_pick_cli" +[tool.hatch.version] +source = "vcs" +# Change regex to match tags like "cherry-picker-v2.2.0". +tag-pattern = '^cherry-picker-(?P[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$' -[tool.flit.scripts] -cherry_picker = "cherry_picker.cherry_picker:cherry_pick_cli" +[tool.hatch.build.hooks.vcs] +version-file = "cherry_picker/_version.py" -[tool.flit.metadata.requires-extra] -dev = ["pytest", "pytest-cov"] +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" + +[tool.ruff] +fix = true +lint.select = [ + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "F", # pyflakes errors + "I", # isort + "ICN", # flake8-import-conventions + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PGH", # pygrep-hooks + "PYI", # flake8-pyi + "RUF022", # unsorted-dunder-all + "RUF100", # unused noqa (yesqa) + "S", # flake8-bandit + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 +] +lint.ignore = [ + "S101", # Use of assert detected + "S404", # subprocess module is possibly insecure + "S603", # subprocess call: check for execution of untrusted input +] +lint.isort.required-imports = [ "from __future__ import annotations" ] + +[tool.pyproject-fmt] +max_supported_python = "3.13" diff --git a/pytest.ini b/pytest.ini index af8028e..bdb4f56 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,9 +3,5 @@ norecursedirs = dist docs build .git .eggs .tox addopts = --durations=10 -v -rxXs --doctest-modules filterwarnings = error - # 3.11: Pending release of https://github.com/certifi/python-certifi/pull/199 - ignore:path is deprecated. Use files\(\) instead.*:DeprecationWarning - # 3.11: Pending release of https://github.com/brettcannon/gidgethub/pull/185 - ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning junit_duration_report = call junit_suite_name = cherry_picker_test_suite diff --git a/readme.rst b/readme.rst deleted file mode 100644 index a3c9034..0000000 --- a/readme.rst +++ /dev/null @@ -1,464 +0,0 @@ -Usage (from a cloned CPython directory) :: - - cherry_picker [--pr-remote REMOTE] [--upstream-remote REMOTE] [--dry-run] [--config-path CONFIG-PATH] [--status] [--abort/--continue] [--push/--no-push] [--auto-pr/--no-auto-pr] - -|pyversion status| -|pypi status| -|github actions status| - -.. contents:: - -About -===== - -This tool is used to backport CPython changes from ``main`` into one or more -of the maintenance branches (``3.6``, ``3.5``, ``2.7``). - -``cherry_picker`` can be configured to backport other projects with similar -workflow as CPython. See the configuration file options below for more details. - -The maintenance branch names should contain some sort of version number (X.Y). -For example: ``3.6``, ``3.5``, ``2.7``, ``stable-2.6``, ``2.5-lts``, are all -supported branch names. - -It will prefix the commit message with the branch, e.g. ``[3.6]``, and then -opens up the pull request page. - -Tests are to be written using `pytest `_. - - -Setup Info -========== - -Requires Python 3.7. - -.. code-block:: console - - $ python3 -m venv venv - $ source venv/bin/activate - (venv) $ python -m pip install cherry_picker - -The cherry picking script assumes that if an ``upstream`` remote is defined, then -it should be used as the source of upstream changes and as the base for -cherry-pick branches. Otherwise, ``origin`` is used for that purpose. -You can override this behavior with the ``--upstream-remote`` option -(e.g. ``--upstream-remote python`` to use a remote named ``python``). - -Verify that an ``upstream`` remote is set to the CPython repository: - -.. code-block:: console - - $ git remote -v - ... - upstream https://github.com/python/cpython (fetch) - upstream https://github.com/python/cpython (push) - -If needed, create the ``upstream`` remote: - -.. code-block:: console - - $ git remote add upstream https://github.com/python/cpython.git - - -By default, the PR branches used to submit pull requests back to the main -repository are pushed to ``origin``. If this is incorrect, then the correct -remote will need be specified using the ``--pr-remote`` option (e.g. -``--pr-remote pr`` to use a remote named ``pr``). - - -Cherry-picking 🐍🍒⛏️ -===================== - -(Setup first! See prev section) - -From the cloned CPython directory: - -.. code-block:: console - - (venv) $ cherry_picker [--pr-remote REMOTE] [--upstream-remote REMOTE] [--dry-run] [--config-path CONFIG-PATH] [--abort/--continue] [--status] [--push/--no-push] [--auto-pr/--no-auto-pr] - - -Commit sha1 ------------ - -The commit sha1 for cherry-picking is the squashed commit that was merged to -the ``main`` branch. On the merged pull request, scroll to the bottom of the -page. Find the event that says something like:: - - merged commit into python:main ago. - -By following the link to ````, you will get the full commit hash. -Use the full commit hash for ``cherry_picker.py``. - - -Options -------- - -:: - - --dry-run Dry Run Mode. Prints out the commands, but not executed. - --pr-remote REMOTE Specify the git remote to push into. Default is 'origin'. - --upstream-remote REMOTE Specify the git remote to use for upstream branches. - Default is 'upstream' or 'origin' if the former doesn't exist. - --status Do `git status` in cpython directory. - - -Additional options:: - - --abort Abort current cherry-pick and clean up branch - --continue Continue cherry-pick, push, and clean up branch - --no-push Changes won't be pushed to remote - --no-auto-pr PR creation page won't be automatically opened in the web browser or - if GH_AUTH is set, the PR won't be automatically opened through API. - --config-path Path to config file - (`.cherry_picker.toml` from project root by default) - - -Configuration file example: - -.. code-block:: toml - - team = "aio-libs" - repo = "aiohttp" - check_sha = "f382b5ffc445e45a110734f5396728da7914aeb6" - fix_commit_msg = false - default_branch = "devel" - require_version_in_branch_name = false - - -Available config options:: - - team github organization or individual nick, - e.g "aio-libs" for https://github.com/aio-libs/aiohttp - ("python" by default) - - repo github project name, - e.g "aiohttp" for https://github.com/aio-libs/aiohttp - ("cpython" by default) - - check_sha A long hash for any commit from the repo, - e.g. a sha1 hash from the very first initial commit - ("7f777ed95a19224294949e1b4ce56bbffcb1fe9f" by default) - - fix_commit_msg Replace # with GH- in cherry-picked commit message. - It is the default behavior for CPython because of external - Roundup bug tracker (https://bugs.python.org) behavior: - #xxxx should point on issue xxxx but GH-xxxx points - on pull-request xxxx. - For projects using GitHub Issues, this option can be disabled. - - default_branch Project's default branch name, - e.g "devel" for https://github.com/ansible/ansible - ("main" by default) - - require_version_in_branch_name Allow backporting to branches whose names don't contain - something that resembles a version number - (i.e. at least two dot-separated numbers). - - -To customize the tool for used by other project: - -1. Create a file called ``.cherry_picker.toml`` in the project's root - folder (alongside with ``.git`` folder). - -2. Add ``team``, ``repo``, ``fix_commit_msg``, ``check_sha`` and - ``default_branch`` config values as described above. - -3. Use ``git add .cherry_picker.toml`` / ``git commit`` to add the config - into ``git``. - -4. Add ``cherry_picker`` to development dependencies or install it - by ``pip install cherry_picker`` - -5. Now everything is ready, use ``cherry_picker - `` for cherry-picking changes from ```` into - maintenance branches. - Branch name should contain at least major and minor version numbers - and may have some prefix or suffix. - Only the first version-like substring is matched when the version - is extracted from branch name. - -Demo ----- - -- Installation: https://asciinema.org/a/125254 - -- Backport: https://asciinema.org/a/125256 - - -Example -------- - -For example, to cherry-pick ``6de2b7817f-some-commit-sha1-d064`` into -``3.5`` and ``3.6``, run the following command from the cloned CPython -directory: - -.. code-block:: console - - (venv) $ cherry_picker 6de2b7817f-some-commit-sha1-d064 3.5 3.6 - - -What this will do: - -.. code-block:: console - - (venv) $ git fetch upstream - - (venv) $ git checkout -b backport-6de2b78-3.5 upstream/3.5 - (venv) $ git cherry-pick -x 6de2b7817f-some-commit-sha1-d064 - (venv) $ git push origin backport-6de2b78-3.5 - (venv) $ git checkout main - (venv) $ git branch -D backport-6de2b78-3.5 - - (venv) $ git checkout -b backport-6de2b78-3.6 upstream/3.6 - (venv) $ git cherry-pick -x 6de2b7817f-some-commit-sha1-d064 - (venv) $ git push origin backport-6de2b78-3.6 - (venv) $ git checkout main - (venv) $ git branch -D backport-6de2b78-3.6 - -In case of merge conflicts or errors, the following message will be displayed:: - - Failed to cherry-pick 554626ada769abf82a5dabe6966afa4265acb6a6 into 2.7 :frowning_face: - ... Stopping here. - - To continue and resolve the conflict: - $ cherry_picker --status # to find out which files need attention - # Fix the conflict - $ cherry_picker --status # should now say 'all conflict fixed' - $ cherry_picker --continue - - To abort the cherry-pick and cleanup: - $ cherry_picker --abort - - -Passing the ``--dry-run`` option will cause the script to print out all the -steps it would execute without actually executing any of them. For example: - -.. code-block:: console - - $ cherry_picker --dry-run --pr-remote pr 1e32a1be4a1705e34011770026cb64ada2d340b5 3.6 3.5 - Dry run requested, listing expected command sequence - fetching upstream ... - dry_run: git fetch origin - Now backporting '1e32a1be4a1705e34011770026cb64ada2d340b5' into '3.6' - dry_run: git checkout -b backport-1e32a1b-3.6 origin/3.6 - dry_run: git cherry-pick -x 1e32a1be4a1705e34011770026cb64ada2d340b5 - dry_run: git push pr backport-1e32a1b-3.6 - dry_run: Create new PR: https://github.com/python/cpython/compare/3.6...ncoghlan:backport-1e32a1b-3.6?expand=1 - dry_run: git checkout main - dry_run: git branch -D backport-1e32a1b-3.6 - Now backporting '1e32a1be4a1705e34011770026cb64ada2d340b5' into '3.5' - dry_run: git checkout -b backport-1e32a1b-3.5 origin/3.5 - dry_run: git cherry-pick -x 1e32a1be4a1705e34011770026cb64ada2d340b5 - dry_run: git push pr backport-1e32a1b-3.5 - dry_run: Create new PR: https://github.com/python/cpython/compare/3.5...ncoghlan:backport-1e32a1b-3.5?expand=1 - dry_run: git checkout main - dry_run: git branch -D backport-1e32a1b-3.5 - -`--pr-remote` option --------------------- - -This will generate pull requests through a remote other than ``origin`` -(e.g. ``pr``) - -`--upstream-remote` option --------------------------- - -This will generate branches from a remote other than ``upstream``/``origin`` -(e.g. ``python``) - -`--status` option ------------------ - -This will do ``git status`` for the CPython directory. - -`--abort` option ----------------- - -Cancels the current cherry-pick and cleans up the cherry-pick branch. - -`--continue` option -------------------- - -Continues the current cherry-pick, commits, pushes the current branch to -``origin``, opens the PR page, and cleans up the branch. - -`--no-push` option ------------------- - -Changes won't be pushed to remote. This allows you to test and make additional -changes. Once you're satisfied with local changes, use ``--continue`` to complete -the backport, or ``--abort`` to cancel and clean up the branch. You can also -cherry-pick additional commits, by: - -.. code-block:: console - - $ git cherry-pick -x - -`--no-auto-pr` option ---------------------- - -PR creation page won't be automatically opened in the web browser or -if GH_AUTH is set, the PR won't be automatically opened through API. -This can be useful if your terminal is not capable of opening a useful web browser, -or if you use cherry-picker with a different Git hosting than GitHub. - -`--config-path` option ----------------------- - -Allows to override default config file path -(``/.cherry_picker.toml``) with a custom one. This allows cherry_picker -to backport projects other than CPython. - - -Creating Pull Requests -====================== - -When a cherry-pick was applied successfully, this script will open up a browser -tab that points to the pull request creation page. - -The url of the pull request page looks similar to the following:: - - https://github.com/python/cpython/compare/3.5...:backport-6de2b78-3.5?expand=1 - - -Press the ``Create Pull Request`` button. - -Bedevere will then remove the ``needs backport to ...`` label from the original -pull request against ``main``. - - -Running Tests -============= - -Install pytest: ``pip install -U pytest`` - -.. code-block:: console - - $ pytest - -Tests require your local version of ``git`` to be ``2.28.0+``. - -Publishing to PyPI -================== - -- Create a new release branch. - -- Update the version info in ``__init__.py`` and ``readme.rst``, dropping the ``.dev``. - -- Tag the branch as ``cherry-picker-vX.Y.Z``. - - -Local installation -================== - -With `flit `_ installed, -in the directory where ``pyproject.toml`` exists: - -.. code-block:: console - - $ flit install - - -.. |pyversion status| image:: https://img.shields.io/pypi/pyversions/cherry-picker.svg - :target: https://pypi.org/project/cherry-picker/ - -.. |pypi status| image:: https://img.shields.io/pypi/v/cherry-picker.svg - :target: https://pypi.org/project/cherry-picker/ - -.. |github actions status| image:: https://github.com/python/cherry-picker/actions/workflows/main.yml/badge.svg - :target: https://github.com/python/cherry-picker/actions/workflows/main.yml - -Changelog -========= - -2.1.0 ------ - -- Mix fixes: #28, #29, #31, #32, #33, #34, #36. - -2.0.0 ------ - -- Support the ``main`` branch by default. (`PR 23 `_) - To use a different default branch, please configure it in the - ``.cherry-picker.toml`` file. - -- Renamed ``cherry-picker``'s own default branch to ``main``. - -1.3.2 ------ - -- Use ``--no-tags`` option when fetching upstream. (`PR 319 `_) - -1.3.1 ------ - -- Modernize cherry_picker's pyproject.toml file. (`PR #316 `_) - -- Remove the ``BACKPORT_COMPLETE`` state. Unset the states when backport is completed. - (`PR #315 `_) - -- Run Travis CI test on Windows (`PR #311 `_). - -1.3.0 ------ - -- Implement state machine and storing reference to the config - used at the beginning of the backport process using commit sha - and a repo-local Git config. - (`PR #295 `_). - -1.2.2 ------ - -- Relaxed click dependency (`PR #302 `_). - -1.2.1 ------ - -- Validate the branch name to operate on with ``--continue`` and fail early if the branch could not - have been created by cherry_picker. (`PR #266 `_). - -- Bugfix: Allow ``--continue`` to support version branches that have dashes in them. This is - a bugfix of the additional branch versioning schemes introduced in 1.2.0. - (`PR #265 `_). - -- Bugfix: Be explicit about the branch name on the remote to push the cherry pick to. This allows - cherry_picker to work correctly when the user has a git push strategy other than the default - configured. (`PR #264 `_). - -1.2.0 ------ - -- Add ``default_branch`` configuration item. The default is ``master``, which - is the default branch for CPython. It can be configured to other branches like, - ``devel``, or ``develop``. The default branch is the branch cherry_picker - will return to after backporting. (`PR #254 `_ - and `Issue #250 `_). - -- Support additional branch versioning schemes, such as ``something-X.Y``, - or ``X.Y-somethingelse``. (`PR #253 `_ - and `Issue #251 `_). - -1.1.1 ------ - -- Change the calls to ``subprocess`` to use lists instead of strings. This fixes - the bug that affects users in Windows. (`PR #238 `_). - -1.1.0 ------ - -- Add ``fix_commit_msg`` configuration item. Setting fix_commit_msg to ``true`` - will replace the issue number in the commit message, from ``#`` to ``GH-``. - This is the default behavior for CPython. Other projects can opt out by - setting it to ``false``. (`PR #233 `_ - and `aiohttp Issue #2853 `_). - -1.0.0 ------ - -- Support configuration file by using ``--config-path`` option, or by adding - ``.cherry-picker.toml`` file to the root of the project. (`Issue #225 - `_). diff --git a/tox.ini b/tox.ini index b5ddc4c..812ab48 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,18 @@ [tox] -envlist = - py{311, 310, 39, 38, 37} -isolated_build = true +requires = + tox>=4.2 +env_list = + py{313, 312, 311, 310, 39} [testenv] -passenv = - FORCE_COLOR extras = dev +pass_env = + FORCE_COLOR commands = - {envpython} -m pytest --cov cherry_picker --cov-report html --cov-report term --cov-report xml {posargs} + {envpython} -m pytest \ + --cov cherry_picker \ + --cov-report html \ + --cov-report term \ + --cov-report xml \ + {posargs}