PKL[ZZcherry_picker/__init__.py"""Backport CPython changes from master to maintenance branches.""" __version__ = '1.2.0' PKkLh7$]]cherry_picker/__main__.pyfrom .cherry_picker import cherry_pick_cli if __name__ == '__main__': cherry_pick_cli() PKYLM+:B:Bcherry_picker/cherry_picker.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- import click import collections import os import pathlib import subprocess import webbrowser import re import sys import requests import toml from gidgethub import sansio from . import __version__ CREATE_PR_URL_TEMPLATE = ("https://api.github.com/repos/" "{config[team]}/{config[repo]}/pulls") DEFAULT_CONFIG = collections.ChainMap({ 'team': 'python', 'repo': 'cpython', 'check_sha': '7f777ed95a19224294949e1b4ce56bbffcb1fe9f', 'fix_commit_msg': True, 'default_branch': 'master', }) class BranchCheckoutException(Exception): pass class CherryPickException(Exception): pass class InvalidRepoException(Exception): pass class CherryPicker: def __init__(self, pr_remote, commit_sha1, branches, *, dry_run=False, push=True, prefix_commit=True, config=DEFAULT_CONFIG, ): self.config = config self.check_repo() # may raise InvalidRepoException if dry_run: click.echo("Dry run requested, listing expected command sequence") self.pr_remote = pr_remote self.commit_sha1 = commit_sha1 self.branches = branches self.dry_run = dry_run self.push = push self.prefix_commit = prefix_commit @property def upstream(self): """Get the remote name to use for upstream branches Uses "upstream" if it exists, "origin" otherwise """ cmd = ['git', 'remote', 'get-url', 'upstream'] try: subprocess.check_output(cmd, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: return "origin" return "upstream" @property def sorted_branches(self): def version_from_branch(branch): try: return tuple(map(int, re.match(r'^.*(?P\d+(\.\d+)+).*$', branch).groupdict()['version'].split('.'))) except AttributeError as attr_err: raise ValueError(f'Branch {branch} seems to not have a version in its name.') from attr_err return sorted( self.branches, reverse=True, key=version_from_branch) @property def username(self): cmd = ['git', 'config', '--get', f'remote.{self.pr_remote}.url'] raw_result = subprocess.check_output(cmd, stderr=subprocess.STDOUT) result = raw_result.decode('utf-8') # implicit ssh URIs use : to separate host from user, others just use / username = result.replace(':', '/').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" def fetch_upstream(self): """ git fetch """ cmd = ['git', 'fetch', self.upstream] self.run_cmd(cmd) def run_cmd(self, cmd): assert not isinstance(cmd, str) if self.dry_run: click.echo(f" dry-run: {' '.join(cmd)}") return output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) click.echo(output.decode('utf-8')) def checkout_branch(self, branch_name): """ git checkout -b """ cmd = ['git', 'checkout', '-b', self.get_cherry_pick_branch(branch_name), f'{self.upstream}/{branch_name}'] try: self.run_cmd(cmd) except subprocess.CalledProcessError as err: click.echo(f"Error checking out the branch {self.get_cherry_pick_branch(branch_name)}.") click.echo(err.output) raise BranchCheckoutException(f"Error checking out the branch {self.get_cherry_pick_branch(branch_name)}.") def get_commit_message(self, commit_sha): """ Return the commit message for the current commit hash, replace # with GH- """ cmd = ['git', 'show', '-s', '--format=%B', commit_sha] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) message = output.strip().decode('utf-8') if self.config['fix_commit_msg']: return message.replace('#', 'GH-') else: return message def checkout_default_branch(self): """ git checkout default branch """ cmd = 'git', 'checkout', self.config['default_branch'] self.run_cmd(cmd) def status(self): """ git status :return: """ cmd = ['git', 'status'] self.run_cmd(cmd) def cherry_pick(self): """ git cherry-pick -x """ cmd = ['git', 'cherry-pick', '-x', self.commit_sha1] try: self.run_cmd(cmd) except subprocess.CalledProcessError as err: click.echo(f"Error cherry-pick {self.commit_sha1}.") click.echo(err.output) raise CherryPickException(f"Error cherry-pick {self.commit_sha1}.") def get_exit_message(self, branch): return \ f""" Failed to cherry-pick {self.commit_sha1} into {branch} \u2639 ... 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 """ def amend_commit_message(self, cherry_pick_branch): """ prefix the commit message with (X.Y) """ commit_prefix = "" if self.prefix_commit: commit_prefix = f"[{get_base_branch(cherry_pick_branch)}] " updated_commit_message = f"""{commit_prefix}{self.get_commit_message(self.commit_sha1)} (cherry picked from commit {self.commit_sha1}) Co-authored-by: {get_author_info_from_short_sha(self.commit_sha1)}""" if self.dry_run: click.echo(f" dry-run: git commit --amend -m '{updated_commit_message}'") else: cmd = ['git', 'commit', '--amend', '-m', updated_commit_message] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as cpe: click.echo("Failed to amend the commit message \u2639") click.echo(cpe.output) return updated_commit_message def push_to_remote(self, base_branch, head_branch, commit_message=""): """ git push """ cmd = ['git', 'push', self.pr_remote, head_branch] try: self.run_cmd(cmd) except subprocess.CalledProcessError: click.echo(f"Failed to push to {self.pr_remote} \u2639") else: gh_auth = os.getenv("GH_AUTH") if gh_auth: self.create_gh_pr(base_branch, head_branch, commit_message=commit_message, gh_auth=gh_auth) else: self.open_pr(self.get_pr_url(base_branch, head_branch)) def create_gh_pr(self, base_branch, head_branch, *, commit_message, gh_auth): """ Create PR in GitHub """ request_headers = sansio.create_headers( self.username, oauth_token=gh_auth) title, body = normalize_commit_message(commit_message) if not self.prefix_commit: title = f"[{base_branch}] {title}" data = { "title": title, "body": body, "head": f"{self.username}:{head_branch}", "base": base_branch, "maintainer_can_modify": True } url = CREATE_PR_URL_TEMPLATE.format(config=self.config) response = requests.post(url, headers=request_headers, json=data) if response.status_code == requests.codes.created: click.echo(f"Backport PR created at {response.json()['html_url']}") else: click.echo(response.status_code) click.echo(response.text) def open_pr(self, url): """ open url in the web browser """ if self.dry_run: click.echo(f" dry-run: Create new PR: {url}") else: click.echo("Backport PR URL:") click.echo(url) webbrowser.open_new_tab(url) def delete_branch(self, branch): cmd = ['git', 'branch', '-D', branch] self.run_cmd(cmd) def cleanup_branch(self, branch): self.checkout_default_branch() try: self.delete_branch(branch) except subprocess.CalledProcessError: click.echo(f"branch {branch} NOT deleted.") else: click.echo(f"branch {branch} has been deleted.") def backport(self): if not self.branches: raise click.UsageError("At least one branch must be specified.") self.fetch_upstream() for maint_branch in self.sorted_branches: click.echo(f"Now backporting '{self.commit_sha1}' into '{maint_branch}'") cherry_pick_branch = self.get_cherry_pick_branch(maint_branch) self.checkout_branch(maint_branch) commit_message = "" try: self.cherry_pick() commit_message = self.amend_commit_message(cherry_pick_branch) except subprocess.CalledProcessError as cpe: click.echo(cpe.output) click.echo(self.get_exit_message(maint_branch)) except CherryPickException: click.echo(self.get_exit_message(maint_branch)) raise else: if self.push: self.push_to_remote(maint_branch, cherry_pick_branch, commit_message) self.cleanup_branch(cherry_pick_branch) else: click.echo(\ f""" Finished cherry-pick {self.commit_sha1} into {cherry_pick_branch} \U0001F600 --no-push option used. ... Stopping here. To continue and push the changes: $ cherry_picker --continue To abort the cherry-pick and cleanup: $ cherry_picker --abort """) def abort_cherry_pick(self): """ run `git cherry-pick --abort` and then clean up the branch """ cmd = ['git', 'cherry-pick', '--abort'] try: self.run_cmd(cmd) except subprocess.CalledProcessError as cpe: click.echo(cpe.output) # only delete backport branch created by cherry_picker.py if get_current_branch().startswith('backport-'): self.cleanup_branch(get_current_branch()) def continue_cherry_pick(self): """ git push origin open the PR clean up branch """ cherry_pick_branch = get_current_branch() if cherry_pick_branch.startswith('backport-'): # amend the commit message, prefix with [X.Y] base = get_base_branch(cherry_pick_branch) short_sha = cherry_pick_branch[cherry_pick_branch.index('-')+1:cherry_pick_branch.index(base)-1] full_sha = get_full_sha_from_short(short_sha) commit_message = self.get_commit_message(short_sha) co_author_info = f"Co-authored-by: {get_author_info_from_short_sha(short_sha)}" updated_commit_message = f"""[{base}] {commit_message}. (cherry picked from commit {full_sha}) {co_author_info}""" if self.dry_run: click.echo(f" dry-run: git commit -a -m '{updated_commit_message}' --allow-empty") else: cmd = ['git', 'commit', '-a', '-m', updated_commit_message, '--allow-empty'] subprocess.check_output(cmd, stderr=subprocess.STDOUT) self.push_to_remote(base, cherry_pick_branch) self.cleanup_branch(cherry_pick_branch) click.echo("\nBackport PR:\n") click.echo(updated_commit_message) else: click.echo(f"Current branch ({cherry_pick_branch}) is not a backport branch. Will not continue. \U0001F61B") def check_repo(self): # CPython repo has a commit with # SHA=7f777ed95a19224294949e1b4ce56bbffcb1fe9f cmd = ['git', 'log', '-r', self.config['check_sha']] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.SubprocessError: raise InvalidRepoException() CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @click.command(context_settings=CONTEXT_SETTINGS) @click.version_option(version=__version__) @click.option('--dry-run', is_flag=True, help="Prints out the commands, but not executed.") @click.option('--pr-remote', 'pr_remote', metavar='REMOTE', help='git remote to use for PR branches', default='origin') @click.option('--abort', 'abort', flag_value=True, default=None, help="Abort current cherry-pick and clean up branch") @click.option('--continue', 'abort', flag_value=False, default=None, help="Continue cherry-pick, push, and clean up branch") @click.option('--status', 'status', flag_value=True, default=None, help="Get the status of cherry-pick") @click.option('--push/--no-push', 'push', is_flag=True, default=True, help="Changes won't be pushed to remote") @click.option('--config-path', 'config_path', metavar='CONFIG-PATH', help=("Path to config file, .cherry_picker.toml " "from project root by default"), default=None) @click.argument('commit_sha1', 'The commit sha1 to be cherry-picked', nargs=1, default = "") @click.argument('branches', 'The branches to backport to', nargs=-1) def cherry_pick_cli(dry_run, pr_remote, abort, status, push, config_path, commit_sha1, branches): click.echo("\U0001F40D \U0001F352 \u26CF") config = load_config(config_path) try: cherry_picker = CherryPicker(pr_remote, commit_sha1, branches, dry_run=dry_run, push=push, config=config) except InvalidRepoException: click.echo(f"You're not inside a {config['repo']} repo right now! \U0001F645") sys.exit(-1) if abort is not None: if abort: cherry_picker.abort_cherry_pick() else: cherry_picker.continue_cherry_pick() elif status: click.echo(cherry_picker.status()) else: try: cherry_picker.backport() except BranchCheckoutException: sys.exit(-1) except CherryPickException: sys.exit(-1) def get_base_branch(cherry_pick_branch): """ return '2.7' from 'backport-sha-2.7' """ prefix, sep, base_branch = cherry_pick_branch.rpartition('-') return base_branch def get_current_branch(): """ Return the current branch """ cmd = ['git', 'rev-parse', '--abbrev-ref', 'HEAD'] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) return output.strip().decode('utf-8') def get_full_sha_from_short(short_sha): cmd = ['git', 'log', '-1', '--format=%H', short_sha] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) full_sha = output.strip().decode('utf-8') return full_sha def get_author_info_from_short_sha(short_sha): cmd = ['git', 'log', '-1', '--format=%aN <%ae>', short_sha] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) author = output.strip().decode('utf-8') return author 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:]) return title, body.lstrip("\n") def find_project_root(): cmd = ['git', 'rev-parse', '--show-toplevel'] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) return pathlib.Path(output.decode('utf-8').strip()) def find_config(): root = find_project_root() if root is not None: child = root / '.cherry_picker.toml' if child.exists() and not child.is_dir(): return child return None def load_config(path): if path is None: path = find_config() if path is None: return DEFAULT_CONFIG else: path = pathlib.Path(path) # enforce a cast to pathlib datatype with path.open() as f: d = toml.load(f) return DEFAULT_CONFIG.new_child(d) if __name__ == '__main__': cherry_pick_cli() PKYL$$cherry_picker/test.pyimport os import pathlib import subprocess from collections import ChainMap from unittest import mock import pytest from .cherry_picker import get_base_branch, get_current_branch, \ get_full_sha_from_short, get_author_info_from_short_sha, \ CherryPicker, InvalidRepoException, \ normalize_commit_message, DEFAULT_CONFIG, \ find_project_root, find_config, load_config @pytest.fixture def config(): check_sha = 'dc896437c8efe5a4a5dfa50218b7a6dc0cbe2598' return ChainMap(DEFAULT_CONFIG).new_child({'check_sha': check_sha}) @pytest.fixture def cd(): cwd = os.getcwd() def changedir(d): os.chdir(d) yield changedir # restore CWD back os.chdir(cwd) def test_get_base_branch(): cherry_pick_branch = 'backport-afc23f4-2.7' result = get_base_branch(cherry_pick_branch) assert result == '2.7' def test_get_base_branch_without_dash(): cherry_pick_branch ='master' result = get_base_branch(cherry_pick_branch) assert result == 'master' @mock.patch('subprocess.check_output') def test_get_current_branch(subprocess_check_output): subprocess_check_output.return_value = b'master' assert get_current_branch() == 'master' @mock.patch('subprocess.check_output') def test_get_full_sha_from_short(subprocess_check_output): mock_output = b"""22a594a0047d7706537ff2ac676cdc0f1dcb329c""" subprocess_check_output.return_value = mock_output assert get_full_sha_from_short('22a594a') == '22a594a0047d7706537ff2ac676cdc0f1dcb329c' @mock.patch('subprocess.check_output') def test_get_author_info_from_short_sha(subprocess_check_output): mock_output = b"Armin Rigo " subprocess_check_output.return_value = mock_output assert get_author_info_from_short_sha('22a594a') == 'Armin Rigo ' @pytest.mark.parametrize('input_branches,sorted_branches', [ (['3.1', '2.7', '3.10', '3.6'], ['3.10', '3.6', '3.1', '2.7']), (['stable-3.1', 'lts-2.7', '3.10-other', 'smth3.6else'], ['3.10-other', 'smth3.6else', 'stable-3.1', 'lts-2.7']), ]) @mock.patch('os.path.exists') def test_sorted_branch(os_path_exists, config, input_branches, sorted_branches): os_path_exists.return_value = True cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', input_branches, config=config) assert cp.sorted_branches == sorted_branches @pytest.mark.parametrize('input_branches', [ (['3.1', '2.7', '3.x10', '3.6', '']), (['stable-3.1', 'lts-2.7', '3.10-other', 'smth3.6else', 'invalid']), ]) @mock.patch('os.path.exists') def test_invalid_branches(os_path_exists, config, input_branches): os_path_exists.return_value = True cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', input_branches, config=config) with pytest.raises(ValueError): cp.sorted_branches @mock.patch('os.path.exists') def test_get_cherry_pick_branch(os_path_exists, config): os_path_exists.return_value = True branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_cherry_pick_branch("3.6") == "backport-22a594a-3.6" @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_pr_url(subprocess_check_output, os_path_exists, config): os_path_exists.return_value = True subprocess_check_output.return_value = b'https://github.com/mock_user/cpython.git' branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_pr_url("3.6", cp.get_cherry_pick_branch("3.6")) \ == "https://github.com/python/cpython/compare/3.6...mock_user:backport-22a594a-3.6?expand=1" @pytest.mark.parametrize('url', [ b'git@github.com:mock_user/cpython.git', 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'https://github.com/mock_user/cpython.git', b'https://github.com/mock_user/cpython', ]) def test_username(url, config): with mock.patch('subprocess.check_output', return_value=url): branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.username == 'mock_user' @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_updated_commit_message(subprocess_check_output, os_path_exists, config): os_path_exists.return_value = True subprocess_check_output.return_value = b'bpo-123: Fix Spam Module (#113)' branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_commit_message('22a594a0047d7706537ff2ac676cdc0f1dcb329c') \ == 'bpo-123: Fix Spam Module (GH-113)' @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_updated_commit_message_without_links_replacement( subprocess_check_output, os_path_exists, config): os_path_exists.return_value = True subprocess_check_output.return_value = b'bpo-123: Fix Spam Module (#113)' config['fix_commit_msg'] = False branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches, config=config) assert cp.get_commit_message('22a594a0047d7706537ff2ac676cdc0f1dcb329c') \ == 'bpo-123: Fix Spam Module (#113)' @mock.patch('subprocess.check_output') def test_is_cpython_repo(subprocess_check_output, config): subprocess_check_output.return_value = """commit 7f777ed95a19224294949e1b4ce56bbffcb1fe9f Author: Guido van Rossum Date: Thu Aug 9 14:25:15 1990 +0000 Initial revision """ # should not raise an exception CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', ["3.6"], config=config) def test_is_not_cpython_repo(): # use default CPython sha to fail on this repo with pytest.raises(InvalidRepoException): CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', ["3.6"]) def test_find_project_root(): here = pathlib.Path(__file__) root = here.parent.parent.parent assert find_project_root() == root def test_find_config(tmpdir, cd): cd(tmpdir) subprocess.run('git init .'.split(), check=True) cfg = tmpdir.join('.cherry_picker.toml') cfg.write('param = 1') assert str(find_config()) == str(cfg) def test_find_config_not_found(tmpdir, cd): cd(tmpdir) subprocess.run('git init .'.split(), check=True) assert find_config() is None def test_load_full_config(tmpdir, cd): cd(tmpdir) subprocess.run('git init .'.split(), check=True) cfg = tmpdir.join('.cherry_picker.toml') cfg.write('''\ team = "python" repo = "core-workfolow" check_sha = "5f007046b5d4766f971272a0cc99f8461215c1ec" default_branch = "devel" ''') cfg = load_config(None) assert cfg == {'check_sha': '5f007046b5d4766f971272a0cc99f8461215c1ec', 'repo': 'core-workfolow', 'team': 'python', 'fix_commit_msg': True, 'default_branch': 'devel', } def test_load_partial_config(tmpdir, cd): cfg = tmpdir.join('.cherry_picker.toml') cfg.write('''\ repo = "core-workfolow" ''') cfg = load_config(pathlib.Path(str(cfg))) assert cfg == {'check_sha': '7f777ed95a19224294949e1b4ce56bbffcb1fe9f', 'repo': 'core-workfolow', 'team': 'python', 'fix_commit_msg': True, 'default_branch': 'master', } def test_normalize_long_commit_message(): 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". In Sphinx 1.5.1+, it is now "index.rst.txt". (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" title, body = normalize_commit_message(commit_message) assert title == "[3.6] Fix broken `Show Source` links on documentation pages (GH-3113)" assert body == """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". In Sphinx 1.5.1+, it is now "index.rst.txt". (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" def test_normalize_short_commit_message(): commit_message = """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" title, body = normalize_commit_message(commit_message) assert title == "[3.6] Fix broken `Show Source` links on documentation pages (GH-3113)" assert body == """(cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" PK!HMj/M.cherry_picker-1.2.0.dist-info/entry_points.txtN+I/N.,()JH-*/LN-Ex9\\PK!HNO#cherry_picker-1.2.0.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,zd&Y)r$[)T&UrPK!H^v}2&cherry_picker-1.2.0.dist-info/METADATAZrHvYQӋuR&Ӆb^d+T(3|R2)gtV-2G".U̲ gYV(6+>ϓvPJ AJ5@_>Tx8e˲c\KW)9ff5.1' K)poԍJimq:֐d»TBi߀:ǯFY%fģҿ^ Tw/t&Rx3&E(j =x]}l3HTQH"2v_eV^Y+(*.?WNՏ=O0@DU&o/h&N¬*%vY[ipF>jө1T@i3>+(E Y؞NQ鮠/ceN;ML"Tp*6PpN!U?CnZ܅Wq" ϙڑ+7Oy{6襹%FDjq.$R88yUI,8NRi A[*O`r@a1 ~RjA#Dxʉ#O-`}V-ؑjYIkܼ+TpO|Hz*y]b#ER"..9)m~st`WtH58~8J8,gFU:vn b5YO8}K: Ȏ 8YL^ަ;5z?s(8$b<(4+Rr)K@5\AE <Ю/bc;ym.9(5pPBq=OX5QZeES^ko^yI|m>LV%IpP#mdP^0 x$j[lU9[<5r;H3Z_CW/2+o$cY%%X\\6yZ]dm_x S$z#@P3\g)b@!}ӹQ܎ִ"c|&u_l@}HN(Z$6Igluܷ^]8 oF@o)"1};6xފ_ڽSiu8"?TngXbZA qn4nl. VYJhqml@Fڃ\kPMğ7r;bƶl)+u," 0u gc#`Ye `A {D=z HU s~#WAB|&j9}aI470K;8ыHHNM䊟ŬeJ'g03 ReUg:h>&vlX4QJI}Ej݆IgFuz]'8mRe­DEdvk#b6em.Ö {8?<;|\gɨ;%w(B b.M~]\:љzפ6Ewk2fĚ>s0tۦ|0x,RHo迡D5qa? :llt 85e4^v*>A ߚpݬY.H2BG:%@ Dbr*156tAPӦӮ .q72$$P)Rf7Tҙ)q*eiiB(p.99pTmvofw+ʩwb`ɞƟ@BSCvKe6$tֻ\wl N {x̉1:fK7Z]m8E@X1a%0Օt$Vqo&[;/5|2%v]O;OGmfSirj%Q85j!1-k L>3# M]Yj%#x4>X&iVlsV8N"5 _ >17)I˶՘u2q[]2| ݂{BA%}"/#Qug{ *ntIkSMƩ`&:m6+Q!: Z=;xH(j$:ý?FpY>T8Beaj&f]89iB7Ȟ ^םz倍q*#&E{ߺNهjrۃ}-F'(0itnz e0:ee"ӣ~A^8;z& Oฑ`jA7 "wõ˰*U"f|a r/7d|ڢ>Q/7>r;kx|9:2i ūE $bݠ~KӉ"7)=*% V⦗ů\*4n~ӻWosP2_Bx-е؆["oyo{45.nƫ욮' Lz/4hS}:48!@Et="`u0NPeU˟ʼnV)RAc_7M.w7`SOyD` ϻ4G|qe1K3snV\!.i1K?s@y_~,YOx⡥xOFM פ]W2;V*)/0}~ms$j>5|o>@8Thr6'Z6vJ6fLyYPܨ QfVjz[{"7 צؤ+&dP#&Y$mKs_hulcvpݼNP+ VY#܄.-(\ ~ H{lep6..\r!d;-u PK!H- $cherry_picker-1.2.0.dist-info/RECORDK0)pu-_ٚⱫ&8ざ 1raS}G_؏߸[DU d%.f-0';R\Tw,w>!?Pt?pZʓ#}C/}曎yvRj^;*S.| H SS\N&f ur_+pYȸipo]X$H]ٱ0U%Y8ܿ=e|3#6?m|W[s\UW޼r@_6~mGբO(wCt̉I8% ٳSkeǸ "kGIvowv3ΔrV,H)k/+PKL[ZZcherry_picker/__init__.pyPKkLh7$]]cherry_picker/__main__.pyPKYLM+:B:B%cherry_picker/cherry_picker.pyPKYL$$Ccherry_picker/test.pyPK!HMj/M.hcherry_picker-1.2.0.dist-info/entry_points.txtPK!HNO#6icherry_picker-1.2.0.dist-info/WHEELPK!H^v}2&icherry_picker-1.2.0.dist-info/METADATAPK!H- ${cherry_picker-1.2.0.dist-info/RECORDPKpJ}