pax_global_header00006660000000000000000000000064137014103540014510gustar00rootroot0000000000000052 comment=613df41253486d01beaaa38a7e6dc6729184c246 git-pw-2.0.0/000077500000000000000000000000001370141035400127165ustar00rootroot00000000000000git-pw-2.0.0/.gitignore000066400000000000000000000013241370141035400147060ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # SublimeText *.sublime-project *.sublime-workspace # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache .pytest_cache/ coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ # pbr AUTHORS ChangeLog RELEASENOTES.rst releasenotes/notes/reno.cache # virtualenv /.venv # Mypy /.mypy_cache # Vim *.swp git-pw-2.0.0/.mailmap000066400000000000000000000000621370141035400143350ustar00rootroot00000000000000 git-pw-2.0.0/.pre-commit-config.yaml000066400000000000000000000003621370141035400172000ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.3.0 hooks: - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-yaml - id: end-of-file-fixer - id: flake8 - id: trailing-whitespace git-pw-2.0.0/.travis.yml000066400000000000000000000036721370141035400150370ustar00rootroot00000000000000language: python sudo: false cache: pip python: - 3.5 - 3.6 - 3.7 - 3.8 install: - pip install tox-travis script: - tox stages: - test - deploy jobs: include: - stage: deploy python: 3.8 install: skip # no tests, no dependencies needed script: skip # we're not running tests deploy: - provider: pypi user: "__token__" password: secure: "uRtyVTX52PK4NIll439jQ8pBYrtbzs8ICRxbNdyEUuGGlmhPTWFlNBJMpD5J4U9d0XKLN18uUxzKR6/8wte7Pfj3SimgarwvscpuW/0jnQx3lDsdfZ5K6bfkzZKPd4tHgmF5oHbkI2mdB6b4ZcQJ511iZmvsPp9LVCpgFGuL+3iiUMDQcX++syAIpW/M6kFqtJVqyNJXkMg1PxErEau1Wj2aaU6rGnCexf2pzdPcDZf2QZBh3fbD9c0L8G7lSpVVnoCtArUTKaGjS2fsYWc/DDXS7D0xg2MgwsI1kdkwkfsfX5fCQVvKIGPyu4sn2W/ZHRc69v/1SaRu77VFAIgDfc1quaZ8tEpOSliZT8FHuVO4qRUpI8whe7eSxucSSSgHMC+47A+mjLF5cTzRi9MoKXMiwLKY8gSq/1gNDFum0d4ILPc/mje47PidDg5E1VOsa7HS2TuJVZLqHGSLNO0K/CDnEzjVGma8DH/RbEo2/wsX6K6zj9hbiWTqiFYFnqq9giBGsL6B1cj1CIcRMyo/dYBKyuSz47bOH1gd1637uCI206b7Jj/G6aSXy/l6zEAI9M/dyPfDivQ2QqFW+WCAFAYjDxZtMAfscVQI/6c/ystqOE9+MhrpUUCFzxZmE3aNtOtx7PzdizFRGY8cNEycsgdGeSm5wKU+QaXzdKGNh6I=" on: tags: true distributions: sdist bdist_wheel skip_cleanup: true - provider: releases token: secure: "aLlmj98VGm+MKrvYWFDab5gTEkL9dcMzJq6b0p2c8oHEKd4wrpGlhELGQlnYiaUyMEZcbn3Vkuacw0JN9nZqDBFRnSxtWhZgBAz31IGfM9AXpvXr1bOa1hwUTOnMflR1kcYLyy3kmDsGkk1njNEWu/vK6rORNTIh6oXXnb87GHso7lAc8U1iwwTJ8+ELGZfxhmfvgptGgtIabA42F1zrnd1cHH1ygdSZhxeexHgSIiFh6BleTfHAVJSGrdMaUZKM7MFDZmYvtnRt+JwlL3VqqOjgMA57qVSwWVUQMTMWheh1vj5s3BzcugQOkNSh4gbb9jkAXNK9Y9fsw7ixzNw74gIMNp+BYnyMX83Q7U4xfeojKwxTgvJv6xwwT1S9W565Hn997E22R5DgSj2ldcLer0kYUGwrKu5/5oE19AzOykeMaVShRHE/2YbW7ugWmrJtEUcaOU+t/6AGReiknse186S/9JoHQIzVhWm6NjeD/wg7Wet/75bxzqEbf8UXjsmTZbJmTCPEhtOrDxC7PJ0tDSmDLXgCiqwUJ8ZtCZuu7p3TC1nyVVligutZ90MhU4UGSWMtyzu4JbX2oJEv8Bk2QaVxFA28jKRhNH5UKQnLbRLRKL+vZEsYjY6jXOYIlwUxjokmx/r5fhEbBSZI5TlvJ2LJJBWU2h5St6Gnemi4ZmU=" on: tags: true file: "dist/*" skip_cleanup: true git-pw-2.0.0/LICENSE000066400000000000000000000021071370141035400137230ustar00rootroot00000000000000The MIT License Copyright (c) 2015 Stephen Finucane http://that.guru/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. git-pw-2.0.0/README.rst000066400000000000000000000101501370141035400144020ustar00rootroot00000000000000====== git-pw ====== .. NOTE: If editing this, be sure to update the line numbers in 'doc/introduction' .. image:: https://badge.fury.io/py/git-pw.svg :target: https://badge.fury.io/py/git-pw :alt: PyPi Status .. image:: https://readthedocs.org/projects/git-pw/badge/?version=latest :target: http://git-pw.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. image:: https://travis-ci.org/getpatchwork/git-pw.svg?branch=master :target: https://travis-ci.org/getpatchwork/git-pw :alt: Build Status *git-pw* is a tool for integrating Git with `Patchwork`__, the web-based patch tracking system. .. important:: `git-pw` only supports Patchwork 2.0+ and REST API support must be enabled on the server end. You can check for support by browsing ``/about`` for your given instance. If this page returns a 404, you are using Patchwork < 2.0. The `pwclient`__ utility can be used to interact with older Patchwork instances or instances with the REST API disabled. __ http://jk.ozlabs.org/projects/patchwork/ __ https://patchwork.ozlabs.org/help/pwclient/ Installation ------------ The easiest way to install *git-pw* and its dependencies is using ``pip``. To do so, run: .. code-block:: bash $ pip install git-pw You can also install *git-pw* manually. First, install the required dependencies. On Fedora, run: .. code-block:: bash $ sudo dnf install python-requests python-click python-pbr python-arrow \ python-tabulate On Ubuntu, run: .. code-block:: bash $ sudo apt-get install python-requests python-click python-pbr python-arrow \ python-tabulate Once dependencies are installed, clone this repo and run ``setup.py``: .. code-block:: bash $ git clone https://github.com/getpatchwork/git-pw $ cd git-pw $ pip install --user . # or 'sudo python setup.py install' Getting Started --------------- To begin, you'll need to configure Git settings appropriately. The following settings are **required**: ``pw.server`` The URL for the Patchwork instance's API. This should include the API version:: https://patchwork.ozlabs.org/api/1.1 You can discover the API version supported by your instance by comparing the server version, found at ``/about``, with the API versions provided in the `documentation`__. For example, if your server is running Patchwork version 2.1.x, you should use API version 1.1. __ https://patchwork.readthedocs.io/en/stable-2.1/api/rest/#rest-api-versions ``pw.project`` The project name or list-id. This will appear in the URL when using the web UI:: https://patchwork.ozlabs.org/project/{project_name}/list/ You also require authentication - you can use either API tokens or a username/password combination: ``pw.token`` The API token for your Patchwork account. ``pw.username`` The username for your Patchwork account. ``pw.password`` The password for your Patchwork account. The following settings are **optional** and may need to be set depending on your Patchwork instance's configuration: ``pw.states`` The states that can be applied to a patch using the ``git pw patch update`` command. Should be provided in slug form (``changes-requested`` instead of ``Changes Requested``). Only required if your Patchwork instance uses non-default states. You can set these settings using the ``git config`` command. This should be done in the repo in which you intend to apply patches. For example, to configure the Patchwork project, run: .. code-block:: bash $ git config pw.server 'https://patchwork.ozlabs.org/api/1.1/' $ git config pw.project 'patchwork' Development ----------- If you're interested in contributing to *git-pw*, first clone the repo: .. code-block:: bash $ git clone https://github.com/getpatchwork/git-pw $ cd git-pw Create a *virtualenv*, then install the package in `editable`__ mode: .. code-block:: bash $ virtualenv .venv $ source .venv/bin/activate $ pip install --editable . __ https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs Documentation ------------- Documentation is available on `Read the Docs`__ __ https://git-pw.readthedocs.org/ git-pw-2.0.0/docs/000077500000000000000000000000001370141035400136465ustar00rootroot00000000000000git-pw-2.0.0/docs/conf.py000066400000000000000000000033041370141035400151450ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # git-pw documentation build configuration file import git_pw try: import sphinx_rtd_theme # noqa has_rtd_theme = True except ImportError: has_rtd_theme = False # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.5' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ['sphinx_click.ext', 'reno.sphinxext'] # Add any paths that contain templates here, relative to this directory. templates_path = [] # The master toctree document. master_doc = 'contents' # General information about the project. project = u'git-pw' copyright = u'2018, Stephen Finucane' author = u'Stephen Finucane' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '.'.join(git_pw.__version__.split('.')[:-1]) # The full version, including alpha/beta/rc tags. release = git_pw.__version__ # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # A list of warning types to suppress arbitrary warning messages. suppress_warnings = ['image.nonlocal_uri'] # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # if has_rtd_theme: html_theme = 'sphinx_rtd_theme' git-pw-2.0.0/docs/contents.rst000066400000000000000000000001041370141035400162300ustar00rootroot00000000000000Contents ======== .. toctree:: index usage release-notes git-pw-2.0.0/docs/index.rst000066400000000000000000000004671370141035400155160ustar00rootroot00000000000000git-pw (Patchwork subcommand for Git) ===================================== Overview -------- .. include:: ../README.rst :start-line: 19 :end-line: 33 .. include:: ../README.rst :start-line: 34 :end-line: -7 Usage ----- See :doc:`usage`. Release Notes ------------- See :doc:`release-notes`. git-pw-2.0.0/docs/release-notes.rst000066400000000000000000000000601370141035400171420ustar00rootroot00000000000000Release Notes ============= .. release-notes:: git-pw-2.0.0/docs/requirements.txt000066400000000000000000000001461370141035400171330ustar00rootroot00000000000000-r ../requirements.txt sphinx>=1.5,<2.0 sphinx-click>=2.0,<3.0 reno>=3.0,<4.0 sphinx-rtd-theme==0.4.3 git-pw-2.0.0/docs/usage.rst000066400000000000000000000001131370141035400154770ustar00rootroot00000000000000Usage ===== .. click:: git_pw.shell:cli :prog: git-pw :show-nested: git-pw-2.0.0/git_pw/000077500000000000000000000000001370141035400142075ustar00rootroot00000000000000git-pw-2.0.0/git_pw/__init__.py000066400000000000000000000002701370141035400163170ustar00rootroot00000000000000""" git-pw -- A tool for integrating Git with Patchwork, the web-based patch tracking system. """ import pkg_resources __version__ = pkg_resources.get_distribution('git-pw').version git-pw-2.0.0/git_pw/api.py000066400000000000000000000330251370141035400153350ustar00rootroot00000000000000""" Simple wrappers around request methods. """ from functools import update_wrapper import logging import os.path import re import pty import sys import tempfile import click import requests import git_pw from git_pw import config if 0: # noqa from typing import Any # noqa from typing import Callable # noqa from typing import Dict # noqa from typing import IO # noqa from typing import List # noqa from typing import Optional # noqa from typing import Tuple # noqa from typing import Union # noqa Filters = List[Tuple[str, str]] CONF = config.CONF LOG = logging.getLogger(__name__) class HTTPTokenAuth(requests.auth.AuthBase): """Attaches HTTP Token Authentication to the given Request object.""" def __init__(self, token): self.token = token def __call__(self, r): r.headers['Authorization'] = self._token_auth_str(self.token) return r @staticmethod def _token_auth_str(token): # type: (str) -> str """Return a Token auth string.""" return 'Token {}'.format(token.strip()) def _get_auth(): # type: () -> requests.auth.AuthBase if CONF.token: return HTTPTokenAuth(CONF.token) elif CONF.username and CONF.password: return requests.auth.HTTPBasicAuth(CONF.username, CONF.password) else: LOG.error('Authentication information missing') LOG.error('You must configure authentication via git-config or via ' '--token or --username, --password') sys.exit(1) def _get_headers(): # type: () -> Dict[str, str] return { 'User-Agent': 'git-pw ({})'.format(git_pw.__version__), } def _get_server(): # type: () -> str if CONF.server: server = CONF.server.rstrip('/') if not re.match(r'.*/api/\d\.\d$', server): LOG.warning('Server version missing') LOG.warning('You should provide the server version in the URL ' 'configured via git-config or --server') LOG.warning('This will be required in git-pw 2.0') if not re.match(r'.*/api(/\d\.\d)?$', server): # NOTE(stephenfin): We've already handled this particular error # above so we don't warn twice. We also don't stick on a version # number since the user clearly wants the latest server += '/api' return server else: LOG.error('Server information missing') LOG.error('You must provide server information via git-config or via ' '--server') sys.exit(1) def _get_project(): # type: () -> str if CONF.project and CONF.project.strip() == '*': return '' # just don't bother filtering on project elif CONF.project: return CONF.project.strip() else: LOG.error('Project information missing') LOG.error('You must provide project information via git-config or ' 'via --project') LOG.error('To list all projects, set project to "*"') sys.exit(1) def _handle_error(operation, exc): if exc.response is not None and exc.response.content: # server errors should always be reported if exc.response.status_code in range(500, 512): # 5xx Server Error LOG.error('Server error. Please report this issue to ' 'https://github.com/getpatchwork/patchwork') raise # we make the assumption that all responses will be JSON encoded if exc.response.status_code == 404: LOG.error('Resource not found') else: LOG.error(exc.response.json()) else: LOG.error('Failed to %s resource. Is your configuration ' 'correct?' % operation) LOG.error("Use the '--debug' flag for more information") if CONF.debug: raise else: sys.exit(1) def _get(url, params=None, stream=False): # type: (str, Filters, bool) -> requests.Response """Make GET request and handle errors.""" LOG.debug('GET %s', url) try: # TODO(stephenfin): We only use a subset of the types possible for # 'params' (namely a list of tuples) but it doesn't seem possible to # indicate this rsp = requests.get( url, auth=_get_auth(), headers=_get_headers(), stream=stream, params=params) # type: ignore rsp.raise_for_status() except requests.exceptions.RequestException as exc: _handle_error('fetch', exc) LOG.debug('Got response') return rsp def _post(url, data): # type: (str, dict) -> requests.Response """Make POST request and handle errors.""" LOG.debug('POST %s, data=%r', url, data) try: rsp = requests.post(url, auth=_get_auth(), headers=_get_headers(), data=data) rsp.raise_for_status() except requests.exceptions.RequestException as exc: _handle_error('create', exc) LOG.debug('Got response') return rsp def _patch(url, data): # type: (str, dict) -> requests.Response """Make PATCH request and handle errors.""" LOG.debug('PATCH %s, data=%r', url, data) try: rsp = requests.patch(url, auth=_get_auth(), headers=_get_headers(), data=data) rsp.raise_for_status() except requests.exceptions.RequestException as exc: _handle_error('update', exc) LOG.debug('Got response') return rsp def _delete(url): # type: (str) -> requests.Response """Make DELETE request and handle errors.""" LOG.debug('DELETE %s', url) try: rsp = requests.delete(url, auth=_get_auth(), headers=_get_headers()) rsp.raise_for_status() except requests.exceptions.RequestException as exc: _handle_error('delete', exc) LOG.debug('Got response') return rsp def version(): # type: () -> Optional[Tuple[int, int]] """Get the version of the server from the URL, if present.""" server = _get_server() version = re.match(r'.*/(\d)\.(\d)$', server) if version: return (int(version.group(1)), int(version.group(2))) # return the oldest version we support if no version provided return (1, 0) def download(url, params=None, output=None): # type: (str, Filters, IO) -> Optional[str] """Retrieve a specific API resource and save it to a file/stdout. The ``Content-Disposition`` header is assumed to be present and will be used for the output filename, if not writing to stdout. Arguments: url: The resource URL. params: Additional parameters. output: The output file. If provided, the caller is responsible for closing. If None, a temporary file will be used. Returns: A path to an output file containing the content, else None if stdout used. """ rsp = _get(url, params, stream=True) # we don't catch anything here because we should break if these are missing header = re.search( 'filename=(.+)', rsp.headers.get('content-disposition') or '', ) if not header: LOG.error('Filename was expected but was not provided in response') sys.exit(1) if output: output_path = None if output.fileno() != pty.STDOUT_FILENO: LOG.debug('Saving to %s', output.name) output_path = output.name # we use iter_content because patches can be binary for block in rsp.iter_content(1024): output.write(block) else: output_path = os.path.join( tempfile.mkdtemp(prefix='git-pw'), header.group(1), ) with open(output_path, 'wb') as output_file: LOG.debug('Saving to %s', output_path) # we use iter_content because patches can be binary for block in rsp.iter_content(1024): output_file.write(block) return output_path def index(resource_type, params=None): # type: (str, Filters) -> dict """List API resources. GET /{resource}/ All resources are JSON bodies, thus we can access them in a similar fashion. Arguments: resource_type: The resource endpoint name. params: Additional parameters, filters. Returns: A list of dictionaries, representing the summary view of each resource. """ # NOTE(stephenfin): All resources must have a trailing '/' url = '/'.join([_get_server(), resource_type, '']) # NOTE(stephenfin): Not all endpoints in the Patchwork API allow filtering # by project, but all the ones we care about here do. params = params or [] params.append(('project', _get_project())) return _get(url, params).json() def detail(resource_type, resource_id, params=None): # type: (str, int, Filters) -> Dict """Retrieve a specific API resource. GET /{resource}/{resourceID}/ Arguments: resource_type: The resource endpoint name. resource_id: The ID for the specific resource. params: Additional parameters. Returns: A dictionary representing the detailed view of a given resource. """ # NOTE(stephenfin): All resources must have a trailing '/' url = '/'.join([_get_server(), resource_type, str(resource_id), '']) return _get(url, params, stream=False).json() def create(resource_type, data): # type: (str, dict) -> dict """Create a new API resource. POST /{resource}/ Arguments: resource_type: The resource endpoint name. params: Fields to update. Returns: A dictionary representing the detailed view of a given resource. """ # NOTE(stephenfin): All resources must have a trailing '/' url = '/'.join([_get_server(), resource_type, '']) return _post(url, data).json() def delete(resource_type, resource_id): # type: (str, Union[str, int]) -> None """Delete a specific API resource. DELETE /{resource}/{resourceID}/ Arguments: resource_type: The resource endpoint name. resource_id: The ID for the specific resource. Returns: A dictionary representing the detailed view of a given resource. """ # NOTE(stephenfin): All resources must have a trailing '/' url = '/'.join([_get_server(), resource_type, str(resource_id), '']) _delete(url) def update(resource_type, resource_id, data): # type: (str, Union[int, str], dict) -> dict """Update a specific API resource. PATCH /{resource}/{resourceID}/ Arguments: resource_type: The resource endpoint name. resource_id: The ID for the specific resource. params: Fields to update. Returns: A dictionary representing the detailed view of a given resource. """ # NOTE(stephenfin): All resources must have a trailing '/' url = '/'.join([_get_server(), resource_type, str(resource_id), '']) return _patch(url, data).json() def validate_minimum_version(min_version, msg): # type: (Tuple[int, int], str) -> Callable[[Any], Any] def inner(f): @click.pass_context def new_func(ctx, *args, **kwargs): if version() < min_version: LOG.error(msg) sys.exit(1) return ctx.invoke(f, *args, **kwargs) return update_wrapper(new_func, f) return inner def validate_multiple_filter_support(f): @click.pass_context def new_func(ctx, *args, **kwargs): if version() >= (1, 1): return ctx.invoke(f, *args, **kwargs) for param in ctx.command.params: if not param.multiple: continue if param.name in ('headers'): continue value = list(kwargs[param.name] or []) if value and len(value) > 1 and value != param.default: msg = ('The `--%s` filter was specified multiple times. ' 'Filtering by multiple %ss is not supported with API ' 'version 1.0. If the server supports it, use version ' '1.1 instead. Refer to https://git.io/vN3vX for more ' 'information.') LOG.warning(msg, param.name, param.name) return ctx.invoke(f, *args, **kwargs) return update_wrapper(new_func, f) def retrieve_filter_ids(resource_type, filter_name, filter_value): """Retrieve IDs for items passed through by filter. Some filters require client-side filtering, e.g. filtering patches by submitter names. Arguments: resource_type: The filter's resource endpoint name. filter_name: The name of the filter. filter_value: The value of the filter. Returns: A list of querystring key-value pairs to use in the actual request. """ if len(filter_value) < 3: # protect agaisnt really generic (and essentially meaningless) queries LOG.error('Filters must be at least 3 characters long') sys.exit(1) # NOTE(stephenfin): This purposefully ignores the possiblity of a second # page because it's unlikely and likely unnecessary items = index(resource_type, [('q', filter_value)]) if len(items) == 0: LOG.warning('No matching %s found: %s', filter_name, filter_value) elif len(items) > 1 and version() < (1, 1): # we don't support multiple filters in 1.0 msg = ('More than one match for found for `--%s=%s`. ' 'Filtering by multiple %ss is not supported with ' 'API version 1.0. If the server supports it, use ' 'version 1.1 instead. Refer to https://git.io/vN3vX ' 'for more information.') LOG.warning(msg, filter_name, filter_value, filter_name) return [(filter_name, item['id']) for item in items] git-pw-2.0.0/git_pw/bundle.py000066400000000000000000000214321370141035400160340ustar00rootroot00000000000000""" Bundle subcommands. """ import logging import sys import click from git_pw import api from git_pw import utils LOG = logging.getLogger(__name__) _list_headers = ('ID', 'Name', 'Owner', 'Public') _sort_fields = ('id', '-id', 'name', '-name') def _get_bundle(bundle_id): """Fetch bundle by ID or name. Allow users to provide a string to search for bundles. This doesn't make sense to expose via the API since there's no uniqueness constraint on bundle names. """ if bundle_id.isdigit(): return api.detail('bundles', bundle_id) bundles = api.index('bundles', [('q', bundle_id)]) if len(bundles) == 0: LOG.error('No matching bundle found: %s', bundle_id) sys.exit(1) elif len(bundles) > 1: LOG.error('More than one bundle found: %s', bundle_id) sys.exit(1) return bundles[0] @click.command(name='apply', context_settings=dict( ignore_unknown_options=True, )) @click.argument('bundle_id') @click.argument('args', nargs=-1, type=click.UNPROCESSED) def apply_cmd(bundle_id, args): """Apply bundle. Apply a bundle locally using the 'git-am' command. Any additional ARGS provided will be passed to the 'git-am' command. """ LOG.debug('Applying bundle: id=%s', bundle_id) bundle = _get_bundle(bundle_id) mbox = api.download(bundle['mbox']) utils.git_am(mbox, args) @click.command(name='download') @click.argument('bundle_id') @click.argument('output', type=click.File('wb'), required=False) def download_cmd(bundle_id, output): """Download bundle in mbox format. Download a bundle but do not apply it. ``OUTPUT`` is optional and can be an output path or ``-`` to output to ``stdout``. If ``OUTPUT`` is not provided, the output path will be automatically chosen. """ LOG.debug('Downloading bundle: id=%s', bundle_id) path = None bundle = _get_bundle(bundle_id) path = api.download(bundle['mbox'], output=output) if path: LOG.info('Downloaded bundle to %s', path) def _show_bundle(bundle, fmt): def _format_patch(patch): return '%-4d %s' % (patch.get('id'), patch.get('name')) output = [ ('ID', bundle.get('id')), ('Name', bundle.get('name')), ('URL', bundle.get('web_url')), ('Owner', bundle.get('owner').get('username')), ('Project', bundle.get('project').get('name')), ('Public', bundle.get('public'))] prefix = 'Patches' for patch in bundle.get('patches'): output.append((prefix, _format_patch(patch))) prefix = '' utils.echo(output, ['Property', 'Value'], fmt=fmt) @click.command(name='show') @utils.format_options @click.argument('bundle_id') def show_cmd(fmt, bundle_id): """Show information about bundle. Retrieve Patchwork metadata for a bundle. """ LOG.debug('Showing bundle: id=%s', bundle_id) bundle = _get_bundle(bundle_id) _show_bundle(bundle, fmt) @click.command(name='list') @click.option('--owner', 'owners', metavar='OWNER', multiple=True, help='Show only bundles with these owners. Should be an email, ' 'name or ID. Private bundles of other users will not be shown.') @utils.pagination_options(sort_fields=_sort_fields, default_sort='name') @utils.format_options(headers=_list_headers) @click.argument('name', required=False) @api.validate_multiple_filter_support def list_cmd(owners, limit, page, sort, fmt, headers, name): """List bundles. List bundles on the Patchwork instance. """ LOG.debug('List bundles: owners=%s, limit=%r, page=%r, sort=%r', ','.join(owners), limit, page, sort) params = [] for owner in owners: # we support server-side filtering by username (but not email) in 1.1 if (api.version() >= (1, 1) and '@' not in owner) or owner.isdigit(): params.append(('owner', owner)) else: params.extend(api.retrieve_filter_ids('users', 'owner', owner)) params.extend([ ('q', name), ('page', page), ('per_page', limit), ('order', sort), ]) bundles = api.index('bundles', params) # Format and print output output = [] for bundle in bundles: item = [ bundle.get('id'), utils.trim(bundle.get('name') or ''), bundle.get('owner').get('username'), 'yes' if bundle.get('public') else 'no', ] output.append([]) for idx, header in enumerate(_list_headers): if header not in headers: continue output[-1].append(item[idx]) utils.echo_via_pager(output, headers, fmt=fmt) @click.command(name='create') @click.option('--public/--private', default=False, help='Allow other users to view this bundle. If private, only ' 'you will be able to see this bundle.') @click.argument('name') @click.argument('patch_ids', type=click.INT, nargs=-1, required=True) @api.validate_minimum_version( (1, 2), 'Creating bundles is only supported from API version 1.2', ) @utils.format_options def create_cmd(name, patch_ids, public, fmt): """Create a bundle. Create a bundle with the given NAME and patches from PATCH_ID. Requires API version 1.2 or greater. """ LOG.debug('Create bundle: name=%s, patches=%s, public=%s', name, patch_ids, public) data = [ ('name', name), ('patches', patch_ids), ('public', public), ] bundle = api.create('bundles', data) _show_bundle(bundle, fmt) @click.command(name='update') @click.option('--name') @click.option('--patch', 'patch_ids', type=click.INT, multiple=True, help='Add the specified patch(es) to the bundle.') @click.option('--public/--private', default=None, help='Allow other users to view this bundle. If private, only ' 'you will be able to see this bundle.') @click.argument('bundle_id') @api.validate_minimum_version( (1, 2), 'Updating bundles is only supported from API version 1.2', ) @utils.format_options def update_cmd(bundle_id, name, patch_ids, public, fmt): """Update a bundle. Update bundle BUNDLE_ID. If PATCH_IDs are specified, this will overwrite all patches in the bundle. Use 'bundle add' and 'bundle remove' to add or remove patches. Requires API version 1.2 or greater. """ LOG.debug( 'Updating bundle: id=%s, name=%s, patches=%s, public=%s', bundle_id, name, patch_ids, public, ) data = [] for key, value in [('name', name), ('public', public)]: if value is None: continue data.append((key, value)) if patch_ids: # special case patches to ignore the empty set data.append(('patches', patch_ids)) bundle = api.update('bundles', bundle_id, data) _show_bundle(bundle, fmt) @click.command(name='delete') @click.argument('bundle_id') @api.validate_minimum_version( (1, 2), 'Deleting bundles is only supported from API version 1.2', ) @utils.format_options def delete_cmd(bundle_id, fmt): """Delete a bundle. Delete bundle BUNDLE_ID. Requires API version 1.2 or greater. """ LOG.debug('Delete bundle: id=%s', bundle_id) api.delete('bundles', bundle_id) @click.command(name='add') @click.argument('bundle_id') @click.argument('patch_ids', type=click.INT, nargs=-1, required=True) @api.validate_minimum_version( (1, 2), 'Modifying bundles is only supported from API version 1.2', ) @utils.format_options def add_cmd(bundle_id, patch_ids, fmt): """Add one or more patches to a bundle. Append the provided PATCH_IDS to bundle BUNDLE_ID. Requires API version 1.2 or greater. """ LOG.debug('Add to bundle: id=%s, patches=%s', bundle_id, patch_ids) bundle = _get_bundle(bundle_id) data = [ ('patches', patch_ids + tuple([p['id'] for p in bundle['patches']])), ] bundle = api.update('bundles', bundle_id, data) _show_bundle(bundle, fmt) @click.command(name='remove') @click.argument('bundle_id') @click.argument('patch_ids', type=click.INT, nargs=-1, required=True) @api.validate_minimum_version( (1, 2), 'Modifying bundles is only supported from API version 1.2', ) @utils.format_options def remove_cmd(bundle_id, patch_ids, fmt): """Remove one or more patches from a bundle. Remove the provided PATCH_IDS to bundle BUNDLE_ID. Requires API version 1.2 or greater. """ LOG.debug('Remove from bundle: id=%s, patches=%s', bundle_id, patch_ids) bundle = _get_bundle(bundle_id) patches = [p['id'] for p in bundle['patches'] if p['id'] not in patch_ids] if not patches: LOG.error( 'Bundles cannot be empty. Consider deleting the bundle instead' ) sys.exit(1) data = [('patches', tuple(patches))] bundle = api.update('bundles', bundle_id, data) _show_bundle(bundle, fmt) git-pw-2.0.0/git_pw/config.py000066400000000000000000000014641370141035400160330ustar00rootroot00000000000000""" Configuration loader using 'git-config'. """ import logging from git_pw import utils LOG = logging.getLogger(__name__) class Config(object): def __init__(self): self._git_config = {} def __getattribute__(self, name): # attempt to use any attributes first try: value = super(Config, self).__getattribute__(name) except AttributeError: value = None if value: LOG.debug("Retrieved '{}' setting from cache".format(name)) return value # fallback to reading from git config otherwise value = utils.git_config('pw.{}'.format(name)) if value: LOG.debug("Retrieved '{}' setting from git-config".format(name)) setattr(self, name, value) return value CONF = Config() git-pw-2.0.0/git_pw/logger.py000066400000000000000000000005301370141035400160360ustar00rootroot00000000000000""" Configure application logging. """ import logging def configure_verbosity(debug): # type: (bool) -> None if debug: logging.basicConfig( level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') else: logging.basicConfig(level=logging.INFO, format='%(message)s') git-pw-2.0.0/git_pw/patch.py000066400000000000000000000234311370141035400156630ustar00rootroot00000000000000""" Patch subcommands. """ import logging import pty import sys import arrow import click from git_pw import api from git_pw import config from git_pw import utils CONF = config.CONF LOG = logging.getLogger(__name__) _list_headers = ( 'ID', 'Date', 'Name', 'Submitter', 'State', 'Archived', 'Delegate') _sort_fields = ( 'id', '-id', 'name', '-name', 'date', '-date') _default_states = ( 'new', 'under-review', 'accepted', 'rejected', 'rfc', 'not-applicable', 'changes-requested', 'awaiting-upstream', 'superseded', 'deferred') @click.command(name='apply', context_settings=dict( ignore_unknown_options=True, )) @click.argument('patch_id', type=click.INT) @click.option('--series', type=click.INT, metavar='SERIES', help='Series to include dependencies from. Defaults to latest.') @click.option('--deps/--no-deps', default=True, help='When applying the patch, include dependencies if ' 'available. Defaults to using the most recent series.') @click.argument('args', nargs=-1, type=click.UNPROCESSED) def apply_cmd(patch_id, series, deps, args): """Apply patch. Apply a patch locally using the 'git-am' command. Any additional ARGS provided will be passed to the 'git-am' command. """ LOG.debug('Applying patch: id=%d, series=%s, deps=%r, args=%s', patch_id, series, deps, ' '.join(args)) patch = api.detail('patches', patch_id) if deps and not series: series = '*' elif not deps: series = None mbox = api.download(patch['mbox'], {'series': series}) utils.git_am(mbox, args) @click.command(name='download') @click.argument('patch_id', type=click.INT) @click.argument('output', type=click.File('wb'), required=False) @click.option('--diff', 'fmt', flag_value='diff', help='Show patch in diff format.') @click.option('--mbox', 'fmt', flag_value='mbox', default=True, help='Show patch in mbox format.') def download_cmd(patch_id, output, fmt): """Download patch in diff or mbox format. Download a patch but do not apply it. ``OUTPUT`` is optional and can be an output path or ``-`` to output to ``stdout``. If ``OUTPUT`` is not provided, the output path will be automatically chosen. """ LOG.debug('Downloading patch: id=%d, format=%s', patch_id, fmt) path = None patch = api.detail('patches', patch_id) if fmt == 'diff': if output: output.write(patch['diff']) if output.fileno() != pty.STDOUT_FILENO: path = output.name else: # TODO(stephenfin): We discard the 'diff' field so we can get the # filename and save to the correct file. We should expose this # information via the API path = api.download( patch['mbox'].replace('mbox', 'raw'), output=output, ) else: path = api.download(patch['mbox'], output=output) if path: LOG.info('Downloaded patch to %s', path) def _show_patch(patch, fmt): def _format_series(series): return '%-4d %s' % (series.get('id'), series.get('name') or '-') output = [ ('ID', patch.get('id')), ('Message ID', patch.get('msgid')), ('Date', patch.get('date')), ('Name', patch.get('name')), ('URL', patch.get('web_url')), ('Submitter', '%s (%s)' % (patch.get('submitter').get('name'), patch.get('submitter').get('email'))), ('State', patch.get('state')), ('Archived', patch.get('archived')), ('Project', patch.get('project').get('name')), ('Delegate', (patch.get('delegate').get('username') if patch.get('delegate') else '')), ('Commit Ref', patch.get('commit_ref'))] prefix = 'Series' for series in patch.get('series'): output.append((prefix, _format_series(series))) prefix = '' utils.echo(output, ['Property', 'Value'], fmt=fmt) @click.command(name='show') @utils.format_options @click.argument('patch_id', type=click.INT) def show_cmd(fmt, patch_id): """Show information about patch. Retrieve Patchwork metadata for a patch. """ LOG.debug('Showing patch: id=%d', patch_id) patch = api.detail('patches', patch_id) _show_patch(patch, fmt) def _get_states(): return CONF.states.split(',') if CONF.states else _default_states @click.command(name='update') @click.argument('patch_ids', type=click.INT, nargs=-1, required=True) @click.option('--commit-ref', metavar='COMMIT_REF', help='Set the patch commit reference hash') @click.option('--state', metavar='STATE', type=click.Choice(_get_states()), help="Set the patch state. Should be a slugified representation " "of a state. The available states are instance dependant and " "can be configured using 'git config pw.states'.") @click.option('--delegate', metavar='DELEGATE', help='Set the patch delegate. Should be unique user identifier: ' 'either a username or a user\'s email address.') @click.option('--archived', metavar='ARCHIVED', type=click.BOOL, help='Set the patch archived state.') @utils.format_options def update_cmd(patch_ids, commit_ref, state, delegate, archived, fmt): """Update one or more patches. Updates one or more Patches on the Patchwork instance. Some operations may require admin or maintainer permissions. """ for patch_id in patch_ids: LOG.debug('Updating patch: id=%d, commit_ref=%s, state=%s, ' 'archived=%s', patch_id, commit_ref, state, archived) if delegate: users = api.index('users', [('q', delegate)]) if len(users) == 0: LOG.error('No matching delegates found: %s', delegate) sys.exit(1) elif len(users) > 1: LOG.error('More than one delegate found: %s', delegate) sys.exit(1) delegate = users[0]['id'] data = [] for key, value in [('commit_ref', commit_ref), ('state', state), ('archived', archived), ('delegate', delegate)]: if value is None: continue data.append((key, value)) patch = api.update('patches', patch_id, data) _show_patch(patch, fmt) @click.command(name='list') @click.option('--state', 'states', metavar='STATE', multiple=True, default=['under-review', 'new'], help='Show only patches matching these states. Should be ' 'slugified representations of states. The available states ' 'are instance dependant.') @click.option('--submitter', 'submitters', metavar='SUBMITTER', multiple=True, help='Show only patches by these submitters. Should be an ' 'email, name or ID.') @click.option('--delegate', 'delegates', metavar='DELEGATE', multiple=True, help='Show only patches with these delegates. Should be an ' 'email or username.') @click.option('--hash', 'hashes', metavar='HASH', multiple=True, help='Show only patches with these hashes.') @click.option('--archived', default=False, is_flag=True, help='Include patches that are archived.') @utils.pagination_options(sort_fields=_sort_fields, default_sort='-date') @utils.format_options(headers=_list_headers) @click.argument('name', required=False) @api.validate_multiple_filter_support def list_cmd(states, submitters, delegates, hashes, archived, limit, page, sort, fmt, headers, name): """List patches. List patches on the Patchwork instance. """ LOG.debug('List patches: states=%s, submitters=%s, delegates=%s, ' 'hashes=%s, archived=%r', ','.join(states), ','.join(submitters), ','.join(delegates), ','.join(hashes), archived) params = [] for state in states: params.append(('state', state)) for submitter in submitters: if submitter.isdigit(): params.append(('submitter', submitter)) else: # we support server-side filtering by email (but not name) in 1.1 if api.version() >= (1, 1) and '@' in submitter: params.append(('submitter', submitter)) else: params.extend( api.retrieve_filter_ids('people', 'submitter', submitter)) for delegate in delegates: if delegate.isdigit(): params.append(('delegate', delegate)) else: # we support server-side filtering by username (but not email) in # 1.1 if api.version() >= (1, 1) and '@' not in delegate: params.append(('delegate', delegate)) else: params.extend( api.retrieve_filter_ids('users', 'delegate', delegate)) for hash_ in hashes: params.append(('hash', hash_)) params.extend([ ('q', name), ('archived', 'true' if archived else 'false'), ('page', page), ('per_page', limit), ('order', sort), ]) patches = api.index('patches', params) # Format and print output output = [] for patch in patches: item = [ patch.get('id'), arrow.get(patch.get('date')).humanize(), utils.trim(patch.get('name')), '%s (%s)' % (patch.get('submitter').get('name'), patch.get('submitter').get('email')), patch.get('state'), 'yes' if patch.get('archived') else 'no', (patch.get('delegate') or {}).get('username', ''), ] output.append([]) for idx, header in enumerate(_list_headers): if header not in headers: continue output[-1].append(item[idx]) utils.echo_via_pager(output, headers, fmt=fmt) git-pw-2.0.0/git_pw/series.py000066400000000000000000000113431370141035400160550ustar00rootroot00000000000000""" Series subcommands. """ import logging import arrow import click from git_pw import api from git_pw import utils LOG = logging.getLogger(__name__) _list_headers = ('ID', 'Date', 'Name', 'Version', 'Submitter') _sort_fields = ('id', '-id', 'name', '-name', 'date', '-date') @click.command(name='apply', context_settings=dict( ignore_unknown_options=True, )) @click.argument('series_id', type=click.INT) @click.argument('args', nargs=-1, type=click.UNPROCESSED) def apply_cmd(series_id, args): """Apply series. Apply a series locally using the 'git-am' command. Any additional ARGS provided will be passed to the 'git-am' command. """ LOG.debug('Applying series: id=%d, args=%s', series_id, ' '.join(args)) series = api.detail('series', series_id) mbox = api.download(series['mbox']) utils.git_am(mbox, args) @click.command(name='download') @click.argument('series_id', type=click.INT) @click.argument('output', type=click.File('wb'), required=False) def download_cmd(series_id, output): """Download series in mbox format. Download a series but do not apply it. ``OUTPUT`` is optional and can be an output path or ``-`` to output to ``stdout``. If ``OUTPUT`` is not provided, the output path will be automatically chosen. """ LOG.debug('Downloading series: id=%d', series_id) path = None series = api.detail('series', series_id) path = api.download(series['mbox'], output=output) if path: LOG.info('Downloaded series to %s', path) @click.command(name='show') @utils.format_options @click.argument('series_id', type=click.INT) def show_cmd(fmt, series_id): """Show information about series. Retrieve Patchwork metadata for a series. """ LOG.debug('Showing series: id=%d', series_id) series = api.detail('series', series_id) def _format_submission(submission): return '%-4d %s' % (submission.get('id'), submission.get('name')) output = [ ('ID', series.get('id')), ('Date', series.get('date')), ('Name', series.get('name')), ('URL', series.get('web_url')), ('Submitter', '%s (%s)' % (series.get('submitter').get('name'), series.get('submitter').get('email'))), ('Project', series.get('project').get('name')), ('Version', series.get('version')), ('Received', '%d of %d' % (series.get('received_total'), series.get('total'))), ('Complete', series.get('received_all')), ('Cover', (_format_submission(series.get('cover_letter')) if series.get('cover_letter') else ''))] prefix = 'Patches' for patch in series.get('patches'): output.append((prefix, _format_submission(patch))) prefix = '' utils.echo(output, ['Property', 'Value'], fmt=fmt) @click.command(name='list') @click.option('--submitter', 'submitters', metavar='SUBMITTER', multiple=True, help='Show only series by these submitters. Should be an ' 'email, name or ID.') @utils.pagination_options(sort_fields=_sort_fields, default_sort='-date') @utils.format_options(headers=_list_headers) @click.argument('name', required=False) @api.validate_multiple_filter_support def list_cmd(submitters, limit, page, sort, fmt, headers, name): """List series. List series on the Patchwork instance. """ LOG.debug('List series: submitters=%s, limit=%r, page=%r, sort=%r', ','.join(submitters), limit, page, sort) params = [] for submitter in submitters: if submitter.isdigit(): params.append(('submitter', submitter)) else: # we support server-side filtering by email (but not name) in 1.1 if api.version() >= (1, 1) and '@' in submitter: params.append(('submitter', submitter)) else: params.extend( api.retrieve_filter_ids('people', 'submitter', submitter)) params.extend([ ('q', name), ('page', page), ('per_page', limit), ('order', sort), ]) series = api.index('series', params) # Format and print output output = [] for series_ in series: item = [ series_.get('id'), arrow.get(series_.get('date')).humanize(), utils.trim(series_.get('name') or ''), series_.get('version'), '%s (%s)' % (series_.get('submitter').get('name'), series_.get('submitter').get('email')) ] output.append([]) for idx, header in enumerate(_list_headers): if header not in headers: continue output[-1].append(item[idx]) utils.echo_via_pager(output, headers, fmt=fmt) git-pw-2.0.0/git_pw/shell.py000066400000000000000000000107311370141035400156720ustar00rootroot00000000000000""" Command-line interface to the Patchwork API. """ import click from git_pw import bundle as bundle_cmds from git_pw import config from git_pw import logger from git_pw import patch as patch_cmds from git_pw import series as series_cmds CONF = config.CONF @click.group() @click.option('--debug', default=False, is_flag=True, help="Output more information about what's going on.") @click.option('--token', metavar='TOKEN', envvar='PW_TOKEN', help="Authentication token. Defaults to the value of " "'git config pw.token'.") @click.option('--username', metavar='USERNAME', envvar='PW_USERNAME', help="Authentication username. Defaults to the value of " "'git config pw.username'.") @click.option('--password', metavar='PASSWORD', envvar='PW_PASSWORD', help="Authentication password. Defaults to the value of " "'git config pw.password'.") @click.option('--server', metavar='SERVER', envvar='PW_SERVER', help="Patchwork server address/hostname. Defaults to the value " "of 'git config pw.server'.") @click.option('--project', metavar='PROJECT', envvar='PW_PROJECT', help="Patchwork project. Defaults the value of " "'git config pw.project'.") @click.version_option() def cli(debug, token, username, password, server, project): """git-pw is a tool for integrating Git with Patchwork. git-pw can interact with individual patches, complete patch series, and customized bundles. The three major subcommands are *patch*, *bundle*, and *series*. The git-pw utility is a wrapper which makes REST calls to the Patchwork service. To use git-pw, you must set up your environment by configuring your Patchwork server URL and either an API token or a username and password. To configure the server URL, run:: git config pw.server http://pw.server.com/path/to/patchwork To configure the token, run:: git config pw.token token Alternatively, you can pass these options via command line parameters or environment variables. For more information on any of the commands, simply pass ``--help`` to the appropriate command. """ logger.configure_verbosity(debug) CONF.debug = debug CONF.token = token CONF.username = username CONF.password = password CONF.server = server CONF.project = project @cli.group() def patch(): """Interact with patches. Patches are the central object in Patchwork structure. A patch contains both a diff and some metadata, such as the name, the description, the author, the version of the patch etc. Patchwork stores not only the patch itself but also various metadata associated with the email that the patch was parsed from, such as the message headers or the date the message itself was received. """ pass @cli.group() def series(): """Interact with series. Series are groups of patches, along with an optional cover letter. Series are mostly dumb containers, though they also contain some metadata themselves, such as a version (which is inherited by the patches and cover letter) and a count of the number of patches found in the series. """ pass @cli.group() def bundle(): """Interact with bundles. Bundles are custom, user-defined groups of patches. Bundles can be used to keep patch lists, preserving order, for future inclusion in a tree. There's no restriction of number of patches and they don't even need to be in the same project. A single patch also can be part of multiple bundles at the same time. An example of Bundle usage would be keeping track of the Patches that are ready for merge to the tree. """ pass patch.add_command(patch_cmds.apply_cmd) patch.add_command(patch_cmds.show_cmd) patch.add_command(patch_cmds.download_cmd) patch.add_command(patch_cmds.update_cmd) patch.add_command(patch_cmds.list_cmd) series.add_command(series_cmds.apply_cmd) series.add_command(series_cmds.show_cmd) series.add_command(series_cmds.download_cmd) series.add_command(series_cmds.list_cmd) bundle.add_command(bundle_cmds.apply_cmd) bundle.add_command(bundle_cmds.show_cmd) bundle.add_command(bundle_cmds.download_cmd) bundle.add_command(bundle_cmds.list_cmd) bundle.add_command(bundle_cmds.create_cmd) bundle.add_command(bundle_cmds.update_cmd) bundle.add_command(bundle_cmds.delete_cmd) bundle.add_command(bundle_cmds.add_cmd) bundle.add_command(bundle_cmds.remove_cmd) git-pw-2.0.0/git_pw/utils.py000066400000000000000000000121601370141035400157210ustar00rootroot00000000000000""" Utility functions. """ from __future__ import print_function import csv import io import os import subprocess import sys import click from tabulate import tabulate def ensure_str(s): if s is None: s = '' elif isinstance(s, bytes): s = s.decode('utf-8', 'strict') elif not isinstance(s, str): s = str(s) return s def trim(string, length=70): # type: (str, int) -> str """Trim a string to the given length.""" return (string[:length - 1] + '...') if len(string) > length else string def git_config(value): """Parse config from ``git-config`` cache. Returns: Matching setting for ``key`` if available, else None. """ try: output = subprocess.check_output(['git', 'config', value]) except subprocess.CalledProcessError: output = b'' return output.decode('utf-8').strip() def git_am(mbox, args): """Execute git-am on a given mbox file.""" cmd = ['git', 'am'] if args: cmd.extend(args) else: cmd.append('-3') cmd.append(mbox) try: output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as exc: print(exc.output.decode('utf-8')) sys.exit(exc.returncode) else: print(output.decode('utf-8'), end='') def _tabulate(output, headers, fmt): fmt = fmt or git_config('pw.format') or 'table' if fmt == 'table': return tabulate(output, headers, tablefmt='psql') elif fmt == 'simple': return tabulate(output, headers, tablefmt='simple') elif fmt == 'csv': result = io.StringIO() writer = csv.writer( result, quoting=csv.QUOTE_ALL, lineterminator=os.linesep) writer.writerow([ensure_str(h) for h in headers]) for item in output: writer.writerow([ensure_str(i) for i in item]) return result.getvalue() print('pw.format must be one of: table, simple, csv') sys.exit(1) def _echo_via_pager(pager, output): env = dict(os.environ) # When the LESS environment variable is unset, Git sets it to FRX (if # LESS environment variable is set, Git does not change it at all). if 'LESS' not in env: env['LESS'] = 'FRX' pager = subprocess.Popen(pager.split(), stdin=subprocess.PIPE, env=env) # TODO(stephenfin): This is potential hangover from Python 2 days if not isinstance(output, bytes): output = output.encode('utf-8', 'strict') try: pager.communicate(input=output) except (IOError, KeyboardInterrupt): pass else: pager.stdin.close() while True: try: pager.wait() except KeyboardInterrupt: pass else: break def echo_via_pager(output, headers, fmt): """Echo using git's default pager. Wrap ``click.echo_via_pager``, setting some environment variables in the processs to mimic the pager settings used by Git: The order of preference is the ``$GIT_PAGER`` environment variable, then ``core.pager`` configuration, then ``$PAGER``, and then the default chosen at compile time (usually ``less``). """ output = _tabulate(output, headers, fmt) pager = os.environ.get('GIT_PAGER', None) if pager: _echo_via_pager(pager, output) return pager = git_config('core.parser') if pager: _echo_via_pager(pager, output) return pager = os.environ.get('PAGER', None) if pager: _echo_via_pager(pager, output) return _echo_via_pager('less', output) def echo(output, headers, fmt): click.echo(_tabulate(output, headers, fmt)) def pagination_options(sort_fields, default_sort): """Shared pagination options.""" def _pagination_options(f): f = click.option('--limit', metavar='LIMIT', type=click.INT, help='Maximum number of items to show.')(f) f = click.option('--page', metavar='PAGE', type=click.INT, help='Page to retrieve items from. This is ' 'influenced by the size of LIMIT.')(f) f = click.option('--sort', metavar='FIELD', default=default_sort, type=click.Choice(sort_fields), help='Sort output on given field.')(f) return f return _pagination_options def format_options(original_function=None, headers=None): """Shared output format options.""" def _format_options(f): f = click.option('--format', '-f', 'fmt', default=None, type=click.Choice(['simple', 'table', 'csv']), help="Output format. Defaults to the value of " "'git config pw.format' else 'table'.")(f) if headers: f = click.option('--column', '-c', 'headers', metavar='COLUMN', multiple=True, default=headers, type=click.Choice(headers), help='Columns to be included in output.')(f) return f if original_function: return _format_options(original_function) return _format_options git-pw-2.0.0/man/000077500000000000000000000000001370141035400134715ustar00rootroot00000000000000git-pw-2.0.0/man/git-pw-bundle-add.1000066400000000000000000000007571370141035400167700ustar00rootroot00000000000000.TH "GIT-PW BUNDLE ADD" "1" "2020-04-17" "1.9.0" "git-pw bundle add Manual" .SH NAME git-pw\-bundle\-add \- Add one or more patches to a bundle. .SH SYNOPSIS .B git-pw bundle add [OPTIONS] BUNDLE_ID PATCH_IDS... .SH DESCRIPTION Add one or more patches to a bundle. .PP Append the provided PATCH_IDS to bundle BUNDLE_ID. .PP Requires API version 1.2 or greater. .SH OPTIONS .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-bundle-apply.1000066400000000000000000000005341370141035400173560ustar00rootroot00000000000000.TH "GIT-PW BUNDLE APPLY" "1" "2020-04-17" "1.9.0" "git-pw bundle apply Manual" .SH NAME git-pw\-bundle\-apply \- Apply bundle. .SH SYNOPSIS .B git-pw bundle apply [OPTIONS] BUNDLE_ID [ARGS]... .SH DESCRIPTION Apply bundle. .PP Apply a bundle locally using the 'git-am' command. Any additional ARGS provided will be passed to the 'git-am' command. git-pw-2.0.0/man/git-pw-bundle-create.1000066400000000000000000000011321370141035400174670ustar00rootroot00000000000000.TH "GIT-PW BUNDLE CREATE" "1" "2020-04-17" "1.9.0" "git-pw bundle create Manual" .SH NAME git-pw\-bundle\-create \- Create a bundle. .SH SYNOPSIS .B git-pw bundle create [OPTIONS] NAME PATCH_IDS... .SH DESCRIPTION Create a bundle. .PP Create a bundle with the given NAME and patches from PATCH_ID. .PP Requires API version 1.2 or greater. .SH OPTIONS .TP \fB\-\-public\fP / \-\-private Allow other users to view this bundle. If private, only you will be able to see this bundle. .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-bundle-delete.1000066400000000000000000000006541370141035400174760ustar00rootroot00000000000000.TH "GIT-PW BUNDLE DELETE" "1" "2020-04-17" "1.9.0" "git-pw bundle delete Manual" .SH NAME git-pw\-bundle\-delete \- Delete a bundle. .SH SYNOPSIS .B git-pw bundle delete [OPTIONS] BUNDLE_ID .SH DESCRIPTION Delete a bundle. .PP Delete bundle BUNDLE_ID. .PP Requires API version 1.2 or greater. .SH OPTIONS .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-bundle-download.1000066400000000000000000000007321370141035400200400ustar00rootroot00000000000000.TH "GIT-PW BUNDLE DOWNLOAD" "1" "2020-04-17" "1.9.0" "git-pw bundle download Manual" .SH NAME git-pw\-bundle\-download \- Download bundle in mbox format. .SH SYNOPSIS .B git-pw bundle download [OPTIONS] BUNDLE_ID [OUTPUT] .SH DESCRIPTION Download bundle in mbox format. .PP Download a bundle but do not apply it. ``OUTPUT`` is optional and can be an output path or ``-`` to output to ``stdout``. If ``OUTPUT`` is not provided, the output path will be automatically chosen. git-pw-2.0.0/man/git-pw-bundle-list.1000066400000000000000000000014431370141035400172040ustar00rootroot00000000000000.TH "GIT-PW BUNDLE LIST" "1" "2020-04-17" "1.9.0" "git-pw bundle list Manual" .SH NAME git-pw\-bundle\-list \- List bundles. .SH SYNOPSIS .B git-pw bundle list [OPTIONS] [NAME] .SH DESCRIPTION List bundles. .PP List bundles on the Patchwork instance. .SH OPTIONS .TP \fB\-\-owner\fP OWNER Show only bundles with these owners. Should be an email, name or ID. Private bundles of other users will not be shown. .TP \fB\-\-sort\fP FIELD Sort output on given field. .TP \fB\-\-page\fP PAGE Page to retrieve items from. This is influenced by the size of LIMIT. .TP \fB\-\-limit\fP LIMIT Maximum number of items to show. .TP \fB\-c,\fP \-\-column COLUMN Columns to be included in output. .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-bundle-remove.1000066400000000000000000000010051370141035400175200ustar00rootroot00000000000000.TH "GIT-PW BUNDLE REMOVE" "1" "2020-04-17" "1.9.0" "git-pw bundle remove Manual" .SH NAME git-pw\-bundle\-remove \- Remove one or more patches from a bundle. .SH SYNOPSIS .B git-pw bundle remove [OPTIONS] BUNDLE_ID PATCH_IDS... .SH DESCRIPTION Remove one or more patches from a bundle. .PP Remove the provided PATCH_IDS to bundle BUNDLE_ID. .PP Requires API version 1.2 or greater. .SH OPTIONS .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-bundle-show.1000066400000000000000000000006501370141035400172100ustar00rootroot00000000000000.TH "GIT-PW BUNDLE SHOW" "1" "2020-04-17" "1.9.0" "git-pw bundle show Manual" .SH NAME git-pw\-bundle\-show \- Show information about bundle. .SH SYNOPSIS .B git-pw bundle show [OPTIONS] BUNDLE_ID .SH DESCRIPTION Show information about bundle. .PP Retrieve Patchwork metadata for a bundle. .SH OPTIONS .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-bundle-update.1000066400000000000000000000014311370141035400175100ustar00rootroot00000000000000.TH "GIT-PW BUNDLE UPDATE" "1" "2020-04-17" "1.9.0" "git-pw bundle update Manual" .SH NAME git-pw\-bundle\-update \- Update a bundle. .SH SYNOPSIS .B git-pw bundle update [OPTIONS] BUNDLE_ID .SH DESCRIPTION Update a bundle. .PP Update bundle BUNDLE_ID. If PATCH_IDs are specified, this will overwrite all patches in the bundle. Use 'bundle add' and 'bundle remove' to add or remove patches. .PP Requires API version 1.2 or greater. .SH OPTIONS .TP \fB\-\-name\fP TEXT .PP .TP \fB\-\-patch\fP INTEGER Add the specified patch(es) to the bundle. .TP \fB\-\-public\fP / \-\-private Allow other users to view this bundle. If private, only you will be able to see this bundle. .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-bundle.1000066400000000000000000000034231370141035400162330ustar00rootroot00000000000000.TH "GIT-PW BUNDLE" "1" "2020-04-17" "1.9.0" "git-pw bundle Manual" .SH NAME git-pw\-bundle \- Interact with bundles. .SH SYNOPSIS .B git-pw bundle [OPTIONS] COMMAND [ARGS]... .SH DESCRIPTION Interact with bundles. .PP Bundles are custom, user-defined groups of patches. Bundles can be used to keep patch lists, preserving order, for future inclusion in a tree. There's no restriction of number of patches and they don't even need to be in the same project. A single patch also can be part of multiple bundles at the same time. An example of Bundle usage would be keeping track of the Patches that are ready for merge to the tree. .SH COMMANDS .PP \fBapply\fP Apply bundle. See \fBgit-pw bundle-apply(1)\fP for full documentation on the \fBapply\fP command. .PP \fBshow\fP Show information about bundle. See \fBgit-pw bundle-show(1)\fP for full documentation on the \fBshow\fP command. .PP \fBdownload\fP Download bundle in mbox format. See \fBgit-pw bundle-download(1)\fP for full documentation on the \fBdownload\fP command. .PP \fBlist\fP List bundles. See \fBgit-pw bundle-list(1)\fP for full documentation on the \fBlist\fP command. .PP \fBcreate\fP Create a bundle. See \fBgit-pw bundle-create(1)\fP for full documentation on the \fBcreate\fP command. .PP \fBupdate\fP Update a bundle. See \fBgit-pw bundle-update(1)\fP for full documentation on the \fBupdate\fP command. .PP \fBdelete\fP Delete a bundle. See \fBgit-pw bundle-delete(1)\fP for full documentation on the \fBdelete\fP command. .PP \fBadd\fP Add one or more patches to a bundle. See \fBgit-pw bundle-add(1)\fP for full documentation on the \fBadd\fP command. .PP \fBremove\fP Remove one or more patches from a bundle. See \fBgit-pw bundle-remove(1)\fP for full documentation on the \fBremove\fP command. git-pw-2.0.0/man/git-pw-patch-apply.1000066400000000000000000000010751370141035400172050ustar00rootroot00000000000000.TH "GIT-PW PATCH APPLY" "1" "2020-04-17" "1.9.0" "git-pw patch apply Manual" .SH NAME git-pw\-patch\-apply \- Apply patch. .SH SYNOPSIS .B git-pw patch apply [OPTIONS] PATCH_ID [ARGS]... .SH DESCRIPTION Apply patch. .PP Apply a patch locally using the 'git-am' command. Any additional ARGS provided will be passed to the 'git-am' command. .SH OPTIONS .TP \fB\-\-series\fP SERIES Series to include dependencies from. Defaults to latest. .TP \fB\-\-deps\fP / \-\-no\-deps When applying the patch, include dependencies if available. Defaults to using the most recent series. git-pw-2.0.0/man/git-pw-patch-download.1000066400000000000000000000011121370141035400176570ustar00rootroot00000000000000.TH "GIT-PW PATCH DOWNLOAD" "1" "2020-04-17" "1.9.0" "git-pw patch download Manual" .SH NAME git-pw\-patch\-download \- Download patch in diff or mbox format. .SH SYNOPSIS .B git-pw patch download [OPTIONS] PATCH_ID [OUTPUT] .SH DESCRIPTION Download patch in diff or mbox format. .PP Download a patch but do not apply it. ``OUTPUT`` is optional and can be an output path or ``-`` to output to ``stdout``. If ``OUTPUT`` is not provided, the output path will be automatically chosen. .SH OPTIONS .TP \fB\-\-diff\fP Show patch in diff format. .TP \fB\-\-mbox\fP Show patch in mbox format. git-pw-2.0.0/man/git-pw-patch-list.1000066400000000000000000000020661370141035400170340ustar00rootroot00000000000000.TH "GIT-PW PATCH LIST" "1" "2020-04-17" "1.9.0" "git-pw patch list Manual" .SH NAME git-pw\-patch\-list \- List patches. .SH SYNOPSIS .B git-pw patch list [OPTIONS] [NAME] .SH DESCRIPTION List patches. .PP List patches on the Patchwork instance. .SH OPTIONS .TP \fB\-\-state\fP STATE Show only patches matching these states. Should be slugified representations of states. The available states are instance dependant. .TP \fB\-\-submitter\fP SUBMITTER Show only patches by these submitters. Should be an email, name or ID. .TP \fB\-\-delegate\fP DELEGATE Show only patches by these delegates. Should be an email or username. .TP \fB\-\-archived\fP Include patches that are archived. .TP \fB\-\-sort\fP FIELD Sort output on given field. .TP \fB\-\-page\fP PAGE Page to retrieve items from. This is influenced by the size of LIMIT. .TP \fB\-\-limit\fP LIMIT Maximum number of items to show. .TP \fB\-c,\fP \-\-column COLUMN Columns to be included in output. .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-patch-show.1000066400000000000000000000006401370141035400170350ustar00rootroot00000000000000.TH "GIT-PW PATCH SHOW" "1" "2020-04-17" "1.9.0" "git-pw patch show Manual" .SH NAME git-pw\-patch\-show \- Show information about patch. .SH SYNOPSIS .B git-pw patch show [OPTIONS] PATCH_ID .SH DESCRIPTION Show information about patch. .PP Retrieve Patchwork metadata for a patch. .SH OPTIONS .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-patch-update.1000066400000000000000000000017011370141035400173360ustar00rootroot00000000000000.TH "GIT-PW PATCH UPDATE" "1" "2020-04-17" "1.9.0" "git-pw patch update Manual" .SH NAME git-pw\-patch\-update \- Update one or more patches. .SH SYNOPSIS .B git-pw patch update [OPTIONS] PATCH_IDS... .SH DESCRIPTION Update one or more patches. .PP Updates one or more Patches on the Patchwork instance. Some operations may require admin or maintainer permissions. .SH OPTIONS .TP \fB\-\-commit\-ref\fP COMMIT_REF Set the patch commit reference hash .TP \fB\-\-state\fP STATE Set the patch state. Should be a slugified representation of a state. The available states are instance dependant and can be configured using 'git config pw.states'. .TP \fB\-\-delegate\fP DELEGATE Set the patch delegate. Should be unique user identifier: either a username or a user's email address. .TP \fB\-\-archived\fP ARCHIVED Set the patch archived state. .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-patch.1000066400000000000000000000023451370141035400160630ustar00rootroot00000000000000.TH "GIT-PW PATCH" "1" "2020-04-17" "1.9.0" "git-pw patch Manual" .SH NAME git-pw\-patch \- Interact with patches. .SH SYNOPSIS .B git-pw patch [OPTIONS] COMMAND [ARGS]... .SH DESCRIPTION Interact with patches. .PP Patches are the central object in Patchwork structure. A patch contains both a diff and some metadata, such as the name, the description, the author, the version of the patch etc. Patchwork stores not only the patch itself but also various metadata associated with the email that the patch was parsed from, such as the message headers or the date the message itself was received. .SH COMMANDS .PP \fBapply\fP Apply patch. See \fBgit-pw patch-apply(1)\fP for full documentation on the \fBapply\fP command. .PP \fBshow\fP Show information about patch. See \fBgit-pw patch-show(1)\fP for full documentation on the \fBshow\fP command. .PP \fBdownload\fP Download patch in diff or mbox format. See \fBgit-pw patch-download(1)\fP for full documentation on the \fBdownload\fP command. .PP \fBupdate\fP Update one or more patches. See \fBgit-pw patch-update(1)\fP for full documentation on the \fBupdate\fP command. .PP \fBlist\fP List patches. See \fBgit-pw patch-list(1)\fP for full documentation on the \fBlist\fP command. git-pw-2.0.0/man/git-pw-series-apply.1000066400000000000000000000005341370141035400173770ustar00rootroot00000000000000.TH "GIT-PW SERIES APPLY" "1" "2020-04-17" "1.9.0" "git-pw series apply Manual" .SH NAME git-pw\-series\-apply \- Apply series. .SH SYNOPSIS .B git-pw series apply [OPTIONS] SERIES_ID [ARGS]... .SH DESCRIPTION Apply series. .PP Apply a series locally using the 'git-am' command. Any additional ARGS provided will be passed to the 'git-am' command. git-pw-2.0.0/man/git-pw-series-download.1000066400000000000000000000007321370141035400200610ustar00rootroot00000000000000.TH "GIT-PW SERIES DOWNLOAD" "1" "2020-04-17" "1.9.0" "git-pw series download Manual" .SH NAME git-pw\-series\-download \- Download series in mbox format. .SH SYNOPSIS .B git-pw series download [OPTIONS] SERIES_ID [OUTPUT] .SH DESCRIPTION Download series in mbox format. .PP Download a series but do not apply it. ``OUTPUT`` is optional and can be an output path or ``-`` to output to ``stdout``. If ``OUTPUT`` is not provided, the output path will be automatically chosen. git-pw-2.0.0/man/git-pw-series-list.1000066400000000000000000000013671370141035400172320ustar00rootroot00000000000000.TH "GIT-PW SERIES LIST" "1" "2020-04-17" "1.9.0" "git-pw series list Manual" .SH NAME git-pw\-series\-list \- List series. .SH SYNOPSIS .B git-pw series list [OPTIONS] [NAME] .SH DESCRIPTION List series. .PP List series on the Patchwork instance. .SH OPTIONS .TP \fB\-\-submitter\fP SUBMITTER Show only series by these submitters. Should be an email, name or ID. .TP \fB\-\-sort\fP FIELD Sort output on given field. .TP \fB\-\-page\fP PAGE Page to retrieve items from. This is influenced by the size of LIMIT. .TP \fB\-\-limit\fP LIMIT Maximum number of items to show. .TP \fB\-c,\fP \-\-column COLUMN Columns to be included in output. .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-series-show.1000066400000000000000000000006501370141035400172310ustar00rootroot00000000000000.TH "GIT-PW SERIES SHOW" "1" "2020-04-17" "1.9.0" "git-pw series show Manual" .SH NAME git-pw\-series\-show \- Show information about series. .SH SYNOPSIS .B git-pw series show [OPTIONS] SERIES_ID .SH DESCRIPTION Show information about series. .PP Retrieve Patchwork metadata for a series. .SH OPTIONS .TP \fB\-f,\fP \-\-format [simple|table|csv] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.0.0/man/git-pw-series.1000066400000000000000000000017751370141035400162640ustar00rootroot00000000000000.TH "GIT-PW SERIES" "1" "2020-04-17" "1.9.0" "git-pw series Manual" .SH NAME git-pw\-series \- Interact with series. .SH SYNOPSIS .B git-pw series [OPTIONS] COMMAND [ARGS]... .SH DESCRIPTION Interact with series. .PP Series are groups of patches, along with an optional cover letter. Series are mostly dumb containers, though they also contain some metadata themselves, such as a version (which is inherited by the patches and cover letter) and a count of the number of patches found in the series. .SH COMMANDS .PP \fBapply\fP Apply series. See \fBgit-pw series-apply(1)\fP for full documentation on the \fBapply\fP command. .PP \fBshow\fP Show information about series. See \fBgit-pw series-show(1)\fP for full documentation on the \fBshow\fP command. .PP \fBdownload\fP Download series in mbox format. See \fBgit-pw series-download(1)\fP for full documentation on the \fBdownload\fP command. .PP \fBlist\fP List series. See \fBgit-pw series-list(1)\fP for full documentation on the \fBlist\fP command. git-pw-2.0.0/man/git-pw.1000066400000000000000000000037431370141035400147710ustar00rootroot00000000000000.TH "GIT-PW" "1" "2020-04-17" "1.9.0" "git-pw Manual" .SH NAME git-pw \- git-pw is a tool for integrating Git with... .SH SYNOPSIS .B git-pw [OPTIONS] COMMAND [ARGS]... .SH DESCRIPTION git-pw is a tool for integrating Git with Patchwork. .PP git-pw can interact with individual patches, complete patch series, and customized bundles. The three major subcommands are *patch*, *bundle*, and *series*. .PP The git-pw utility is a wrapper which makes REST calls to the Patchwork service. To use git-pw, you must set up your environment by configuring your Patchwork server URL and either an API token or a username and password. To configure the server URL, run:: .PP git config pw.server http://pw.server.com/path/to/patchwork .PP To configure the token, run:: .PP git config pw.token token .PP Alternatively, you can pass these options via command line parameters or environment variables. .PP For more information on any of the commands, simply pass ``--help`` to the appropriate command. .SH OPTIONS .TP \fB\-\-debug\fP Output more information about what's going on. .TP \fB\-\-token\fP TOKEN Authentication token. Defaults to the value of 'git config pw.token'. .TP \fB\-\-username\fP USERNAME Authentication username. Defaults to the value of 'git config pw.username'. .TP \fB\-\-password\fP PASSWORD Authentication password. Defaults to the value of 'git config pw.password'. .TP \fB\-\-server\fP SERVER Patchwork server address/hostname. Defaults to the value of 'git config pw.server'. .TP \fB\-\-project\fP PROJECT Patchwork project. Defaults the value of 'git config pw.project'. .TP \fB\-\-version\fP Show the version and exit. .SH COMMANDS .PP \fBpatch\fP Interact with patches. See \fBgit-pw-patch(1)\fP for full documentation on the \fBpatch\fP command. .PP \fBseries\fP Interact with series. See \fBgit-pw-series(1)\fP for full documentation on the \fBseries\fP command. .PP \fBbundle\fP Interact with bundles. See \fBgit-pw-bundle(1)\fP for full documentation on the \fBbundle\fP command. git-pw-2.0.0/releasenotes/000077500000000000000000000000001370141035400154075ustar00rootroot00000000000000git-pw-2.0.0/releasenotes/notes/000077500000000000000000000000001370141035400165375ustar00rootroot00000000000000git-pw-2.0.0/releasenotes/notes/api-v1-1-5c804713ef435739.yaml000066400000000000000000000000751370141035400226660ustar00rootroot00000000000000--- features: - | Patchwork API v1.1 is now supported. git-pw-2.0.0/releasenotes/notes/bundle-crud-47aadae6eb7a20ad.yaml000066400000000000000000000007111370141035400243260ustar00rootroot00000000000000--- features: - | The following ``bundle`` commands have been added: - ``bundle create`` - ``bundle update`` - ``bundle delete`` - ``bundle add`` - ``bundle remove`` Together, these allow for creation, modification and deletion of bundles. Bundles are custom, user-defined groups of patches that can be used to keep patch lists, preserving order, for future inclusion in a tree. These commands require API v1.2. git-pw-2.0.0/releasenotes/notes/drop-pypy-support-f670deb05ef527fe.yaml000066400000000000000000000001041370141035400254570ustar00rootroot00000000000000--- upgrade: - | *git-pw* no longer officially supports PyPy. git-pw-2.0.0/releasenotes/notes/drop-python34-support-5e01360fff605972.yaml000066400000000000000000000001001370141035400256360ustar00rootroot00000000000000--- upgrade: - | Support for Python 3.4 has been dropped. git-pw-2.0.0/releasenotes/notes/enforce-filtering-by-project-59ed29c4b7edc0a5.yaml000066400000000000000000000007111370141035400274610ustar00rootroot00000000000000--- upgrade: - | Project configuration, e.g. via ``git config pw.project``, is now required. Previously, not configuring a project would result in items for for all projects being listed. This did not scale for larger instances such as `patchwork.ozlabs.org` and proved confusing for some users. If this functionality is required, you must explicitly request it using the ``*`` character, e.g. ``git pw patch list --project='*'``. git-pw-2.0.0/releasenotes/notes/filter-item-list-by-user-id-4f4e7d6dc402093b.yaml000066400000000000000000000003401370141035400270040ustar00rootroot00000000000000--- features: - | It is now possible to filter patches, bundles and series and the IDs of users that submitted or are delegated to the item in question. For example:: $ git pw patch list --submitter 1 git-pw-2.0.0/releasenotes/notes/filter-multiple-matches-197ff839f6b578da.yaml000066400000000000000000000003621370141035400264310ustar00rootroot00000000000000--- features: - | Filtering patches, bundles and series by user will now only display a warning if multiple matches are found for a given filter and you're using API v1.0. Previously this would have been an unconditional error. git-pw-2.0.0/releasenotes/notes/handle-error-codes-d72c575fb2d9b452.yaml000066400000000000000000000001151370141035400253170ustar00rootroot00000000000000--- fixes: - | HTTP 404 and HTTP 5xx errors are now handled correctly. git-pw-2.0.0/releasenotes/notes/initial-release-0aad09064615d023.yaml000066400000000000000000000001441370141035400245240ustar00rootroot00000000000000--- prelude: > Initial release of `git-pw` using the new REST API provided in Patchwork 2.0 git-pw-2.0.0/releasenotes/notes/issue-24-60a9fa796f666f35.yaml000066400000000000000000000004401370141035400231540ustar00rootroot00000000000000--- fixes: - | Resolve an issue that prevented the following filtering when using API v1.1: - Filter patches or series by submitter using the submitter's name - Filter patches by delegate using the delegate's email - Filter bundles by owner using the owner's email git-pw-2.0.0/releasenotes/notes/issue-29-884269fdf35f64b2.yaml000066400000000000000000000007611370141035400231650ustar00rootroot00000000000000--- features: - | Many commands now take a ``--format``/``-f`` parameter, which can be used to control the output format. Three formats are currently supported: - ``table`` (default) - ``simple`` (a version of table with less markup) - ``csv`` (comma-separated output) - | All list comands now take a ``--column``/``-c`` parameter, which can be used to control what columns are output. This can be specified multiples times. All columns are output by default. git-pw-2.0.0/releasenotes/notes/issue-43-c2c166e1fa23fe76.yaml000066400000000000000000000002251370141035400232720ustar00rootroot00000000000000--- fixes: - | An issue that resulted in invalid output on Python 3 when a patch or series was not successfully applied has been resolved. git-pw-2.0.0/releasenotes/notes/issue-44-66b78577e9534f16.yaml000066400000000000000000000002131370141035400230200ustar00rootroot00000000000000--- upgrade: - | Downloaded patches, series and bundles are now saved to a temporary directory instead of the current directory. git-pw-2.0.0/releasenotes/notes/issue-46-50933643cd5c8db0.yaml000066400000000000000000000001061370141035400231320ustar00rootroot00000000000000--- upgrade: - | CSV-formatted output is now quoted by default. git-pw-2.0.0/releasenotes/notes/issue-47-a9ac87642050d289.yaml000066400000000000000000000001541370141035400230650ustar00rootroot00000000000000--- fixes: - | Resolved an issue that prevented viewing patch diffs/mboxes in stdout on Python 3. git-pw-2.0.0/releasenotes/notes/issue-48-694495f722119fed.yaml000066400000000000000000000002131370141035400230750ustar00rootroot00000000000000--- fixes: - | An info-level log is now correctly skipped when downloading patches, bundles or series to ``STDOUT`` on Python 3. git-pw-2.0.0/releasenotes/notes/issue-49-865c4f1657b97fce.yaml000066400000000000000000000001631370141035400232450ustar00rootroot00000000000000--- fixes: - | An issue with the unicode data when using the CSV format on Python 2.7 has been resolved. git-pw-2.0.0/releasenotes/notes/issue-55-bfcf05e02ad305b1.yaml000066400000000000000000000001711370141035400233330ustar00rootroot00000000000000--- features: - | It is now possible to filter patches by hash. For example:: git pw patch list --hash HASH git-pw-2.0.0/releasenotes/notes/passthrough-git-am-arguments-23cd0b292304d648.yaml000066400000000000000000000004641370141035400272220ustar00rootroot00000000000000--- upgrade: - | ``git pw patch apply``, ``git pw bundle apply`` and ``git pw series apply`` will now pass any additional arugments provided through to ``git am``. For example:: $ git pw patch apply 123 --signoff Previously it was necessary to escape these arguments with ``--``. git-pw-2.0.0/releasenotes/notes/patch-states-b88240569f8474f1.yaml000066400000000000000000000004171370141035400240420ustar00rootroot00000000000000--- features: - | The ``--state`` option of the ``git pw patch update`` command now supports auto-complete for the default set of states provided by Patchwork. If necessary, these states can be overridden using the ``pw.states`` ``git config`` setting. git-pw-2.0.0/releasenotes/notes/python-2-deprecation-c87e311384eab29b.yaml000066400000000000000000000002131370141035400256070ustar00rootroot00000000000000--- other: - | *git-pw* 1.9.0 will be the last version to support Python 2.7. *git-pw* 2.0.0 will require Python 3.5 or greater. git-pw-2.0.0/releasenotes/notes/random-fixes-3da473a63c253f2d.yaml000066400000000000000000000005521370141035400242270ustar00rootroot00000000000000--- fixes: - | Patches are now automatically filtered by ``new`` state, as originally intended. You can override this by specifying the ``--state`` filter. - | Some issues with filtering patches, series and bundles by submitters and delegates, submitters, and owners respectively are resolved. - | API errors are now handled correctly. git-pw-2.0.0/releasenotes/notes/remove-python-3-2-3-3-support-8987031bed2c0333.yaml000066400000000000000000000001321370141035400266160ustar00rootroot00000000000000--- other: - | Python 3.2 and 3.3 are no longer supported as both versions are EOL. git-pw-2.0.0/releasenotes/notes/require-server-version-93ac0818c293b85e.yaml000066400000000000000000000003061370141035400262300ustar00rootroot00000000000000--- upgrade: - | Configuring a server without the API version, e.g. via ``git config pw.server`` will now result in a warning. An error will be raised in a future release of *git-pw*. git-pw-2.0.0/releasenotes/notes/save-patches-before-applying-c5e786156d47d752.yaml000066400000000000000000000004131370141035400271610ustar00rootroot00000000000000--- fixes: - | The ``git pw {patch,series,bundle} apply`` commands will now save the downloaded patches before applying them. This avoids ascii/unicode issues on different versions of Python and avoids the need to load the entire patch into memory. git-pw-2.0.0/releasenotes/notes/save-patches-to-file-c667ab7dd0b73ead.yaml000066400000000000000000000013151370141035400257760ustar00rootroot00000000000000--- features: - | Patches, series and bundles downloaded using the ``download`` command will now be saved to a file by default instead of output to stdout. By default, this will use the name provided by Patchwork but you can override this by passing a specific filename. For example:: $ git pw patch download 1234 hello-world.patch You can also output to ``stdout`` using the ``-`` shortcut. For example:: $ git pw patch download 1234 - upgrade: - | Patches, series and bundles downloaded using the ``download`` command will now be saved to a file by default. To continue outputing to ``stdout``, use ``-``. For example:: $ git pw patch download 1234 - git-pw-2.0.0/releasenotes/notes/update-multiple-patches-ed515cd53964c203.yaml000066400000000000000000000004351370141035400263200ustar00rootroot00000000000000--- features: - | You can now list multiple patches for ``git pw patch update``. This allows you to do bulk updates, e.g. for a series of patches (because series states are not yet supported). For example:: $ git pw patch update --state accepted 123 124 125 126 git-pw-2.0.0/releasenotes/notes/use-bundle-names-b1b3ee5c2858c96b.yaml000066400000000000000000000005431370141035400250730ustar00rootroot00000000000000--- features: - | It's now possible to use a bundle name in addition to the numeric ID for the ``bundle download``, ``bundle apply`` and ``bundle show`` commands. For example:: $ git pw bundle show 'My sample bundle' As bundle names are not necessarily unique, this will fail if multiple bundles match the provided string. git-pw-2.0.0/releasenotes/notes/use-git-pager-settings-ec6555d8311a8bec.yaml000066400000000000000000000006731370141035400262360ustar00rootroot00000000000000--- features: - | *git-pw* will now choose use the same rules as Git to select the pager used for ``list`` commands. This means the pager will be chosen based on a variety of environment variables and git config options: The order of preference is the ``$GIT_PAGER`` environment variable, then ``core.pager`` configuration, then ``$PAGER``, and then the default chosen at compile time (usually ``less``) git-pw-2.0.0/releasenotes/notes/warn-on-multiple-filters-a4e01fdb5cf6e459.yaml000066400000000000000000000003311370141035400266570ustar00rootroot00000000000000--- upgrade: - | A warning is now raised when using multiple filters (e.g. ``git pw bundle --owner foo --owner bar``) with Patchwork API v1.0. This is not supported and will result in confusing results. git-pw-2.0.0/requirements.txt000066400000000000000000000000741370141035400162030ustar00rootroot00000000000000click>=6.0,<8.0 requests>2.0,<3.0 tabulate>=0.8 arrow>=0.10 git-pw-2.0.0/rpm/000077500000000000000000000000001370141035400135145ustar00rootroot00000000000000git-pw-2.0.0/rpm/git-pw.spec000066400000000000000000000020331370141035400155750ustar00rootroot00000000000000Name: git-pw Version: 1.9.0 Release: 1%{?dist} Summary: Git-Patchwork integration tool License: MIT URL: https://github.com/getpatchwork/git-pw Source0: https://github.com/getpatchwork/git-pw/releases/download/%{version}/%{name}-%{version}.tar.gz BuildArch: noarch BuildRequires: python3-devel BuildRequires: python3-pbr BuildRequires: python3-setuptools Requires: git %description git-pw is a tool for integrating Git with Patchwork, the web-based patch tracking system. %prep %autosetup -n %{name}-%{version} # Remove bundled egg-info rm -rf %{name}.egg-info %build %py3_build %install %py3_install mkdir -p %{buildroot}%{_mandir}/man1 install -p -D -m 644 man/*.1 %{buildroot}%{_mandir}/man1/ %files %license LICENSE %doc README.rst %{_bindir}/git-pw %{_mandir}/man1/git-pw*.1* %{python3_sitelib}/git_pw/ %{python3_sitelib}/git_pw-%{version}-py%{python3_version}*.egg-info %changelog * Sun Apr 26 2020 Stephen Finucane - 1.9.0-1 - Initial package. git-pw-2.0.0/setup.cfg000066400000000000000000000020441370141035400145370ustar00rootroot00000000000000[metadata] name = git-pw summary = Git-Patchwork integration tool description-file = README.rst license = MIT License license_file = LICENSE classifiers = Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python Development Status :: 4 - Beta Environment :: Console Intended Audience :: Developers Intended Audience :: Information Technology License :: OSI Approved :: MIT License Operating System :: OS Independent keywords = git patchwork author = Stephen Finucane author_email = stephen@that.guru url = https://github.com/getpatchwork/git-pw project_urls = Bug Tracker = https://github.com/getpatchwork/git-pw/issues Source Code = https://github.com/getpatchwork/git-pw Documentation = https://git-pw.readthedocs.io python_requires = >=3.5 [files] packages = git_pw [entry_points] console_scripts = git-pw = git_pw.shell:cli git-pw-2.0.0/setup.py000066400000000000000000000001511370141035400144250ustar00rootroot00000000000000#!/usr/bin/env python3 from setuptools import setup setup( setup_requires=['pbr'], pbr=True, ) git-pw-2.0.0/test-requirements.txt000066400000000000000000000000551370141035400171570ustar00rootroot00000000000000mock~=3.0.0 pytest>=3.0,<6.0 pytest-cov~=2.5 git-pw-2.0.0/tests/000077500000000000000000000000001370141035400140605ustar00rootroot00000000000000git-pw-2.0.0/tests/__init__.py000066400000000000000000000000001370141035400161570ustar00rootroot00000000000000git-pw-2.0.0/tests/test_api.py000066400000000000000000000066401370141035400162500ustar00rootroot00000000000000"""Unit tests for ``git_pw/api.py``.""" import mock import pytest from git_pw import api @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'CONF') def test_get_server_undefined(mock_conf, mock_log): mock_conf.server = None with pytest.raises(SystemExit): api._get_server() assert mock_log.error.called @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'CONF') def test_get_server_missing_version(mock_conf, mock_log): mock_conf.server = 'https://example.com/api' server = api._get_server() assert mock_log.warning.called assert server == 'https://example.com/api' @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'CONF') def test_get_server_missing_version_and_path(mock_conf, mock_log): mock_conf.server = 'https://example.com/' server = api._get_server() assert mock_log.warning.called assert server == 'https://example.com/api' @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'CONF') def test_get_project_undefined(mock_conf, mock_log): mock_conf.project = None with pytest.raises(SystemExit): api._get_project() assert mock_log.error.called @mock.patch.object(api, 'CONF') def test_get_project_wildcard(mock_conf): mock_conf.project = '*' project = api._get_project() assert project == '' @mock.patch.object(api, '_get_server') def test_version_missing(mock_server): mock_server.return_value = 'https://example.com/api' assert api.version() == (1, 0) @mock.patch.object(api, '_get_server') def test_version(mock_server): mock_server.return_value = 'https://example.com/api/1.1' assert api.version() == (1, 1) @mock.patch.object(api, 'index') def test_retrieve_filter_ids_too_short(mock_index): with pytest.raises(SystemExit): api.retrieve_filter_ids('users', 'owner', 'f') assert not mock_index.called @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'index') def test_retrieve_filter_ids_no_matches(mock_index, mock_log): mock_index.return_value = [] ids = api.retrieve_filter_ids('users', 'owner', 'foo') assert mock_log.warning.called assert ids == [] @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'version') @mock.patch.object(api, 'index') def test_retrieve_filter_ids_multiple_matches_1_0(mock_index, mock_version, mock_log): mock_index.return_value = [ {'id': 1}, {'id': 2}, # incomplete but good enough ] mock_version.return_value = (1, 0) ids = api.retrieve_filter_ids('users', 'owner', 'foo') assert mock_log.warning.called assert ids == [('owner', 1), ('owner', 2)] @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'version') @mock.patch.object(api, 'index') def test_retrieve_filter_ids_multiple_matches_1_1(mock_index, mock_version, mock_log): mock_index.return_value = [ {'id': 1}, {'id': 2}, # incomplete but good enough ] mock_version.return_value = (1, 1) ids = api.retrieve_filter_ids('users', 'owner', 'foo') assert not mock_log.warning.called assert ids == [('owner', 1), ('owner', 2)] @mock.patch.object(api, 'LOG') @mock.patch.object(api, 'index') def test_retrieve_filter_ids(mock_index, mock_log): mock_index.return_value = [{'id': 1}] ids = api.retrieve_filter_ids('users', 'owner', 'foo') assert not mock_log.warning.called assert ids == [('owner', 1)] git-pw-2.0.0/tests/test_bundle.py000066400000000000000000000442611370141035400167510ustar00rootroot00000000000000import unittest from click.testing import CliRunner as CLIRunner from click import utils as click_utils import mock from git_pw import bundle @mock.patch('git_pw.api.detail') @mock.patch('git_pw.api.index') class GetBundleTestCase(unittest.TestCase): """Test the ``_get_bundle`` function.""" def test_get_by_id(self, mock_index, mock_detail): """Validate using a number (bundle ID).""" # not a valid return value (should be a JSON response) but good enough mock_detail.return_value = 'hello, world' result = bundle._get_bundle('123') assert result == mock_detail.return_value, result mock_index.assert_not_called() mock_detail.assert_called_once_with('bundles', '123') def test_get_by_name(self, mock_index, mock_detail): """Validate using a string (bundle name).""" # not a valid return value (should be a JSON response) but good enough mock_index.return_value = ['hello, world'] result = bundle._get_bundle('test') assert result == mock_index.return_value[0], result mock_detail.assert_not_called() mock_index.assert_called_once_with('bundles', [('q', 'test')]) def test_get_by_name_too_many_matches(self, mock_index, mock_detail): """Validate using a string that returns too many results.""" # not valid return values (should be a JSON response) but good enough mock_index.return_value = ['hello, world', 'uh oh'] with self.assertRaises(SystemExit): bundle._get_bundle('test') def test_get_by_name_too_few_matches(self, mock_index, mock_detail): """Validate using a string that returns too few (no) results.""" mock_index.return_value = [] with self.assertRaises(SystemExit): bundle._get_bundle('test') @mock.patch('git_pw.bundle._get_bundle') @mock.patch('git_pw.api.download') @mock.patch('git_pw.utils.git_am') class ApplyTestCase(unittest.TestCase): def test_apply_without_args(self, mock_git_am, mock_download, mock_get_bundle): """Validate calling with no arguments.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_get_bundle.return_value = rsp mock_download.return_value = 'test.patch' runner = CLIRunner() result = runner.invoke(bundle.apply_cmd, ['123']) assert result.exit_code == 0, result mock_get_bundle.assert_called_once_with('123') mock_download.assert_called_once_with(rsp['mbox']) mock_git_am.assert_called_once_with(mock_download.return_value, ()) def test_apply_with_args(self, mock_git_am, mock_download, mock_get_bundle): """Validate passthrough of arbitrary arguments to git-am.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_get_bundle.return_value = rsp mock_download.return_value = 'test.patch' runner = CLIRunner() result = runner.invoke(bundle.apply_cmd, ['123', '-3']) assert result.exit_code == 0, result mock_get_bundle.assert_called_once_with('123') mock_download.assert_called_once_with(rsp['mbox']) mock_git_am.assert_called_once_with(mock_download.return_value, ('-3',)) @mock.patch('git_pw.bundle._get_bundle') @mock.patch('git_pw.api.download') class DownloadTestCase(unittest.TestCase): def test_download(self, mock_download, mock_get_bundle): """Validate standard behavior.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_get_bundle.return_value = rsp mock_download.return_value = 'test.patch' runner = CLIRunner() result = runner.invoke(bundle.download_cmd, ['123']) assert result.exit_code == 0, result mock_get_bundle.assert_called_once_with('123') mock_download.assert_called_once_with(rsp['mbox'], output=None) def test_download_to_file(self, mock_download, mock_get_bundle): """Validate downloading to a file.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_get_bundle.return_value = rsp runner = CLIRunner() result = runner.invoke(bundle.download_cmd, ['123', 'test.patch']) assert result.exit_code == 0, result mock_get_bundle.assert_called_once_with('123') mock_download.assert_called_once_with(rsp['mbox'], output=mock.ANY) assert isinstance( mock_download.call_args[1]['output'], click_utils.LazyFile, ) class ShowTestCase(unittest.TestCase): @staticmethod def _get_bundle(**kwargs): # Not a complete response but good enough for our purposes rsp = { 'id': 123, 'date': '2017-01-01 00:00:00', 'web_url': 'https://example.com/bundle/123', 'name': 'Sample bundle', 'owner': { 'username': 'foo', }, 'project': { 'name': 'bar', }, 'patches': [ { 'id': 42, 'date': '2017-01-01 00:00:00', 'web_url': 'https://example.com/project/foo/patch/123/', 'msgid': '', 'list_archive_url': None, 'name': 'Test', 'mbox': 'https://example.com/project/foo/patch/123/mbox/', }, ], 'public': True, } rsp.update(**kwargs) return rsp @mock.patch('git_pw.bundle._get_bundle') def test_show(self, mock_get_bundle): """Validate standard behavior.""" rsp = self._get_bundle() mock_get_bundle.return_value = rsp runner = CLIRunner() result = runner.invoke(bundle.show_cmd, ['123']) assert result.exit_code == 0, result mock_get_bundle.assert_called_once_with('123') @mock.patch('git_pw.api.version', return_value=(1, 0)) @mock.patch('git_pw.api.index') @mock.patch('git_pw.utils.echo_via_pager') class ListTestCase(unittest.TestCase): @staticmethod def _get_bundle(**kwargs): return ShowTestCase._get_bundle(**kwargs) @staticmethod def _get_users(**kwargs): rsp = { 'id': 1, 'username': 'john.doe', } rsp.update(**kwargs) return rsp def test_list(self, mock_echo, mock_index, mock_version): """Validate standard behavior.""" rsp = [self._get_bundle()] mock_index.return_value = rsp runner = CLIRunner() result = runner.invoke(bundle.list_cmd, []) assert result.exit_code == 0, result mock_index.assert_called_once_with('bundles', [ ('q', None), ('page', None), ('per_page', None), ('order', 'name')]) def test_list_with_formatting(self, mock_echo, mock_index, mock_version): """Validate behavior with formatting applied.""" rsp = [self._get_bundle()] mock_index.return_value = rsp runner = CLIRunner() result = runner.invoke(bundle.list_cmd, [ '--format', 'simple', '--column', 'ID', '--column', 'Name']) assert result.exit_code == 0, result mock_echo.assert_called_once_with(mock.ANY, ('ID', 'Name'), fmt='simple') def test_list_with_filters(self, mock_echo, mock_index, mock_version): """Validate behavior with filters applied. Apply all filters, including those for pagination. """ user_rsp = [self._get_users()] bundle_rsp = [self._get_bundle()] mock_index.side_effect = [user_rsp, bundle_rsp] runner = CLIRunner() result = runner.invoke(bundle.list_cmd, [ '--owner', 'john.doe', '--owner', '2', '--limit', 1, '--page', 1, '--sort', '-name', 'test']) assert result.exit_code == 0, result calls = [ mock.call('users', [('q', 'john.doe')]), mock.call('bundles', [ ('owner', 1), ('owner', '2'), ('q', 'test'), ('page', 1), ('per_page', 1), ('order', '-name')])] mock_index.assert_has_calls(calls) @mock.patch('git_pw.api.LOG') def test_list_with_wildcard_filters(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with a "wildcard" filter. Patchwork API v1.0 did not support multiple filters correctly. Ensure the user is warned as necessary if a filter has multiple matches. """ people_rsp = [self._get_users(), self._get_users()] bundle_rsp = [self._get_bundle()] mock_index.side_effect = [people_rsp, bundle_rsp] runner = CLIRunner() runner.invoke(bundle.list_cmd, ['--owner', 'john.doe']) assert mock_log.warning.called @mock.patch('git_pw.api.LOG') def test_list_with_multiple_filters(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with use of multiple filters. Patchwork API v1.0 did not support multiple filters correctly. Ensure the user is warned as necessary if they specify multiple filters. """ people_rsp = [self._get_users()] bundle_rsp = [self._get_bundle()] mock_index.side_effect = [people_rsp, people_rsp, bundle_rsp] runner = CLIRunner() result = runner.invoke(bundle.list_cmd, ['--owner', 'john.doe', '--owner', 'user.b']) assert result.exit_code == 0, result assert mock_log.warning.called @mock.patch('git_pw.api.LOG') def test_list_api_v1_1(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with API v1.1.""" mock_version.return_value = (1, 1) user_rsp = [self._get_users()] bundle_rsp = [self._get_bundle()] mock_index.side_effect = [user_rsp, bundle_rsp] runner = CLIRunner() result = runner.invoke(bundle.list_cmd, [ '--owner', 'john.doe', '--owner', 'user.b', '--owner', 'john@example.com']) assert result.exit_code == 0, result # We should have only made a single call to '/users' (for the user # specified by an email address) since API v1.1 supports filtering with # usernames natively calls = [ mock.call('users', [('q', 'john@example.com')]), mock.call('bundles', [ ('owner', 'john.doe'), ('owner', 'user.b'), ('owner', 1), ('q', None), ('page', None), ('per_page', None), ('order', 'name')])] mock_index.assert_has_calls(calls) # We shouldn't see a warning about multiple versions either assert not mock_log.warning.called @mock.patch('git_pw.api.version', return_value=(1, 2)) @mock.patch('git_pw.api.create') @mock.patch('git_pw.utils.echo_via_pager') class CreateTestCase(unittest.TestCase): @staticmethod def _get_bundle(**kwargs): return ShowTestCase._get_bundle(**kwargs) def test_create(self, mock_echo, mock_create, mock_version): """Validate standard behavior.""" mock_create.return_value = self._get_bundle() runner = CLIRunner() result = runner.invoke(bundle.create_cmd, ['hello', '1', '2']) assert result.exit_code == 0, result mock_create.assert_called_once_with( 'bundles', [('name', 'hello'), ('patches', (1, 2)), ('public', False)] ) def test_create_with_public(self, mock_echo, mock_create, mock_version): """Validate behavior with --public option.""" mock_create.return_value = self._get_bundle() runner = CLIRunner() result = runner.invoke(bundle.create_cmd, [ 'hello', '1', '2', '--public']) assert result.exit_code == 0, result mock_create.assert_called_once_with( 'bundles', [('name', 'hello'), ('patches', (1, 2)), ('public', True)] ) @mock.patch('git_pw.api.LOG') def test_create_api_v1_1( self, mock_log, mock_echo, mock_create, mock_version ): mock_version.return_value = (1, 1) runner = CLIRunner() result = runner.invoke(bundle.create_cmd, ['hello', '1', '2']) assert result.exit_code == 1, result assert mock_log.error.called @mock.patch('git_pw.api.version', return_value=(1, 2)) @mock.patch('git_pw.api.update') @mock.patch('git_pw.api.detail') @mock.patch('git_pw.utils.echo_via_pager') class UpdateTestCase(unittest.TestCase): @staticmethod def _get_bundle(**kwargs): return ShowTestCase._get_bundle(**kwargs) def test_update(self, mock_echo, mock_detail, mock_update, mock_version): """Validate standard behavior.""" mock_update.return_value = self._get_bundle() runner = CLIRunner() result = runner.invoke( bundle.update_cmd, ['1', '--name', 'hello', '--patch', '1', '--patch', '2'], ) assert result.exit_code == 0, result mock_detail.assert_not_called() mock_update.assert_called_once_with( 'bundles', '1', [('name', 'hello'), ('patches', (1, 2))] ) def test_update_with_public( self, mock_echo, mock_detail, mock_update, mock_version, ): """Validate behavior with --public option.""" mock_update.return_value = self._get_bundle() runner = CLIRunner() result = runner.invoke(bundle.update_cmd, ['1', '--public']) assert result.exit_code == 0, result mock_detail.assert_not_called() mock_update.assert_called_once_with('bundles', '1', [('public', True)]) @mock.patch('git_pw.api.LOG') def test_update_api_v1_1( self, mock_log, mock_echo, mock_detail, mock_update, mock_version, ): mock_version.return_value = (1, 1) runner = CLIRunner() result = runner.invoke(bundle.update_cmd, ['1', '--name', 'hello']) assert result.exit_code == 1, result assert mock_log.error.called @mock.patch('git_pw.api.version', return_value=(1, 2)) @mock.patch('git_pw.api.delete') @mock.patch('git_pw.utils.echo_via_pager') class DeleteTestCase(unittest.TestCase): def test_delete(self, mock_echo, mock_delete, mock_version): """Validate standard behavior.""" mock_delete.return_value = None runner = CLIRunner() result = runner.invoke(bundle.delete_cmd, ['hello']) assert result.exit_code == 0, result mock_delete.assert_called_once_with('bundles', 'hello') @mock.patch('git_pw.api.LOG') def test_delete_api_v1_1( self, mock_log, mock_echo, mock_delete, mock_version, ): """Validate standard behavior.""" mock_version.return_value = (1, 1) runner = CLIRunner() result = runner.invoke(bundle.delete_cmd, ['hello']) assert result.exit_code == 1, result assert mock_log.error.called @mock.patch('git_pw.api.version', return_value=(1, 2)) @mock.patch('git_pw.api.update') @mock.patch('git_pw.api.detail') @mock.patch('git_pw.utils.echo_via_pager') class AddTestCase(unittest.TestCase): @staticmethod def _get_bundle(**kwargs): return ShowTestCase._get_bundle(**kwargs) def test_add( self, mock_echo, mock_detail, mock_update, mock_version, ): """Validate standard behavior.""" mock_detail.return_value = self._get_bundle() mock_update.return_value = self._get_bundle() runner = CLIRunner() result = runner.invoke(bundle.add_cmd, ['1', '1', '2']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('bundles', '1') mock_update.assert_called_once_with( 'bundles', '1', [('patches', (1, 2, 42))], ) @mock.patch('git_pw.api.LOG') def test_add_api_v1_1( self, mock_log, mock_echo, mock_detail, mock_update, mock_version, ): """Validate behavior with API v1.1.""" mock_version.return_value = (1, 1) runner = CLIRunner() result = runner.invoke(bundle.add_cmd, ['1', '1', '2']) assert result.exit_code == 1, result assert mock_log.error.called @mock.patch('git_pw.api.version', return_value=(1, 2)) @mock.patch('git_pw.api.update') @mock.patch('git_pw.api.detail') @mock.patch('git_pw.utils.echo_via_pager') class RemoveTestCase(unittest.TestCase): @staticmethod def _get_bundle(**kwargs): return ShowTestCase._get_bundle(**kwargs) def test_remove( self, mock_echo, mock_detail, mock_update, mock_version, ): """Validate standard behavior.""" mock_detail.return_value = self._get_bundle( patches=[{'id': 1}, {'id': 2}, {'id': 3}], ) mock_update.return_value = self._get_bundle() runner = CLIRunner() result = runner.invoke(bundle.remove_cmd, ['1', '1', '2']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('bundles', '1') mock_update.assert_called_once_with( 'bundles', '1', [('patches', (3,))], ) @mock.patch('git_pw.bundle.LOG') def test_remove_empty( self, mock_log, mock_echo, mock_detail, mock_update, mock_version, ): """Validate behavior when deleting would remove all patches.""" mock_detail.return_value = self._get_bundle( patches=[{'id': 1}, {'id': 2}, {'id': 3}], ) mock_update.return_value = self._get_bundle() runner = CLIRunner() result = runner.invoke(bundle.remove_cmd, ['1', '1', '2', '3']) assert result.exit_code == 1, result.output assert mock_log.error.called mock_detail.assert_called_once_with('bundles', '1') mock_update.assert_not_called() @mock.patch('git_pw.api.LOG') def test_remove_api_v1_1( self, mock_log, mock_echo, mock_detail, mock_update, mock_version, ): """Validate behavior with API v1.1.""" mock_version.return_value = (1, 1) runner = CLIRunner() result = runner.invoke(bundle.remove_cmd, ['1', '1', '2']) assert result.exit_code == 1, result assert mock_log.error.called git-pw-2.0.0/tests/test_patch.py000066400000000000000000000365731370141035400166060ustar00rootroot00000000000000import unittest import click from click.testing import CliRunner as CLIRunner from click import utils as click_utils import mock from packaging import version from git_pw import patch @mock.patch('git_pw.api.detail') @mock.patch('git_pw.api.download') @mock.patch('git_pw.utils.git_am') class ApplyTestCase(unittest.TestCase): def test_apply(self, mock_git_am, mock_download, mock_detail): """Validate behavior with no arguments.""" rsp = {'mbox': 'hello, world'} mock_detail.return_value = rsp mock_download.return_value = object() runner = CLIRunner() result = runner.invoke(patch.apply_cmd, ['123']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) mock_download.assert_called_once_with(rsp['mbox'], {'series': '*'}) mock_git_am.assert_called_once_with(mock_download.return_value, ()) def test_apply_with_series(self, mock_git_am, mock_download, mock_detail): """Validate behavior with a specific series.""" rsp = {'mbox': 'hello, world'} mock_detail.return_value = rsp mock_download.return_value = object() runner = CLIRunner() result = runner.invoke(patch.apply_cmd, ['123', '--series', 3]) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) mock_download.assert_called_once_with(rsp['mbox'], {'series': 3}) mock_git_am.assert_called_once_with(mock_download.return_value, ()) def test_apply_without_deps(self, mock_git_am, mock_download, mock_detail): """Validate behavior without using dependencies.""" rsp = {'mbox': 'hello, world'} mock_detail.return_value = rsp mock_download.return_value = object() runner = CLIRunner() result = runner.invoke(patch.apply_cmd, ['123', '--no-deps']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) mock_download.assert_called_once_with(rsp['mbox'], {'series': None}) mock_git_am.assert_called_once_with(mock_download.return_value, ()) def test_apply_with_args(self, mock_git_am, mock_download, mock_detail): """Validate passthrough of arbitrary arguments to git-am.""" rsp = {'mbox': 'hello, world'} mock_detail.return_value = rsp mock_download.return_value = object() runner = CLIRunner() result = runner.invoke(patch.apply_cmd, ['123', '-3']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) mock_download.assert_called_once_with(rsp['mbox'], {'series': '*'}) mock_git_am.assert_called_once_with(mock_download.return_value, ('-3',)) @mock.patch('git_pw.api.detail') @mock.patch('git_pw.api.download') @mock.patch('git_pw.patch.LOG') class DownloadTestCase(unittest.TestCase): def test_download(self, mock_log, mock_download, mock_detail): """Validate standard behavior.""" rsp = {'mbox': 'hello, world', 'diff': 'test'} mock_detail.return_value = rsp mock_download.return_value = '/tmp/abc123.patch' runner = CLIRunner() result = runner.invoke(patch.download_cmd, ['123']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) mock_download.assert_called_once_with(rsp['mbox'], output=None) assert mock_log.info.called def test_download_diff(self, mock_log, mock_download, mock_detail): """Validate behavior if downloading a diff instead of mbox.""" rsp = {'mbox': 'hello, world', 'diff': 'test'} mock_detail.return_value = rsp runner = CLIRunner() result = runner.invoke(patch.download_cmd, ['123', '--diff']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) mock_download.assert_called_once_with( rsp['mbox'].replace('mbox', 'raw'), output=None, ) assert mock_log.info.called def test_download_to_file(self, mock_log, mock_download, mock_detail): """Validate behavior if downloading to a specific file.""" rsp = {'mbox': 'hello, world', 'diff': 'test'} mock_detail.return_value = rsp runner = CLIRunner() result = runner.invoke(patch.download_cmd, ['123', 'test.patch']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) mock_download.assert_called_once_with(rsp['mbox'], output=mock.ANY) assert isinstance( mock_download.call_args[1]['output'], click_utils.LazyFile, ) assert mock_log.info.called def test_download_diff_to_file(self, mock_log, mock_download, mock_detail): """Validate behavior if downloading a diff to a specific file.""" rsp = {'mbox': 'hello, world', 'diff': b'test'} mock_detail.return_value = rsp runner = CLIRunner() with runner.isolated_filesystem(): result = runner.invoke(patch.download_cmd, ['123', '--diff', 'test.diff']) assert result.exit_code == 0, result with open('test.diff') as output: assert [rsp['diff'].decode()] == output.readlines() mock_detail.assert_called_once_with('patches', 123) mock_download.assert_not_called() assert mock_log.info.called class ShowTestCase(unittest.TestCase): @staticmethod def _get_patch(**kwargs): rsp = { 'id': 123, 'msgid': 'hello@example.com', 'date': '2017-01-01 00:00:00', 'name': 'Sample patch', 'submitter': { 'name': 'foo', 'email': 'foo@bar.com', }, 'state': 'new', 'archived': False, 'project': { 'name': 'bar', }, 'delegate': { 'username': 'johndoe', }, 'commit_ref': None, 'series': [ { 'id': 321, 'name': 'Sample series', } ], } rsp.update(**kwargs) return rsp @mock.patch('git_pw.api.detail') def test_show(self, mock_detail): """Validate standard behavior.""" rsp = self._get_patch() mock_detail.return_value = rsp runner = CLIRunner() result = runner.invoke(patch.show_cmd, ['123']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('patches', 123) @mock.patch('git_pw.api.update') @mock.patch.object(patch, '_show_patch') @mock.patch.object(patch, '_get_states') class UpdateTestCase(unittest.TestCase): @staticmethod def _get_person(**kwargs): rsp = { 'id': 1, 'name': 'John Doe', 'email': 'john@example.com', } rsp.update(**kwargs) return rsp def test_update_no_arguments(self, mock_states, mock_show, mock_update): """Validate behavior with no arguments.""" runner = CLIRunner() result = runner.invoke(patch.update_cmd, ['123']) assert result.exit_code == 0, result mock_update.assert_called_once_with('patches', 123, []) mock_show.assert_called_once_with(mock_update.return_value, None) def test_update_with_arguments(self, mock_states, mock_show, mock_update): """Validate behavior with all arguments except delegate.""" mock_states.return_value = ['new'] runner = CLIRunner() result = runner.invoke(patch.update_cmd, [ '123', '--commit-ref', '3ed8fb12', '--state', 'new', '--archived', '1', '--format', 'table']) assert result.exit_code == 0, result mock_update.assert_called_once_with('patches', 123, [ ('commit_ref', '3ed8fb12'), ('state', 'new'), ('archived', True)]) mock_show.assert_called_once_with(mock_update.return_value, 'table') def test_update_with_invalid_state( self, mock_states, mock_show, mock_update): """Validate behavior with invalid state.""" mock_states.return_value = ['foo'] runner = CLIRunner() result = runner.invoke(patch.update_cmd, [ '123', '--state', 'bar']) assert result.exit_code == 2, result if version.parse(click.__version__) >= version.Version('7.1'): assert "Invalid value for '--state'" in result.output, result else: assert 'Invalid value for "--state"' in result.output, result @mock.patch('git_pw.api.index') def test_update_with_delegate( self, mock_index, mock_states, mock_show, mock_update): """Validate behavior with delegate argument.""" mock_index.return_value = [self._get_person()] runner = CLIRunner() result = runner.invoke(patch.update_cmd, [ '123', '--delegate', 'doe@example.com']) assert result.exit_code == 0, result mock_index.assert_called_once_with('users', [('q', 'doe@example.com')]) mock_update.assert_called_once_with('patches', 123, [ ('delegate', mock_index.return_value[0]['id'])]) mock_show.assert_called_once_with(mock_update.return_value, None) @mock.patch('git_pw.api.version', return_value=(1, 0)) @mock.patch('git_pw.api.index') @mock.patch('git_pw.utils.echo_via_pager') class ListTestCase(unittest.TestCase): @staticmethod def _get_patch(**kwargs): return ShowTestCase._get_patch(**kwargs) @staticmethod def _get_person(**kwargs): rsp = { 'id': 1, 'name': 'John Doe', 'email': 'john@example.com', } rsp.update(**kwargs) return rsp @staticmethod def _get_users(**kwargs): rsp = { 'id': 1, 'username': 'john.doe', 'email': 'john@example.com', } rsp.update(**kwargs) return rsp def test_list(self, mock_echo, mock_index, mock_version): """Validate standard behavior.""" rsp = [self._get_patch()] mock_index.return_value = rsp runner = CLIRunner() result = runner.invoke(patch.list_cmd, []) assert result.exit_code == 0, result mock_index.assert_called_once_with('patches', [ ('state', 'under-review'), ('state', 'new'), ('q', None), ('archived', 'false'), ('page', None), ('per_page', None), ('order', '-date')]) def test_list_with_formatting(self, mock_echo, mock_index, mock_version): rsp = [self._get_patch()] mock_index.return_value = rsp runner = CLIRunner() result = runner.invoke(patch.list_cmd, [ '--format', 'simple', '--column', 'ID', '--column', 'Name']) assert result.exit_code == 0, result mock_echo.assert_called_once_with(mock.ANY, ('ID', 'Name'), fmt='simple') def test_list_with_filters(self, mock_echo, mock_index, mock_version): """Validate behavior with filters applied. Apply all filters, including those for pagination. """ submitter_rsp = [self._get_person()] delegate_rsp = [self._get_person()] patch_rsp = [self._get_patch()] mock_index.side_effect = [submitter_rsp, delegate_rsp, patch_rsp] runner = CLIRunner() result = runner.invoke(patch.list_cmd, [ '--state', 'new', '--submitter', 'john@example.com', '--submitter', '2', '--delegate', 'doe@example.com', '--delegate', '2', '--hash', 'foo', '--archived', '--limit', 1, '--page', 1, '--sort', '-name', 'test']) assert result.exit_code == 0, result calls = [ mock.call('people', [('q', 'john@example.com')]), mock.call('users', [('q', 'doe@example.com')]), mock.call('patches', [ ('state', 'new'), ('submitter', 1), ('submitter', '2'), ('delegate', 1), ('delegate', '2'), ('hash', 'foo'), ('q', 'test'), ('archived', 'true'), ('page', 1), ('per_page', 1), ('order', '-name')])] mock_index.assert_has_calls(calls) @mock.patch('git_pw.api.LOG') def test_list_with_wildcard_filters(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with a "wildcard" filter. Patchwork API v1.0 did not support multiple filters correctly. Ensure the user is warned as necessary if a filter has multiple matches. """ people_rsp = [self._get_person(), self._get_person()] patch_rsp = [self._get_patch()] mock_index.side_effect = [people_rsp, patch_rsp] runner = CLIRunner() runner.invoke(patch.list_cmd, ['--submitter', 'john@example.com']) assert mock_log.warning.called @mock.patch('git_pw.api.LOG') def test_list_with_multiple_filters(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with use of multiple filters. Patchwork API v1.0 did not support multiple filters correctly. Ensure the user is warned as necessary if they specify multiple filters. """ people_rsp = [self._get_person()] user_rsp = [self._get_users()] patch_rsp = [self._get_patch()] mock_index.side_effect = [people_rsp, people_rsp, user_rsp, user_rsp, patch_rsp] runner = CLIRunner() result = runner.invoke(patch.list_cmd, [ '--submitter', 'John Doe', '--submitter', 'Jimmy Foo', '--delegate', 'foo', '--delegate', 'bar']) assert result.exit_code == 0, result assert mock_log.warning.called @mock.patch('git_pw.api.LOG') def test_list_api_v1_1(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with API v1.1.""" mock_version.return_value = (1, 1) people_rsp = [self._get_person()] user_rsp = [self._get_users()] patch_rsp = [self._get_patch()] mock_index.side_effect = [people_rsp, user_rsp, patch_rsp] runner = CLIRunner() result = runner.invoke(patch.list_cmd, [ '--submitter', 'jimmy@example.com', '--submitter', 'John Doe', '--delegate', 'foo', '--delegate', 'john@example.com']) assert result.exit_code == 0, result # We should have only made a single call to each of '/users' and # '/people' (for the user specified by an email address and the # submitter specified by name, respectively) since API v1.1 supports # filtering of users with username and people with emails natively calls = [ mock.call('people', [('q', 'John Doe')]), mock.call('users', [('q', 'john@example.com')]), mock.call('patches', [ ('state', 'under-review'), ('state', 'new'), ('submitter', 'jimmy@example.com'), ('submitter', 1), ('delegate', 'foo'), ('delegate', 1), ('q', None), ('archived', 'false'), ('page', None), ('per_page', None), ('order', '-date')])] mock_index.assert_has_calls(calls) # We shouldn't see a warning about multiple versions either assert not mock_log.warning.called git-pw-2.0.0/tests/test_series.py000066400000000000000000000212171370141035400167660ustar00rootroot00000000000000import unittest from click.testing import CliRunner as CLIRunner from click import utils as click_utils import mock from git_pw import series @mock.patch('git_pw.api.detail') @mock.patch('git_pw.api.download') @mock.patch('git_pw.utils.git_am') class ApplyTestCase(unittest.TestCase): def test_apply_without_args(self, mock_git_am, mock_download, mock_detail): """Validate calling with no arguments.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_detail.return_value = rsp mock_download.return_value = 'test.patch' runner = CLIRunner() result = runner.invoke(series.apply_cmd, ['123']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('series', 123) mock_download.assert_called_once_with(rsp['mbox']) mock_git_am.assert_called_once_with(mock_download.return_value, ()) def test_apply_with_args(self, mock_git_am, mock_download, mock_detail): """Validate passthrough of arbitrary arguments to git-am.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_detail.return_value = rsp mock_download.return_value = 'test.patch' runner = CLIRunner() result = runner.invoke(series.apply_cmd, ['123', '-3']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('series', 123) mock_download.assert_called_once_with(rsp['mbox']) mock_git_am.assert_called_once_with(mock_download.return_value, ('-3',)) @mock.patch('git_pw.api.detail') @mock.patch('git_pw.api.download') class DownloadTestCase(unittest.TestCase): def test_download(self, mock_download, mock_detail): """Validate standard behavior.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_detail.return_value = rsp runner = CLIRunner() result = runner.invoke(series.download_cmd, ['123']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('series', 123) mock_download.assert_called_once_with(rsp['mbox'], output=None) def test_download_to_file(self, mock_download, mock_detail): """Validate downloading to a file.""" rsp = {'mbox': 'http://example.com/api/patches/123/mbox/'} mock_detail.return_value = rsp runner = CLIRunner() result = runner.invoke(series.download_cmd, ['123', 'test.patch']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('series', 123) mock_download.assert_called_once_with(rsp['mbox'], output=mock.ANY) assert isinstance( mock_download.call_args[1]['output'], click_utils.LazyFile, ) class ShowTestCase(unittest.TestCase): @staticmethod def _get_series(**kwargs): rsp = { 'id': 123, 'date': '2017-01-01 00:00:00', 'name': 'Sample series', 'submitter': { 'name': 'foo', 'email': 'foo@bar.com', }, 'project': { 'name': 'bar', }, 'version': '1', 'total': 2, 'received_total': 2, 'received_all': True, 'cover_letter': None, 'patches': [], } rsp.update(**kwargs) return rsp @mock.patch('git_pw.api.detail') def test_show(self, mock_detail): """Validate standard behavior.""" rsp = self._get_series() mock_detail.return_value = rsp runner = CLIRunner() result = runner.invoke(series.show_cmd, ['123']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('series', 123) @mock.patch('git_pw.api.version', return_value=(1, 0)) @mock.patch('git_pw.api.index') @mock.patch('git_pw.utils.echo_via_pager') class ListTestCase(unittest.TestCase): @staticmethod def _get_series(**kwargs): return ShowTestCase._get_series(**kwargs) @staticmethod def _get_people(**kwargs): rsp = { 'id': 1, 'name': 'John Doe', 'email': 'john@example.com', } rsp.update(**kwargs) return rsp def test_list(self, mock_echo, mock_index, mock_version): """Validate standard behavior.""" rsp = [self._get_series()] mock_index.return_value = rsp runner = CLIRunner() result = runner.invoke(series.list_cmd, []) assert result.exit_code == 0, result mock_index.assert_called_once_with('series', [ ('q', None), ('page', None), ('per_page', None), ('order', '-date')]) def test_list_with_formatting(self, mock_echo, mock_index, mock_version): """Validate behavior with formatting applied.""" rsp = [self._get_series()] mock_index.return_value = rsp runner = CLIRunner() result = runner.invoke(series.list_cmd, [ '--format', 'simple', '--column', 'ID', '--column', 'Name']) assert result.exit_code == 0, result mock_echo.assert_called_once_with(mock.ANY, ('ID', 'Name'), fmt='simple') def test_list_with_filters(self, mock_echo, mock_index, mock_version): """Validate behavior with filters applied. Apply all filters, including those for pagination. """ people_rsp = [self._get_people()] series_rsp = [self._get_series()] mock_index.side_effect = [people_rsp, series_rsp] runner = CLIRunner() result = runner.invoke(series.list_cmd, [ '--submitter', 'john@example.com', '--submitter', '2', '--limit', 1, '--page', 1, '--sort', '-name', 'test']) assert result.exit_code == 0, result calls = [ mock.call('people', [('q', 'john@example.com')]), mock.call('series', [ ('submitter', 1), ('submitter', '2'), ('q', 'test'), ('page', 1), ('per_page', 1), ('order', '-name')])] mock_index.assert_has_calls(calls) @mock.patch('git_pw.api.LOG') def test_list_with_wildcard_filters(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with a "wildcard" filter. Patchwork API v1.0 did not support multiple filters correctly. Ensure the user is warned as necessary if a filter has multiple matches. """ people_rsp = [self._get_people(), self._get_people()] series_rsp = [self._get_series()] mock_index.side_effect = [people_rsp, series_rsp] runner = CLIRunner() runner.invoke(series.list_cmd, ['--submitter', 'john@example.com']) assert mock_log.warning.called @mock.patch('git_pw.api.LOG') def test_list_with_multiple_filters(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with use of multiple filters. Patchwork API v1.0 did not support multiple filters correctly. Ensure the user is warned as necessary if they specify multiple filters. """ people_rsp = [self._get_people()] series_rsp = [self._get_series()] mock_index.side_effect = [people_rsp, people_rsp, series_rsp] runner = CLIRunner() result = runner.invoke(series.list_cmd, [ '--submitter', 'john@example.com', '--submitter', 'jimmy@example.com']) assert result.exit_code == 0, result assert mock_log.warning.called @mock.patch('git_pw.api.LOG') def test_list_api_v1_1(self, mock_log, mock_echo, mock_index, mock_version): """Validate behavior with API v1.1.""" mock_version.return_value = (1, 1) people_rsp = [self._get_people()] series_rsp = [self._get_series()] mock_index.side_effect = [people_rsp, series_rsp] runner = CLIRunner() result = runner.invoke(series.list_cmd, [ '--submitter', 'jimmy@example.com', '--submitter', 'John Doe']) assert result.exit_code == 0, result # We should have only made a single call to '/people' since API v1.1 # supports filtering with emails natively calls = [ mock.call('people', [('q', 'John Doe')]), mock.call('series', [ ('submitter', 'jimmy@example.com'), ('submitter', 1), ('q', None), ('page', None), ('per_page', None), ('order', '-date')])] mock_index.assert_has_calls(calls) # We shouldn't see a warning about multiple versions either assert not mock_log.warning.called git-pw-2.0.0/tests/test_utils.py000066400000000000000000000113001370141035400166240ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for ``git_pw/utils.py``.""" import subprocess import textwrap import os import mock from git_pw import utils @mock.patch.object(utils.subprocess, 'check_output', return_value=b' bar ') def test_git_config(mock_subprocess): value = utils.git_config('foo') assert value == 'bar' mock_subprocess.assert_called_once_with(['git', 'config', 'foo']) @mock.patch.object(utils.subprocess, 'check_output', return_value=b'\xf0\x9f\xa4\xb7') def test_git_config_unicode(mock_subprocess): value = utils.git_config('foo') assert value == u'\U0001f937' mock_subprocess.assert_called_once_with(['git', 'config', 'foo']) @mock.patch.object(utils.subprocess, 'check_output', side_effect=subprocess.CalledProcessError(1, 'xyz', '123')) def test_git_config_error(mock_subprocess): value = utils.git_config('foo') assert value == '' @mock.patch.object(utils, 'git_config', return_value='bar') @mock.patch.object(utils, '_tabulate') @mock.patch.object(utils, '_echo_via_pager') @mock.patch.dict(os.environ, {'GIT_PAGER': 'foo', 'PAGER': 'baz'}) def test_echo_via_pager_env_GIT_PAGER(mock_inner, mock_tabulate, mock_config): utils.echo_via_pager('test', ('foo',), None) mock_config.assert_not_called() mock_tabulate.assert_called_once_with('test', ('foo',), None) mock_inner.assert_called_once_with('foo', mock_tabulate.return_value) @mock.patch.object(utils, 'git_config', return_value='bar') @mock.patch.object(utils, '_tabulate') @mock.patch.object(utils, '_echo_via_pager') @mock.patch.dict(os.environ, {'PAGER': 'baz'}) def test_echo_via_pager_config(mock_inner, mock_tabulate, mock_config): utils.echo_via_pager('test', ('foo',), None) mock_config.assert_called_once_with('core.parser') mock_tabulate.assert_called_once_with('test', ('foo',), None) mock_inner.assert_called_once_with('bar', mock_tabulate.return_value) @mock.patch.object(utils, 'git_config', return_value=None) @mock.patch.object(utils, '_tabulate') @mock.patch.object(utils, '_echo_via_pager') @mock.patch.dict(os.environ, {'PAGER': 'baz'}) def test_echo_via_pager_env_PAGER(mock_inner, mock_tabulate, mock_config): utils.echo_via_pager('test', ('foo',), None) mock_config.assert_called_once_with('core.parser') mock_tabulate.assert_called_once_with('test', ('foo',), None) mock_inner.assert_called_once_with('baz', mock_tabulate.return_value) @mock.patch.object(utils, 'git_config', return_value=None) @mock.patch.object(utils, '_tabulate') @mock.patch.object(utils, '_echo_via_pager') def test_echo_via_pager_env_default(mock_inner, mock_tabulate, mock_config): utils.echo_via_pager('test', ('foo',), None) mock_config.assert_called_once_with('core.parser') mock_tabulate.assert_called_once_with('test', ('foo',), None) mock_inner.assert_called_once_with('less', mock_tabulate.return_value) def _test_tabulate(fmt): output = [(b'foo', 'bar', u'baz', '😀', None, 1)] headers = ('col1', 'colb', 'colIII', 'colX', 'colY', 'colZ') result = utils._tabulate(output, headers, fmt) return output, headers, result @mock.patch.object(utils, 'tabulate') def test_tabulate_table(mock_tabulate): output, headers, result = _test_tabulate('table') mock_tabulate.assert_called_once_with(output, headers, tablefmt='psql') assert result == mock_tabulate.return_value @mock.patch.object(utils, 'tabulate') def test_tabulate_simple(mock_tabulate): output, headers, result = _test_tabulate('simple') mock_tabulate.assert_called_once_with(output, headers, tablefmt='simple') assert result == mock_tabulate.return_value @mock.patch.object(utils, 'tabulate') def test_tabulate_csv(mock_tabulate): output, headers, result = _test_tabulate('csv') mock_tabulate.assert_not_called() assert result == textwrap.dedent("""\ "col1","colb","colIII","colX","colY","colZ" "foo","bar","baz","😀","","1" """) @mock.patch.object(utils, 'git_config', return_value='simple') @mock.patch.object(utils, 'tabulate') def test_tabulate_git_config(mock_tabulate, mock_git_config): output, headers, result = _test_tabulate(None) mock_git_config.assert_called_once_with('pw.format') mock_tabulate.assert_called_once_with(output, headers, tablefmt='simple') assert result == mock_tabulate.return_value @mock.patch.object(utils, 'git_config', return_value='') @mock.patch.object(utils, 'tabulate') def test_tabulate_default(mock_tabulate, mock_git_config): output, headers, result = _test_tabulate(None) mock_git_config.assert_called_once_with('pw.format') mock_tabulate.assert_called_once_with(output, headers, tablefmt='psql') assert result == mock_tabulate.return_value git-pw-2.0.0/tox.ini000066400000000000000000000017061370141035400142350ustar00rootroot00000000000000[tox] minversion = 3.1 envlist = pep8,mypy,clean,py{27,35,36,37,38},report ignore_basepython_conflict = true [testenv] basepython = python3 deps = -r{toxinidir}/test-requirements.txt commands = pytest -Wall --cov=git_pw --cov-report term-missing {posargs} [testenv:pep8] skip_install = true deps = flake8 commands = flake8 {posargs:git_pw tests docs} [testenv:mypy] deps= mypy commands= mypy {posargs:--ignore-missing-imports --follow-imports=skip} git_pw [testenv:report] skip_install = true deps = coverage commands = coverage report coverage html [testenv:clean] envdir = {toxworkdir}/report skip_install = true deps = {[testenv:report]deps} commands = coverage erase [testenv:docs] deps = -r{toxinidir}/docs/requirements.txt commands = sphinx-build {posargs:-E -W} docs docs/_build/html [testenv:man] deps = click-man~=0.4.0 commands = click-man git-pw [flake8] show-source = true [travis] python = 3.7: py37, pep8, mypy