pax_global_header00006660000000000000000000000064147455013540014522gustar00rootroot0000000000000052 comment=58641b31c7e6332ee7519f7b9feeac57886fd0c8 git-pw-2.7.1/000077500000000000000000000000001474550135400127405ustar00rootroot00000000000000git-pw-2.7.1/.git-blame-ignore-revs000066400000000000000000000000511474550135400170340ustar00rootroot00000000000000defc7e6ea85485917af55d1af89328ef3c4a81e0 git-pw-2.7.1/.github/000077500000000000000000000000001474550135400143005ustar00rootroot00000000000000git-pw-2.7.1/.github/workflows/000077500000000000000000000000001474550135400163355ustar00rootroot00000000000000git-pw-2.7.1/.github/workflows/ci.yaml000066400000000000000000000066571474550135400176320ustar00rootroot00000000000000--- name: CI on: push: pull_request: schedule: - cron: '30 0 * * 1' jobs: lint: name: Run linters runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install dependencies run: python -m pip install tox - name: Run tox run: tox -e pep8,mypy test: name: Run unit tests runs-on: ubuntu-latest strategy: matrix: python: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Install dependencies run: python -m pip install tox - name: Run unit tests (via tox) # Run tox using the version of Python in `PATH` run: tox -e py docs: name: Build docs runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install dependencies run: python -m pip install tox - name: Build docs (via tox) run: tox -e docs - name: Archive build results uses: actions/upload-artifact@v4 with: name: html-docs-build path: docs/_build/html retention-days: 7 release: name: Upload release artifacts runs-on: ubuntu-latest needs: test if: github.event_name == 'push' steps: - name: Checkout source code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install dependencies run: python -m pip install build - name: Build a binary wheel and a source tarball run: python -m build --sdist --wheel --outdir dist/ . - name: Publish distribution to Test PyPI if: ${{ github.ref_type != 'tag' }} uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository-url: https://test.pypi.org/legacy/ - name: Publish distribution to PyPI if: ${{ github.ref_type == 'tag' }} uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} - name: Create release on GitHub id: create_release if: ${{ github.ref_type == 'tag' }} uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref_name }} release_name: ${{ github.ref_name }} draft: false prerelease: false - name: Add sdist to release id: upload-release-asset if: ${{ github.ref_type == 'tag' }} uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./dist/git-pw-${{ github.ref_name }}.tar.gz asset_name: git-pw-${{ github.ref_name }}.tar.gz asset_content_type: application/gzip git-pw-2.7.1/.gitignore000066400000000000000000000013241474550135400147300ustar00rootroot00000000000000# 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.7.1/.mailmap000066400000000000000000000000621474550135400143570ustar00rootroot00000000000000 git-pw-2.7.1/.pre-commit-config.yaml000066400000000000000000000010171474550135400172200ustar00rootroot00000000000000--- default_language_version: # force all unspecified python hooks to run python3 python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/pycqa/flake8 rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/psf/black rev: 24.10.0 hooks: - id: black git-pw-2.7.1/.readthedocs.yaml000066400000000000000000000003351474550135400161700ustar00rootroot00000000000000--- version: 2 python: install: - requirements: docs/requirements.txt - method: pip path: . build: os: "ubuntu-22.04" tools: python: "3.11" jobs: post_checkout: - git fetch --unshallow git-pw-2.7.1/LICENSE000066400000000000000000000021071474550135400137450ustar00rootroot00000000000000The 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.7.1/README.rst000066400000000000000000000103631474550135400144320ustar00rootroot00000000000000====== git-pw ====== .. NOTE: If editing this, be sure to update the line numbers in 'doc/index' .. 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://github.com/getpatchwork/git-pw/actions/workflows/ci.yaml/badge.svg :target: https://github.com/getpatchwork/git-pw/actions/workflows/ci.yaml :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 python3-requests python3-click python3-pbr \ python3-arrow python3-tabulate python3-yaml On Ubuntu, run: .. code-block:: bash $ sudo apt-get install python3-requests python3-click python3-pbr \ python3-arrow python3-tabulate python3-yaml Once dependencies are installed, clone this repo and install with ``pip``: .. code-block:: bash $ git clone https://github.com/getpatchwork/git-pw $ cd git-pw $ pip install --user . 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.3 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 3.2.x, you should use API version 1.3. .. __: https://patchwork.readthedocs.io/en/stable-3.2/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/ For read-write access, you also need 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. If only read-only access is desired, credentials can be omitted. 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.7.1/docs/000077500000000000000000000000001474550135400136705ustar00rootroot00000000000000git-pw-2.7.1/docs/conf.py000066400000000000000000000033151474550135400151710ustar00rootroot00000000000000# -*- 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 = 'git-pw' copyright = '2018, Stephen Finucane' author = '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.7.1/docs/contents.rst000066400000000000000000000001041474550135400162520ustar00rootroot00000000000000Contents ======== .. toctree:: index usage release-notes git-pw-2.7.1/docs/index.rst000066400000000000000000000003671474550135400155370ustar00rootroot00000000000000git-pw (Patchwork subcommand for Git) ===================================== Overview -------- .. include:: ../README.rst :start-line: 18 :end-line: -7 Usage ----- See :doc:`usage`. Release Notes ------------- See :doc:`release-notes`. git-pw-2.7.1/docs/release-notes.rst000066400000000000000000000000601474550135400171640ustar00rootroot00000000000000Release Notes ============= .. release-notes:: git-pw-2.7.1/docs/requirements.txt000066400000000000000000000001011474550135400171440ustar00rootroot00000000000000-r ../requirements.txt sphinx sphinx-click reno sphinx-rtd-theme git-pw-2.7.1/docs/usage.rst000066400000000000000000000001131474550135400155210ustar00rootroot00000000000000Usage ===== .. click:: git_pw.shell:cli :prog: git-pw :show-nested: git-pw-2.7.1/git_pw/000077500000000000000000000000001474550135400142315ustar00rootroot00000000000000git-pw-2.7.1/git_pw/__init__.py000066400000000000000000000002611474550135400163410ustar00rootroot00000000000000""" git-pw -- A tool for integrating Git with Patchwork, the web-based patch tracking system. """ import importlib.metadata __version__ = importlib.metadata.version('git-pw') git-pw-2.7.1/git_pw/api.py000066400000000000000000000336301474550135400153610ustar00rootroot00000000000000""" Simple wrappers around request methods. """ from functools import update_wrapper import logging import os.path import re import sys import tempfile import typing as ty import click import requests import git_pw from git_pw import config CONF = config.CONF LOG = logging.getLogger(__name__) Filters = ty.List[ty.Tuple[str, str]] class HTTPTokenAuth(requests.auth.AuthBase): """Attaches HTTP Token Authentication to the given Request object.""" def __init__(self, token: str): self.token = token def __call__( self, r: requests.PreparedRequest, ) -> requests.PreparedRequest: r.headers['Authorization'] = self._token_auth_str(self.token) return r @staticmethod def _token_auth_str(token: str) -> str: """Return a Token auth string.""" return 'Token {}'.format(token.strip()) def _get_auth(optional: bool = False) -> ty.Optional[requests.auth.AuthBase]: if CONF.token: return HTTPTokenAuth(CONF.token) elif CONF.username and CONF.password: return requests.auth.HTTPBasicAuth(CONF.username, CONF.password) elif not optional: LOG.error('Authentication information missing') LOG.error( 'You must configure authentication via git-config or via ' '--token or --username, --password' ) sys.exit(1) return None def _get_headers() -> ty.Dict[str, str]: return { 'User-Agent': 'git-pw ({})'.format(git_pw.__version__), } def _get_server() -> 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() -> 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: str, exc: requests.exceptions.RequestException, ) -> None: 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' ) elif exc.response.status_code == 404: LOG.error('Resource not found') else: LOG.error(exc.response.text) 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 exc else: sys.exit(1) def _get( url: str, params: ty.Optional[Filters] = None, stream: bool = False, ) -> 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(optional=True), 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: str, data: ty.List[ty.Tuple[str, ty.Any]], ) -> 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: str, data: ty.List[ty.Tuple[str, ty.Any]], ) -> 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: 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() -> ty.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: str, params: ty.Optional[Filters] = None, output: ty.Optional[ty.Optional[str]] = None, ) -> ty.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 output is a directory then the file name will be according to the patch subject and will be downloaded into the output directory. 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 = output output_file = sys.stdout.buffer else: if output: output_path = output if os.path.isdir(output): output_path = os.path.join(output, header.group(1)) else: output_path = os.path.join( tempfile.mkdtemp(prefix='git-pw'), header.group(1), ) LOG.debug('Saving to %s', output_path) output_file = open(output_path, 'wb') try: # we use iter_content because patches can be binary for block in rsp.iter_content(1024): output_file.write(block) finally: output_file.close() return output_path def index(resource_type: str, params: ty.Optional[Filters] = None) -> 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: str, resource_id: ty.Union[str, int], params: ty.Optional[Filters] = None, ) -> ty.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: str, data: ty.List[ty.Tuple[str, ty.Any]], ) -> 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: str, resource_id: ty.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: str, resource_id: ty.Union[str, int], data: ty.List[ty.Tuple[str, ty.Any]], ) -> 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: ty.Tuple[int, int], msg: str, ) -> ty.Callable[[ty.Any], ty.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: ty.Callable) -> ty.Callable: @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://tinyurl.com/2p8swbpn 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: str, filter_name: str, filter_value: str, ) -> ty.List[ty.Tuple[str, str]]: """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://tinyurl.com/2p8swbpn ' 'for more information.' ) LOG.warning(msg, filter_name, filter_value, filter_name) return [(filter_name, item['id']) for item in items] git-pw-2.7.1/git_pw/bundle.py000066400000000000000000000226711474550135400160640ustar00rootroot00000000000000""" Bundle subcommands. """ import logging import sys import typing as ty 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: str) -> dict: """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: str, args: ty.Tuple[str]) -> None: """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']) if mbox: utils.git_am(mbox, args) @click.command(name='download') @click.argument('bundle_id') @click.argument( 'output', type=click.Path(file_okay=True, writable=True, readable=True), required=False, ) def download_cmd(bundle_id: str, output: ty.Optional[str]) -> None: """Download bundle in mbox format. Download a bundle but do not apply it. ``OUTPUT`` is optional and can be an output full file path or a directory 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: dict, fmt: str) -> None: 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: str, bundle_id: str) -> None: """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: str, patch_ids: ty.Tuple[int], public: bool, fmt: str, ) -> None: """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: str, name: str, patch_ids: ty.List[int], public: bool, fmt: str, ) -> None: """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: str, fmt: str) -> None: """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: str, patch_ids: ty.Tuple[int], fmt: str) -> None: """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: str, patch_ids: ty.Tuple[int], fmt: str, ) -> None: """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.7.1/git_pw/config.py000066400000000000000000000023301474550135400160460ustar00rootroot00000000000000""" Configuration loader using 'git-config'. """ import logging import typing as ty from git_pw import utils LOG = logging.getLogger(__name__) def parse_boolean(value: str) -> bool: """Parse a boolean config value. Based on https://git-scm.com/docs/git-config#_values """ if value in ('yes', 'on', 'true', '1', ''): return True if value in ('no', 'off', 'false', '0'): return False LOG.error("'{}' is not a valid boolean value".format(value)) return False class Config(object): def __init__(self) -> None: self._git_config: ty.Dict[str, str] = {} def __getattribute__(self, name: str) -> str: # 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.7.1/git_pw/logger.py000066400000000000000000000005301474550135400160600ustar00rootroot00000000000000""" Configure application logging. """ import logging def configure_verbosity(debug: 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.7.1/git_pw/patch.py000066400000000000000000000261311474550135400157050ustar00rootroot00000000000000""" Patch subcommands. """ import logging import os 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', ) def _get_apply_patch_deps() -> bool: if CONF.applyPatchDeps is not None: return config.parse_boolean(CONF.applyPatchDeps) return True @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=_get_apply_patch_deps(), help=( "When applying the patch, include series dependencies if available. " "Dependencies are retrieved from the most recent series by default. " "Defaults to the value of 'git config pw applyPatchDeps' else 'true'." ), ) @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.Path(file_okay=True, writable=True, readable=True), 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 file path or a directory 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 and not os.path.isdir(output): if output == '-': output_path = 0 # stdout fd else: output_path = output path = output with open(output_path, 'w') as output_file: output_file.write(utils.ensure_str(patch['diff'])) 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.date_options() @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, since, before, 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), ] ) if since: params.append(('since', since.isoformat())) if before: params.append(('before', before.isoformat())) 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.7.1/git_pw/series.py000066400000000000000000000141671474550135400161060ustar00rootroot00000000000000""" Series subcommands. """ import logging import arrow import click from git_pw import api from git_pw import utils import os.path import sys 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.Path(file_okay=True, writable=True, readable=True), required=False, ) @click.option( '--separate', 'fmt', flag_value='separate', help='Download each series patch to a separate file', ) @click.option( '--combined', 'fmt', flag_value='combined', default=True, help='Download all series patches to one file', ) def download_cmd(series_id, output, fmt): """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) if fmt == 'separate': if output and not os.path.isdir(output): LOG.error( 'When downloading into separate files, OUTPUT can only be a ' 'directoy' ) sys.exit(1) for patch in series.get('patches'): path = api.download(patch['mbox'], output=output) if path: LOG.info( 'Downloaded patch %s from series %s to %s', patch.get('id'), series.get('id'), path, ) return 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.date_options() @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, since, before): """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), ] ) if since: params.append(('since', since.isoformat())) if before: params.append(('before', before.isoformat())) 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.7.1/git_pw/shell.py000066400000000000000000000110411474550135400157070ustar00rootroot00000000000000""" 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 to 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.7.1/git_pw/utils.py000066400000000000000000000152541474550135400157520ustar00rootroot00000000000000""" Utility functions. """ import csv import io import logging import os import subprocess import sys import typing as ty import click from tabulate import tabulate import yaml LOG = logging.getLogger(__name__) def ensure_str(s: ty.Any) -> str: 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: str, length: int = 70) -> str: """Trim a string to the given length.""" return (string[: length - 1] + '...') if len(string) > length else string def git_config(value: str) -> str: """Parse config from ``git-config`` cache. Returns: Matching setting for ``key`` if available, else None. """ cmd = ['git', 'config', value] LOG.debug('Fetching git config info for %s', value) LOG.debug('Running: %s', ' '.join(cmd)) try: output = subprocess.check_output(cmd) except subprocess.CalledProcessError: output = b'' return output.decode('utf-8').strip() def git_am(mbox: str, args: ty.Tuple[str, ...]) -> None: """Execute git-am on a given mbox file.""" cmd = ['git', 'am'] if args: cmd.extend(args) else: cmd.append('-3') cmd.append(mbox) LOG.debug('Applying patch at %s', mbox) LOG.debug('Running: %s', ' '.join(cmd)) try: output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as exc: LOG.error('Failed to apply patch:\n%s', exc.output.decode('utf-8')) sys.exit(exc.returncode) else: LOG.info(output.decode('utf-8')) def _tabulate( output: ty.List[ty.Tuple[str, ty.Any]], headers: ty.List[str], fmt: str, ) -> str: 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() elif fmt == 'yaml': data = [ {headers[i].lower(): entry[i] for i in range(len(headers))} for entry in output ] return yaml.dump(data, default_flow_style=False) LOG.error('pw.format must be one of: table, simple, csv, yaml') sys.exit(1) def _echo_via_pager(pager: str, output: str) -> None: 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' proc = subprocess.Popen(pager.split(), stdin=subprocess.PIPE, env=env) try: proc.communicate(input=output.encode('utf-8', 'strict')) except (IOError, KeyboardInterrupt): pass else: if proc.stdin: proc.stdin.close() while True: try: proc.wait() except KeyboardInterrupt: pass else: break def echo_via_pager( output: ty.List[ty.Tuple[str, ty.Any]], headers: ty.List[str], fmt: str, ) -> None: """Echo using git's default pager. Wrap ``click.echo_via_pager``, setting some environment variables in the process 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``). """ out = _tabulate(output, headers, fmt) pager = os.environ.get('GIT_PAGER', None) if pager: _echo_via_pager(pager, out) return pager = git_config('core.pager') if pager: _echo_via_pager(pager, out) return pager = os.environ.get('PAGER', None) if pager: _echo_via_pager(pager, out) return _echo_via_pager('less', out) def echo( output: ty.List[ty.Tuple[str, ty.Any]], headers: ty.List[str], fmt: str, ) -> None: click.echo(_tabulate(output, headers, fmt)) def pagination_options( sort_fields: ty.Tuple[str, ...], default_sort: str, ) -> ty.Callable: """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 date_options() -> ty.Callable: """Shared date bounding options.""" def _date_options(f): f = click.option( '--since', metavar='SINCE', type=click.DateTime(), help='Show only items since a given date in ISO 8601 format', )(f) f = click.option( '--before', metavar='BEFORE', type=click.DateTime(), help='Show only items before a given date in ISO 8601 format', )(f) return f return _date_options def format_options( original_function: ty.Optional[ty.Callable] = None, headers: ty.Optional[ty.Tuple[str, ...]] = None, ) -> ty.Callable: """Shared output format options.""" def _format_options(f): f = click.option( '--format', '-f', 'fmt', default=None, type=click.Choice(['simple', 'table', 'csv', 'yaml']), 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.7.1/man/000077500000000000000000000000001474550135400135135ustar00rootroot00000000000000git-pw-2.7.1/man/git-pw-bundle-add.1000066400000000000000000000007751474550135400170120ustar00rootroot00000000000000.TH "GIT-PW BUNDLE ADD" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-bundle-apply.1000066400000000000000000000005441474550135400174010ustar00rootroot00000000000000.TH "GIT-PW BUNDLE APPLY" "1" "2024-10-23" "2.7.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.7.1/man/git-pw-bundle-create.1000066400000000000000000000011501474550135400175110ustar00rootroot00000000000000.TH "GIT-PW BUNDLE CREATE" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-bundle-delete.1000066400000000000000000000006721474550135400175200ustar00rootroot00000000000000.TH "GIT-PW BUNDLE DELETE" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-bundle-download.1000066400000000000000000000007771474550135400200730ustar00rootroot00000000000000.TH "GIT-PW BUNDLE DOWNLOAD" "1" "2024-10-23" "2.7.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 full file path or a directory or ``-`` to output to ``stdout``. If ``OUTPUT`` is not provided, the output path will be automatically chosen. git-pw-2.7.1/man/git-pw-bundle-list.1000066400000000000000000000014551474550135400172310ustar00rootroot00000000000000.TH "GIT-PW BUNDLE LIST" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-bundle-remove.1000066400000000000000000000010231474550135400175420ustar00rootroot00000000000000.TH "GIT-PW BUNDLE REMOVE" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-bundle-show.1000066400000000000000000000006621474550135400172350ustar00rootroot00000000000000.TH "GIT-PW BUNDLE SHOW" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-bundle-update.1000066400000000000000000000014571474550135400175420ustar00rootroot00000000000000.TH "GIT-PW BUNDLE UPDATE" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-bundle.1000066400000000000000000000034601474550135400162560ustar00rootroot00000000000000.TH "GIT-PW BUNDLE" "1" "2024-10-23" "2.7.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.7.1/man/git-pw-patch-apply.1000066400000000000000000000012531474550135400172250ustar00rootroot00000000000000.TH "GIT-PW PATCH APPLY" "1" "2024-10-23" "2.7.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 series dependencies if available. Dependencies are retrieved from the most recent series by default. Defaults to the value of 'git config pw applyPatchDeps' else 'true'. git-pw-2.7.1/man/git-pw-patch-download.1000066400000000000000000000011531474550135400177060ustar00rootroot00000000000000.TH "GIT-PW PATCH DOWNLOAD" "1" "2024-10-23" "2.7.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 file path or a directory 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.7.1/man/git-pw-patch-list.1000066400000000000000000000024421474550135400170540ustar00rootroot00000000000000.TH "GIT-PW PATCH LIST" "1" "2024-10-23" "2.7.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 with these delegates. Should be an email or username. .TP \fB\-\-hash\fP HASH Show only patches with these hashes. .TP \fB\-\-archived\fP Include patches that are archived. .TP \fB\-\-before\fP BEFORE Show only items before a given date in ISO 8601 format .TP \fB\-\-since\fP SINCE Show only items since a given date in ISO 8601 format .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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-patch-show.1000066400000000000000000000006521474550135400170620ustar00rootroot00000000000000.TH "GIT-PW PATCH SHOW" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-patch-update.1000066400000000000000000000017171474550135400173670ustar00rootroot00000000000000.TH "GIT-PW PATCH UPDATE" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-patch.1000066400000000000000000000023761474550135400161110ustar00rootroot00000000000000.TH "GIT-PW PATCH" "1" "2024-10-23" "2.7.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.7.1/man/git-pw-series-apply.1000066400000000000000000000005441474550135400174220ustar00rootroot00000000000000.TH "GIT-PW SERIES APPLY" "1" "2024-10-23" "2.7.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.7.1/man/git-pw-series-download.1000066400000000000000000000011671474550135400201060ustar00rootroot00000000000000.TH "GIT-PW SERIES DOWNLOAD" "1" "2024-10-23" "2.7.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. .SH OPTIONS .TP \fB\-\-separate\fP Download each series patch to a separate file .TP \fB\-\-combined\fP Download all series patches to one file git-pw-2.7.1/man/git-pw-series-list.1000066400000000000000000000016441474550135400172520ustar00rootroot00000000000000.TH "GIT-PW SERIES LIST" "1" "2024-10-23" "2.7.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\-\-before\fP BEFORE Show only items before a given date in ISO 8601 format .TP \fB\-\-since\fP SINCE Show only items since a given date in ISO 8601 format .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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-series-show.1000066400000000000000000000006621474550135400172560ustar00rootroot00000000000000.TH "GIT-PW SERIES SHOW" "1" "2024-10-23" "2.7.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|yaml] Output format. Defaults to the value of 'git config pw.format' else 'table'. git-pw-2.7.1/man/git-pw-series.1000066400000000000000000000020221474550135400162700ustar00rootroot00000000000000.TH "GIT-PW SERIES" "1" "2024-10-23" "2.7.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.7.1/man/git-pw.1000066400000000000000000000040371474550135400150100ustar00rootroot00000000000000.TH "GIT-PW" "1" "2024-10-23" "2.7.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 to 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.7.1/pyproject.toml000066400000000000000000000002331474550135400156520ustar00rootroot00000000000000[build-system] requires = ["pbr>=5.7.0", "setuptools>=36.6.0"] build-backend = "pbr.build" [tool.black] line-length = 79 skip-string-normalization = true git-pw-2.7.1/releasenotes/000077500000000000000000000000001474550135400154315ustar00rootroot00000000000000git-pw-2.7.1/releasenotes/config.yaml000066400000000000000000000000311474550135400175540ustar00rootroot00000000000000--- default_branch: main git-pw-2.7.1/releasenotes/notes/000077500000000000000000000000001474550135400165615ustar00rootroot00000000000000git-pw-2.7.1/releasenotes/notes/add-before-since-options-c67799ef2ad89c0c.yaml000066400000000000000000000002721474550135400265450ustar00rootroot00000000000000--- features: - | The ``series list`` and ``patch list`` commands now support ``--since`` and ``--before`` filters to list items created since or before a given date-time. git-pw-2.7.1/releasenotes/notes/add-yaml-format-support-5cd6b9028e6d2d8e.yaml000066400000000000000000000003261474550135400264440ustar00rootroot00000000000000--- features: - | The ``-f`` / ``--format`` option used for many ``list`` commands now supports a new option: ``yaml``. This can be useful in environments where limited horizontal space is available. git-pw-2.7.1/releasenotes/notes/api-v1-1-5c804713ef435739.yaml000066400000000000000000000000751474550135400227100ustar00rootroot00000000000000--- features: - | Patchwork API v1.1 is now supported. git-pw-2.7.1/releasenotes/notes/bundle-crud-47aadae6eb7a20ad.yaml000066400000000000000000000007111474550135400243500ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/download-series-to-separate-patches-eae647315dd4d2e1.yaml000066400000000000000000000010741474550135400307070ustar00rootroot00000000000000--- features: - | The ``series download`` command now accepts an optional toggle flag pair, ``--separate`` / ``--combine``, to either download series patches to separate files or to a combined mbox file (default). - | The ``patch download``, ``bundle download``, and ``series download`` commands now accept a directory for the ``OUTPUT`` argument as well as a file. If a directory is provided, the file will use the name provided by Patchwork but will be downloaded to the specified directory rather than the current working directory. git-pw-2.7.1/releasenotes/notes/drop-pypy-support-f670deb05ef527fe.yaml000066400000000000000000000001041474550135400255010ustar00rootroot00000000000000--- upgrade: - | *git-pw* no longer officially supports PyPy. git-pw-2.7.1/releasenotes/notes/drop-python-35-36-support-add-python-310-7d364b9ba71bf5ba.yaml000066400000000000000000000002041474550135400310320ustar00rootroot00000000000000--- upgrade: - | Support for Python 3.5 and 3.6 has been dropped. features: - | Support for Python 3.10 has been added. git-pw-2.7.1/releasenotes/notes/drop-python34-support-5e01360fff605972.yaml000066400000000000000000000001001474550135400256600ustar00rootroot00000000000000--- upgrade: - | Support for Python 3.4 has been dropped. git-pw-2.7.1/releasenotes/notes/enforce-filtering-by-project-59ed29c4b7edc0a5.yaml000066400000000000000000000007111474550135400275030ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/filter-item-list-by-user-id-4f4e7d6dc402093b.yaml000066400000000000000000000003401474550135400270260ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/filter-multiple-matches-197ff839f6b578da.yaml000066400000000000000000000003621474550135400264530ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/handle-error-codes-d72c575fb2d9b452.yaml000066400000000000000000000001151474550135400253410ustar00rootroot00000000000000--- fixes: - | HTTP 404 and HTTP 5xx errors are now handled correctly. git-pw-2.7.1/releasenotes/notes/initial-release-0aad09064615d023.yaml000066400000000000000000000001441474550135400245460ustar00rootroot00000000000000--- prelude: > Initial release of `git-pw` using the new REST API provided in Patchwork 2.0 git-pw-2.7.1/releasenotes/notes/issue-24-60a9fa796f666f35.yaml000066400000000000000000000004401474550135400231760ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/issue-29-884269fdf35f64b2.yaml000066400000000000000000000007611474550135400232070ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/issue-40-add82959d7442cfa.yaml000066400000000000000000000002721474550135400233250ustar00rootroot00000000000000--- features: - | It is no longer necessary to provide credentials when interacting with read-only APIs. You will continue to be prompted for credentials for write access. git-pw-2.7.1/releasenotes/notes/issue-43-c2c166e1fa23fe76.yaml000066400000000000000000000002251474550135400233140ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/issue-44-66b78577e9534f16.yaml000066400000000000000000000002131474550135400230420ustar00rootroot00000000000000--- upgrade: - | Downloaded patches, series and bundles are now saved to a temporary directory instead of the current directory. git-pw-2.7.1/releasenotes/notes/issue-46-50933643cd5c8db0.yaml000066400000000000000000000001061474550135400231540ustar00rootroot00000000000000--- upgrade: - | CSV-formatted output is now quoted by default. git-pw-2.7.1/releasenotes/notes/issue-47-a9ac87642050d289.yaml000066400000000000000000000001541474550135400231070ustar00rootroot00000000000000--- fixes: - | Resolved an issue that prevented viewing patch diffs/mboxes in stdout on Python 3. git-pw-2.7.1/releasenotes/notes/issue-48-694495f722119fed.yaml000066400000000000000000000002131474550135400231170ustar00rootroot00000000000000--- fixes: - | An info-level log is now correctly skipped when downloading patches, bundles or series to ``STDOUT`` on Python 3. git-pw-2.7.1/releasenotes/notes/issue-49-865c4f1657b97fce.yaml000066400000000000000000000001631474550135400232670ustar00rootroot00000000000000--- fixes: - | An issue with the unicode data when using the CSV format on Python 2.7 has been resolved. git-pw-2.7.1/releasenotes/notes/issue-55-bfcf05e02ad305b1.yaml000066400000000000000000000001711474550135400233550ustar00rootroot00000000000000--- features: - | It is now possible to filter patches by hash. For example:: git pw patch list --hash HASH git-pw-2.7.1/releasenotes/notes/passthrough-git-am-arguments-23cd0b292304d648.yaml000066400000000000000000000004641474550135400272440ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/patch-apply-applyPatchDeps-option-9a8fed887af313d5.yaml000066400000000000000000000003451474550135400304320ustar00rootroot00000000000000--- features: - | The ``patch apply`` command will now respect the ``pw.applyPatchDeps`` git config option. This can be a boolean value (one of: ``yes``, ``on``, ``true``, ``1``, ``no``, ``off``, ``false``, ``0``). git-pw-2.7.1/releasenotes/notes/patch-states-b88240569f8474f1.yaml000066400000000000000000000004171474550135400240640ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/python-2-deprecation-c87e311384eab29b.yaml000066400000000000000000000002131474550135400256310ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/python-311-support-de330cb1ff9da396.yaml000066400000000000000000000001141474550135400253540ustar00rootroot00000000000000--- features: - | Python 3.11 is now officially supported and tested. git-pw-2.7.1/releasenotes/notes/python-313-054fa37d696d4a87.yaml000066400000000000000000000002021474550135400234340ustar00rootroot00000000000000--- features: - | Python 3.13 is now officially supported and tested. upgrade: - | Python 3.8 is no longer supported. git-pw-2.7.1/releasenotes/notes/random-fixes-3da473a63c253f2d.yaml000066400000000000000000000005521474550135400242510ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/remove-python-3-2-3-3-support-8987031bed2c0333.yaml000066400000000000000000000001321474550135400266400ustar00rootroot00000000000000--- other: - | Python 3.2 and 3.3 are no longer supported as both versions are EOL. git-pw-2.7.1/releasenotes/notes/require-server-version-93ac0818c293b85e.yaml000066400000000000000000000003061474550135400262520ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/save-patches-before-applying-c5e786156d47d752.yaml000066400000000000000000000004131474550135400272030ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/save-patches-to-file-c667ab7dd0b73ead.yaml000066400000000000000000000013151474550135400260200ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/update-multiple-patches-ed515cd53964c203.yaml000066400000000000000000000004351474550135400263420ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/use-bundle-names-b1b3ee5c2858c96b.yaml000066400000000000000000000005431474550135400251150ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/use-git-pager-settings-ec6555d8311a8bec.yaml000066400000000000000000000006731474550135400262600ustar00rootroot00000000000000--- 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.7.1/releasenotes/notes/warn-on-multiple-filters-a4e01fdb5cf6e459.yaml000066400000000000000000000003311474550135400267010ustar00rootroot00000000000000--- 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.7.1/requirements.txt000066400000000000000000000000771474550135400162300ustar00rootroot00000000000000click>=6.0 requests>=2.0 tabulate>=0.8 arrow>=0.10 PyYAML>=5.1 git-pw-2.7.1/rpm/000077500000000000000000000000001474550135400135365ustar00rootroot00000000000000git-pw-2.7.1/rpm/README.rst000066400000000000000000000020011474550135400152160ustar00rootroot00000000000000============== RPM spec files ============== Spec files for building an RPM for ``git-pw``. These should follow the Fedora Python Packaging Guidelines, found `here`__. These are published on `copr`__. You can build the RPM yourself using the following commands: .. code-block:: bash $ copr build $USER/$PROJECT rpm/git-pw.spec where ``$USER/$PROJECT`` refers to a project you've created on copr. If you haven't created one already, you can do like so: .. code-block:: bash $ copr create $PROJECT .. note:: The source code is pulled from PyPI, thus, local builds will only reflect changes to the spec file - not the source itself. If you wish to also reflect these changes, you need to update the value of ``Source0`` in the spec file. More information can be found in the `copr docs`__. .. __: https://docs.fedoraproject.org/en-US/packaging-guidelines/Python/ .. __: https://copr.fedorainfracloud.org/coprs/stephenfin/git-pw/ .. __: https://docs.pagure.org/copr.copr/user_documentation.html git-pw-2.7.1/rpm/git-pw.spec000066400000000000000000000025751474550135400156320ustar00rootroot00000000000000Name: git-pw Version: 2.3.0 Release: 1%{?dist} Summary: Git-Patchwork integration tool License: MIT URL: https://github.com/getpatchwork/git-pw Source0: %{pypi_source} BuildArch: noarch BuildRequires: python3-devel BuildRequires: python3-pbr BuildRequires: python3-setuptools %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 %generate_buildrequires %pyproject_buildrequires -t %build %pyproject_wheel %install %pyproject_install %pyproject_save_files git_pw mkdir -p %{buildroot}%{_mandir}/man1 install -p -D -m 644 man/*.1 %{buildroot}%{_mandir}/man1/ %check %tox %files -f %{pyproject_files} %license LICENSE %doc README.rst %{_bindir}/git-pw %{_mandir}/man1/git-pw*.1* %changelog * Thu Mar 24 2022 Stephen Finucane - 2.3.0-1 - Update to 2.3.0 * Mon Nov 29 2021 Stephen Finucane - 2.2.3-1 - Update to 2.2.3 * Fri Nov 26 2021 Stephen Finucane - 2.2.2-1 - Update to 2.2.2 * Fri Nov 26 2021 Stephen Finucane - 2.2.1-1 - Update to 2.2.1 * Fri Oct 01 2021 Stephen Finucane - 2.2.0-1 - Update to 2.2.0 * Sun Apr 26 2020 Stephen Finucane - 1.9.0-1 - Initial package. git-pw-2.7.1/setup.cfg000066400000000000000000000017711474550135400145670ustar00rootroot00000000000000[metadata] name = git-pw summary = Git-Patchwork integration tool long_description = file: README.rst long_description_content_type = text/x-rst; charset=UTF-8 license = MIT License license_file = LICENSE classifiers = Development Status :: 5 - Production/Stable Environment :: Console Intended Audience :: Developers Intended Audience :: Information Technology Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python 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.9 [files] packages = git_pw [entry_points] console_scripts = git-pw = git_pw.shell:cli git-pw-2.7.1/setup.py000066400000000000000000000001511474550135400144470ustar00rootroot00000000000000#!/usr/bin/env python3 from setuptools import setup setup( setup_requires=['pbr'], pbr=True, ) git-pw-2.7.1/test-requirements.txt000066400000000000000000000000271474550135400172000ustar00rootroot00000000000000pytest>=3.0 pytest-cov git-pw-2.7.1/tests/000077500000000000000000000000001474550135400141025ustar00rootroot00000000000000git-pw-2.7.1/tests/__init__.py000066400000000000000000000000001474550135400162010ustar00rootroot00000000000000git-pw-2.7.1/tests/test_api.py000066400000000000000000000114021474550135400162620ustar00rootroot00000000000000"""Unit tests for ``git_pw/api.py``.""" from unittest import mock import requests 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) def test_handle_error__server_error(caplog): fake_response = mock.MagicMock(autospec=requests.Response) fake_response.content = b'InternalServerError' fake_response.status_code = 500 exc = requests.exceptions.RequestException(response=fake_response) with pytest.raises(SystemExit): api._handle_error('fetch', exc) assert 'Server error.' in caplog.text def test_handle_error__not_found(caplog): fake_response = mock.MagicMock(autospec=requests.Response) fake_response.content = b'NotFound' fake_response.status_code = 404 exc = requests.exceptions.RequestException(response=fake_response) with pytest.raises(SystemExit): api._handle_error('fetch', exc) assert 'Resource not found' in caplog.text def test_handle_error__other(caplog): fake_response = mock.MagicMock(autospec=requests.Response) fake_response.content = b'{"key": "value"}' fake_response.status_code = 403 fake_response.text = '{"key": "value"}' exc = requests.exceptions.RequestException(response=fake_response) with pytest.raises(SystemExit): api._handle_error('fetch', exc) assert '{"key": "value"}' in caplog.text def test_handle_error__no_response(caplog): exc = requests.exceptions.RequestException() with pytest.raises(SystemExit): api._handle_error('fetch', exc) assert 'Failed to fetch resource.' in caplog.text @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.7.1/tests/test_bundle.py000066400000000000000000000460531474550135400167740ustar00rootroot00000000000000import unittest from unittest import mock from click.testing import CliRunner as CLIRunner 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'], str, ) 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.7.1/tests/test_patch.py000066400000000000000000000415771474550135400166300ustar00rootroot00000000000000import unittest from unittest import mock import click from click.testing import CliRunner as CLIRunner 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'], str, ) 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', '--since', '2022-01-01', '--before', '2022-12-31', ], ) 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'), ('since', '2022-01-01T00:00:00'), ('before', '2022-12-31T00:00:00'), ], ), ] 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.7.1/tests/test_series.py000066400000000000000000000244661474550135400170210ustar00rootroot00000000000000import unittest from unittest import mock from click.testing import CliRunner as CLIRunner 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'], str, ) def test_download_separate_to_dir(self, mock_download, mock_detail): """Validate downloading seperate to a directory.""" rsp = { 'mbox': 'http://example.com/api/patches/123/mbox/', 'patches': [ { 'id': 10539359, 'mbox': 'https://example.com/project/foo/patch/123/mbox/', } ], } mock_detail.return_value = rsp runner = CLIRunner() result = runner.invoke(series.download_cmd, ['123', '--separate', '.']) assert result.exit_code == 0, result mock_detail.assert_called_once_with('series', 123) mock_download.assert_called_once_with( rsp['patches'][0]['mbox'], output=mock.ANY, ) assert isinstance( mock_download.call_args[1]['output'], str, ) 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', '--since', '2022-01-01', '--before', '2022-12-31', ], ) 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'), ('since', '2022-01-01T00:00:00'), ('before', '2022-12-31T00:00:00'), ], ), ] 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.7.1/tests/test_utils.py000066400000000000000000000122661474550135400166620ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for ``git_pw/utils.py``.""" import subprocess import textwrap import os import yaml from unittest 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.pager') 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.pager') 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') @mock.patch.dict(os.environ, {'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.pager') 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(yaml, 'dump') def test_tabulate_yaml(mock_dump): output, headers, result = _test_tabulate('yaml') mock_dump.assert_called_once_with( [ { 'col1': b'foo', 'colb': 'bar', 'coliii': u'baz', 'colx': '😀', 'coly': None, 'colz': 1, } ], default_flow_style=False, ) @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.7.1/tox.ini000066400000000000000000000020571474550135400142570ustar00rootroot00000000000000[tox] minversion = 3.1 envlist = pep8,mypy,clean,py{39,310,311,312,313},report [testenv] deps = -r{toxinidir}/test-requirements.txt commands = pytest -Wall --cov=git_pw --cov-report term-missing {posargs} [testenv:pep8] skip_install = true deps = pre-commit commands = pre-commit run --all-files --show-diff-on-failure [testenv:mypy] deps= mypy types-PyYAML types-requests types-setuptools types-tabulate 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] # Does not currently support Python 3.12 # https://github.com/click-contrib/click-man/pull/64 basepython = 3.11 deps = click-man~=0.4.0 commands = click-man git-pw [flake8] show-source = true