├── .git-blame-ignore-revs ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── COPYING ├── README.rst ├── docs ├── conf.py ├── configuration.rst ├── contents.rst ├── index.rst ├── release-notes.rst ├── requirements.txt └── usage.rst ├── man └── pwclient.1 ├── pwclient ├── __init__.py ├── api.py ├── checks.py ├── exceptions.py ├── parser.py ├── patches.py ├── projects.py ├── shell.py ├── states.py ├── utils.py └── xmlrpc.py ├── pyproject.toml ├── releasenotes ├── config.yaml └── notes │ ├── add-long-opts-4611e7cce3993f08.yaml │ ├── add-python3-10-drop-python-3-6-support-32b91d5753adfc29.yaml │ ├── add-python3-11-support-eb86886925d2e5ec.yaml │ ├── add-python3-13-support-57096ea159e1f6af.yaml │ ├── check-get-4f010b2c4fdcd55c.yaml │ ├── check-list-create-help-94ccb51660af1138.yaml │ ├── check-patch-id-82f673f7c520ca24.yaml │ ├── drop-pypy-support-17f1f95b9394b257.yaml │ ├── drop-python2-support-0351245b41052e20.yaml │ ├── git-am--m-flag-190f3a7e17cec6f4.yaml │ ├── initial-release-eb74a7ae0ce3b1fb.yaml │ ├── issue-1-c7e4c3e4e57c1c22.yaml │ ├── list--hash-option-ebb96d3a70920cf5.yaml │ ├── pwclientrc-environment-variable-e070047b82e3b77f.yaml │ ├── rest-api-support-4341d2884f8d41c8.yaml │ ├── version-2-2-0d142cb0ab85eb67.yaml │ └── version-2-3-fd18e538b15396d8.yaml ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── tests ├── __init__.py ├── fakes.py ├── test_api.py ├── test_checks.py ├── test_patches.py ├── test_projects.py ├── test_shell.py ├── test_states.py └── test_utils.py └── tox.ini /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | b4892b5f7879e745dad57a631234f3dd553a1a3d 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | - push 5 | - pull_request 6 | jobs: 7 | lint: 8 | name: Run linters 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout source code 12 | uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.13' 17 | - name: Install dependencies 18 | run: python -m pip install tox 19 | - name: Run tox 20 | run: tox -e pep8,mypy 21 | test: 22 | name: Run unit tests 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | python: ['3.9', '3.10', '3.11', '3.12', '3.13'] 27 | steps: 28 | - name: Checkout source code 29 | uses: actions/checkout@v4 30 | - name: Set up Python ${{ matrix.python }} 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python }} 34 | - name: Install dependencies 35 | run: python -m pip install tox 36 | - name: Run unit tests (via tox) 37 | # Run tox using the version of Python in `PATH` 38 | run: tox -e py 39 | docs: 40 | name: Build docs 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout source code 44 | uses: actions/checkout@v4 45 | with: 46 | fetch-depth: 0 47 | - name: Set up Python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: '3.13' 51 | - name: Install dependencies 52 | run: python -m pip install tox 53 | - name: Build docs (via tox) 54 | run: tox -e docs 55 | - name: Archive build results 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: html-docs-build 59 | path: docs/_build/html 60 | retention-days: 7 61 | release: 62 | name: Upload release artifacts 63 | runs-on: ubuntu-latest 64 | needs: test 65 | if: github.event_name == 'push' 66 | steps: 67 | - name: Checkout source code 68 | uses: actions/checkout@v4 69 | with: 70 | fetch-depth: 0 71 | - name: Set up Python 72 | uses: actions/setup-python@v5 73 | with: 74 | python-version: '3.13' 75 | - name: Install dependencies 76 | run: python -m pip install build 77 | - name: Build a binary wheel and a source tarball 78 | run: python -m build --sdist --wheel --outdir dist/ . 79 | - name: Publish distribution to Test PyPI 80 | if: ${{ github.ref_type != 'tag' }} 81 | uses: pypa/gh-action-pypi-publish@release/v1 82 | with: 83 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 84 | repository-url: https://test.pypi.org/legacy/ 85 | - name: Publish distribution to PyPI 86 | if: ${{ github.ref_type == 'tag' }} 87 | uses: pypa/gh-action-pypi-publish@release/v1 88 | with: 89 | password: ${{ secrets.PYPI_API_TOKEN }} 90 | - name: Create release on GitHub 91 | id: create_release 92 | if: ${{ github.ref_type == 'tag' }} 93 | uses: actions/create-release@v1 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | with: 97 | tag_name: ${{ github.ref_name }} 98 | release_name: ${{ github.ref_name }} 99 | draft: false 100 | prerelease: false 101 | - name: Add sdist to release 102 | id: upload-release-asset 103 | if: ${{ github.ref_type == 'tag' }} 104 | uses: actions/upload-release-asset@v1 105 | env: 106 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 107 | with: 108 | upload_url: ${{ steps.create_release.outputs.upload_url }} 109 | asset_path: ./dist/pwclient-${{ github.ref_name }}.tar.gz 110 | asset_name: pwclient-${{ github.ref_name }}.tar.gz 111 | asset_content_type: application/gzip 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | /build/ 8 | /dist/ 9 | /eggs/ 10 | /.eggs/ 11 | /*.egg-info/ 12 | /*.egg 13 | 14 | # Unit test / coverage reports 15 | /.tox/ 16 | 17 | # Sphinx documentation 18 | /docs/_build/ 19 | 20 | # pbr 21 | /AUTHORS 22 | /ChangeLog 23 | /RELEASENOTES.rst 24 | /releasenotes/notes/reno.cache 25 | 26 | # virtualenv 27 | /.venv 28 | 29 | # Mypy 30 | /.mypy_cache 31 | 32 | # coverage 33 | /.coverage 34 | /htmlcov 35 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | default_language_version: 3 | # force all unspecified python hooks to run python3 4 | python: python3 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-executables-have-shebangs 10 | - id: check-merge-conflict 11 | - id: check-yaml 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/pycqa/flake8 15 | rev: 7.0.0 16 | hooks: 17 | - id: flake8 18 | - repo: https://github.com/psf/black 19 | rev: 23.12.1 20 | hooks: 21 | - id: black 22 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | python: 4 | install: 5 | - requirements: docs/requirements.txt 6 | - method: pip 7 | path: . 8 | build: 9 | os: "ubuntu-22.04" 10 | tools: 11 | python: "3.11" 12 | jobs: 13 | post_checkout: 14 | - git fetch --unshallow 15 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | pwclient 3 | ======== 4 | 5 | .. NOTE: If editing this, be sure to update the line numbers in 'doc/index' 6 | 7 | .. image:: https://badge.fury.io/py/pwclient.svg 8 | :target: https://badge.fury.io/py/pwclient 9 | :alt: PyPi Status 10 | 11 | .. image:: https://readthedocs.org/projects/pwclient/badge/?version=latest 12 | :target: https://pwclient.readthedocs.io/en/latest/?badge=latest 13 | :alt: Documentation Status 14 | 15 | .. image:: https://github.com/getpatchwork/pwclient/actions/workflows/ci.yaml/badge.svg 16 | :target: https://github.com/getpatchwork/pwclient/actions/workflows/ci.yaml 17 | :alt: Build Status 18 | 19 | *pwclient* is a VCS-agnostic tool for interacting with `Patchwork`__, the 20 | web-based patch tracking system. 21 | 22 | __ http://jk.ozlabs.org/projects/patchwork/ 23 | 24 | 25 | Installation 26 | ------------ 27 | 28 | The easiest way to install *pwclient* and its dependencies is using ``pip``. To 29 | do so, run: 30 | 31 | .. code-block:: bash 32 | 33 | $ python3 -m pip install pwclient 34 | 35 | You can also install *pwclient* manually. First, install the required 36 | dependencies. On Fedora, run: 37 | 38 | .. code-block:: bash 39 | 40 | $ sudo dnf install python-pbr 41 | 42 | On Ubuntu, run: 43 | 44 | .. code-block:: bash 45 | 46 | $ sudo apt-get install python-pbr 47 | 48 | Once dependencies are installed, clone this repo and run ``setup.py``: 49 | 50 | .. code-block:: bash 51 | 52 | $ git clone https://github.com/getpatchwork/pwclient 53 | $ cd pwclient 54 | $ python3 -m pip install --user . 55 | 56 | Getting Started 57 | --------------- 58 | 59 | To use *pwclient*, you will need a ``.pwclientrc`` file, located in your home 60 | directory (``$HOME`` or ``~``). You can point to another path with the 61 | environment variable ``PWCLIENTRC``. Patchwork itself provides sample 62 | ``.pwclientrc`` files for projects at ``/project/{projectName}/pwclientrc/``. 63 | For example, `here`__ is the ``.pwclientrc`` file for Patchwork itself. 64 | 65 | __ https://patchwork.ozlabs.org/project/patchwork/pwclientrc/ 66 | 67 | 68 | Development 69 | ----------- 70 | 71 | If you're interested in contributing to *pwclient*, first clone the repo: 72 | 73 | .. code-block:: bash 74 | 75 | $ git clone https://github.com/getpatchwork/pwclient 76 | $ cd pwclient 77 | 78 | Create a *virtualenv*, then install the package in `editable`__ mode: 79 | 80 | .. code-block:: bash 81 | 82 | $ virtualenv .venv 83 | $ source .venv/bin/activate 84 | $ python3 -m pip install --editable . 85 | 86 | __ https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs 87 | 88 | 89 | Documentation 90 | ------------- 91 | 92 | Documentation is available on `Read the Docs`__ 93 | 94 | __ https://pwclient.readthedocs.io/ 95 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # pwclient documentation build configuration file 2 | 3 | try: 4 | import sphinx_rtd_theme # noqa 5 | 6 | has_rtd_theme = True 7 | except ImportError: 8 | has_rtd_theme = False 9 | 10 | # -- General configuration ------------------------------------------------ 11 | 12 | # Add any Sphinx extension module names here, as strings. They can be 13 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 14 | # ones. 15 | extensions = [ 16 | 'reno.sphinxext', 17 | 'sphinxcontrib.autoprogram', 18 | ] 19 | 20 | # The master toctree document. 21 | master_doc = 'contents' 22 | 23 | # General information about the project. 24 | project = 'pwclient' 25 | copyright = '2018-present, Stephen Finucane' 26 | author = 'Stephen Finucane' 27 | 28 | # The name of the Pygments (syntax highlighting) style to use. 29 | pygments_style = 'sphinx' 30 | 31 | # -- Options for HTML output ---------------------------------------------- 32 | 33 | # The theme to use for HTML and HTML Help pages. See the documentation for 34 | # a list of builtin themes. 35 | # 36 | if has_rtd_theme: 37 | html_theme = 'sphinx_rtd_theme' 38 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | *pwclient* reads configuration from the ``.pwclientrc`` file, located in your 5 | home directory (``$HOME`` or ``~``). You can point to another path with the 6 | environment variable ``PWCLIENTRC``. Patchwork itself provides sample 7 | ``.pwclientrc`` files for projects at: 8 | 9 | /project/{projectName}/pwclientrc/ 10 | 11 | For example, `here`__ is the ``.pwclientrc`` file for Patchwork itself. 12 | 13 | __ https://patchwork.ozlabs.org/project/patchwork/pwclientrc/ 14 | 15 | 16 | Format 17 | ------ 18 | 19 | The ``.pwclientrc`` file is an `INI-style`__ config file, **containing** an 20 | ``options`` section along with a section for each project. 21 | 22 | The ``options`` section provides the following configuration options: 23 | 24 | ``default`` 25 | The default project to use. Must be configured if not specifying a project 26 | via the command line. 27 | 28 | ``signoff`` 29 | Add a ``Signed-Off-By:`` line to commit messages when applying patches using 30 | the :command:`git-am` command. Defaults to ``False``. 31 | 32 | ``3way`` 33 | Enable three-way merge when applying patches using the :command:`git-am` 34 | command. Defaults to ``False``. 35 | 36 | ``msgid`` 37 | Add a ``Message-Id:`` line to commit messages when applying patches using 38 | the :command:`git-am` command. Defaults to ``False``. 39 | 40 | The names of the project sections must correspond to the project names in 41 | Patchwork, as reflected in the project's URL in Patchwork. Multiple projects 42 | can be defined, but no two projects can share the same name. Project sections 43 | require the following configuration options: 44 | 45 | ``url`` 46 | The URL of the API endpoint for the Patchwork instance that the project is 47 | available on. This depends on the API *backend* in use. For the ``rest`` 48 | backend, this will typically be ``$PATCHWORK/api``. For example: 49 | 50 | https://patchwork.ozlabs.org/api 51 | 52 | For the ``xmlrpc`` backend, this is will typically be 53 | ``$PATCHWORK_URL/xmlrpc``. For example: 54 | 55 | https://patchwork.ozlabs.org/xmlrpc 56 | 57 | In addition, the following options are optional: 58 | 59 | ``backend`` 60 | The API backend to use. One of: ``rest``, ``xmlrpc`` 61 | 62 | ``username`` 63 | Your Patchwork username. 64 | 65 | ``password`` 66 | Your Patchwork password. 67 | 68 | ``token`` 69 | Your Patchwork API token. (only supported with ``rest`` backend) 70 | 71 | .. note:: 72 | 73 | Patchwork credentials are only needed for certain operations, such as 74 | updating the state of a patch. You will also require admin priviledges on 75 | the instance in question. 76 | 77 | __ https://en.wikipedia.org/wiki/INI_file 78 | 79 | 80 | Example 81 | ------- 82 | 83 | :: 84 | 85 | [options] 86 | default = patchwork 87 | 88 | [patchwork] 89 | backend = rest 90 | url = http://patchwork.ozlabs.org/api/ 91 | token = 088cade25e52482e6486794ef4a4561d3e5fe727 92 | 93 | Legacy Format 94 | ------------- 95 | 96 | Older Patchwork instances may provide a legacy version of the ``.pwclientrc`` 97 | file that did not support multiple projects. *pwclient* will automatically 98 | convert this version of the file to the latest version. 99 | -------------------------------------------------------------------------------- /docs/contents.rst: -------------------------------------------------------------------------------- 1 | Contents 2 | ======== 3 | 4 | .. toctree:: 5 | 6 | index 7 | configuration 8 | usage 9 | release-notes 10 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | pwclient 2 | ======== 3 | 4 | .. include:: ../README.rst 5 | :start-line: 18 6 | :end-line: -7 7 | -------------------------------------------------------------------------------- /docs/release-notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | .. release-notes:: 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | reno 3 | sphinx_rtd_theme 4 | sphinxcontrib.autoprogram 5 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | .. autoprogram:: pwclient.parser:get_parser() 5 | :prog: pwclient 6 | -------------------------------------------------------------------------------- /man/pwclient.1: -------------------------------------------------------------------------------- 1 | .TH PWCLIENT "1" "2024\-10\-23" "pwclient" "Generated Python Manual" 2 | .SH NAME 3 | pwclient 4 | .SH SYNOPSIS 5 | .B pwclient 6 | [-h] {apply,git-am,get,info,projects,check-get,check-list,check-info,check-create,states,view,update,list,search} ... 7 | 8 | .SH 9 | COMMANDS 10 | .TP 11 | \fBpwclient\fR \fI\,apply\/\fR 12 | apply a patch in the current directory using 'patch \-p1' 13 | .TP 14 | \fBpwclient\fR \fI\,git\-am\/\fR 15 | apply a patch to current git branch using 'git am' 16 | .TP 17 | \fBpwclient\fR \fI\,get\/\fR 18 | download a patch and save it locally 19 | .TP 20 | \fBpwclient\fR \fI\,info\/\fR 21 | show information for a given patch ID 22 | .TP 23 | \fBpwclient\fR \fI\,projects\/\fR 24 | list all projects 25 | .TP 26 | \fBpwclient\fR \fI\,check\-get\/\fR 27 | get checks for a patch 28 | .TP 29 | \fBpwclient\fR \fI\,check\-list\/\fR 30 | list all checks 31 | .TP 32 | \fBpwclient\fR \fI\,check\-info\/\fR 33 | show information for a given check 34 | .TP 35 | \fBpwclient\fR \fI\,check\-create\/\fR 36 | add a check to a patch 37 | .TP 38 | \fBpwclient\fR \fI\,states\/\fR 39 | show list of potential patch states 40 | .TP 41 | \fBpwclient\fR \fI\,view\/\fR 42 | view a patch 43 | .TP 44 | \fBpwclient\fR \fI\,update\/\fR 45 | update patch 46 | .TP 47 | \fBpwclient\fR \fI\,list\/\fR 48 | list patches using optional filters 49 | .TP 50 | \fBpwclient\fR \fI\,search\/\fR 51 | alias for 'list' 52 | 53 | .SH COMMAND \fI\,'pwclient apply'\/\fR 54 | usage: pwclient apply [\-\-help] [\-h] [\-p PROJECT] PATCH_ID [PATCH_ID ...] 55 | 56 | .TP 57 | \fBPATCH_ID\fR 58 | patch ID 59 | 60 | .SH OPTIONS \fI\,'pwclient apply'\/\fR 61 | .TP 62 | \fB\-h\fR, \fB\-\-use\-hashes\fR 63 | lookup by patch hash 64 | 65 | .TP 66 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 67 | lookup patch in project 68 | 69 | .SH COMMAND \fI\,'pwclient git\-am'\/\fR 70 | usage: pwclient git\-am [\-\-help] [\-h] [\-p PROJECT] [\-s] [\-3] [\-m] PATCH_ID [PATCH_ID ...] 71 | 72 | .TP 73 | \fBPATCH_ID\fR 74 | patch ID 75 | 76 | .SH OPTIONS \fI\,'pwclient git\-am'\/\fR 77 | .TP 78 | \fB\-h\fR, \fB\-\-use\-hashes\fR 79 | lookup by patch hash 80 | 81 | .TP 82 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 83 | lookup patch in project 84 | 85 | .TP 86 | \fB\-s\fR, \fB\-\-signoff\fR 87 | pass '\-\-signoff' to 'git\-am' 88 | 89 | .TP 90 | \fB\-3\fR, \fB\-\-3way\fR 91 | pass '\-\-3way' to 'git\-am' 92 | 93 | .TP 94 | \fB\-m\fR, \fB\-\-msgid\fR 95 | pass '\-\-message\-id' to 'git\-am' 96 | 97 | .SH COMMAND \fI\,'pwclient get'\/\fR 98 | usage: pwclient get [\-\-help] [\-h] [\-p PROJECT] PATCH_ID [PATCH_ID ...] 99 | 100 | .TP 101 | \fBPATCH_ID\fR 102 | patch ID 103 | 104 | .SH OPTIONS \fI\,'pwclient get'\/\fR 105 | .TP 106 | \fB\-h\fR, \fB\-\-use\-hashes\fR 107 | lookup by patch hash 108 | 109 | .TP 110 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 111 | lookup patch in project 112 | 113 | .SH COMMAND \fI\,'pwclient info'\/\fR 114 | usage: pwclient info [\-\-help] [\-h] [\-p PROJECT] PATCH_ID [PATCH_ID ...] 115 | 116 | .TP 117 | \fBPATCH_ID\fR 118 | patch ID 119 | 120 | .SH OPTIONS \fI\,'pwclient info'\/\fR 121 | .TP 122 | \fB\-h\fR, \fB\-\-use\-hashes\fR 123 | lookup by patch hash 124 | 125 | .TP 126 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 127 | lookup patch in project 128 | 129 | .SH COMMAND \fI\,'pwclient projects'\/\fR 130 | usage: pwclient projects [\-h] 131 | 132 | .SH COMMAND \fI\,'pwclient check\-get'\/\fR 133 | usage: pwclient check\-get [\-\-help] [\-h] [\-p PROJECT] [\-f FORMAT] PATCH_ID [PATCH_ID ...] 134 | 135 | .TP 136 | \fBPATCH_ID\fR 137 | patch ID 138 | 139 | .SH OPTIONS \fI\,'pwclient check\-get'\/\fR 140 | .TP 141 | \fB\-h\fR, \fB\-\-use\-hashes\fR 142 | lookup by patch hash 143 | 144 | .TP 145 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 146 | lookup patch in project 147 | 148 | .TP 149 | \fB\-f\fR \fI\,FORMAT\/\fR, \fB\-\-format\fR \fI\,FORMAT\/\fR 150 | print output in the given format. You can use tags matching fields, e.g. %{context}, %{state}, or %{msgid}. 151 | 152 | .SH COMMAND \fI\,'pwclient check\-list'\/\fR 153 | usage: pwclient check\-list [\-h] [\-u USER] [PATCH_ID] 154 | 155 | .TP 156 | \fBPATCH_ID\fR 157 | patch ID (required if using the REST API backend) 158 | 159 | .SH OPTIONS \fI\,'pwclient check\-list'\/\fR 160 | .TP 161 | \fB\-u\fR \fI\,USER\/\fR, \fB\-\-user\fR \fI\,USER\/\fR 162 | user (name or ID) to filter checks by 163 | 164 | .SH COMMAND \fI\,'pwclient check\-info'\/\fR 165 | usage: pwclient check\-info [\-h] [PATCH_ID] CHECK_ID 166 | 167 | .TP 168 | \fBPATCH_ID\fR 169 | patch ID (required if using the REST API backend) 170 | 171 | .TP 172 | \fBCHECK_ID\fR 173 | check ID 174 | 175 | .SH COMMAND \fI\,'pwclient check\-create'\/\fR 176 | usage: pwclient check\-create [\-\-help] [\-h] [\-p PROJECT] [\-c CONTEXT] [\-s {pending,success,warning,fail}] [\-u TARGET_URL] [\-d DESCRIPTION] PATCH_ID [PATCH_ID ...] 177 | 178 | .TP 179 | \fBPATCH_ID\fR 180 | patch ID 181 | 182 | .SH OPTIONS \fI\,'pwclient check\-create'\/\fR 183 | .TP 184 | \fB\-h\fR, \fB\-\-use\-hashes\fR 185 | lookup by patch hash 186 | 187 | .TP 188 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 189 | lookup patch in project 190 | 191 | .TP 192 | \fB\-c\fR \fI\,CONTEXT\/\fR, \fB\-\-context\fR \fI\,CONTEXT\/\fR 193 | 194 | .TP 195 | \fB\-s\fR \fI\,{pending,success,warning,fail}\/\fR, \fB\-\-state\fR \fI\,{pending,success,warning,fail}\/\fR 196 | 197 | .TP 198 | \fB\-u\fR \fI\,TARGET_URL\/\fR, \fB\-\-target\-url\fR \fI\,TARGET_URL\/\fR 199 | 200 | .TP 201 | \fB\-d\fR \fI\,DESCRIPTION\/\fR, \fB\-\-description\fR \fI\,DESCRIPTION\/\fR 202 | 203 | .SH COMMAND \fI\,'pwclient states'\/\fR 204 | usage: pwclient states [\-h] 205 | 206 | .SH COMMAND \fI\,'pwclient view'\/\fR 207 | usage: pwclient view [\-\-help] [\-h] [\-p PROJECT] PATCH_ID [PATCH_ID ...] 208 | 209 | .TP 210 | \fBPATCH_ID\fR 211 | patch ID 212 | 213 | .SH OPTIONS \fI\,'pwclient view'\/\fR 214 | .TP 215 | \fB\-h\fR, \fB\-\-use\-hashes\fR 216 | lookup by patch hash 217 | 218 | .TP 219 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 220 | lookup patch in project 221 | 222 | .SH COMMAND \fI\,'pwclient update'\/\fR 223 | usage: pwclient update [\-\-help] [\-h] [\-p PROJECT] [\-c COMMIT\-REF] [\-s STATE] [\-a {yes,no}] PATCH_ID [PATCH_ID ...] 224 | 225 | .TP 226 | \fBPATCH_ID\fR 227 | patch ID 228 | 229 | .SH OPTIONS \fI\,'pwclient update'\/\fR 230 | .TP 231 | \fB\-h\fR, \fB\-\-use\-hashes\fR 232 | lookup by patch hash 233 | 234 | .TP 235 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 236 | lookup patch in project 237 | 238 | .TP 239 | \fB\-c\fR \fI\,COMMIT\-REF\/\fR, \fB\-\-commit\-ref\fR \fI\,COMMIT\-REF\/\fR 240 | commit reference hash 241 | 242 | .TP 243 | \fB\-s\fR \fI\,STATE\/\fR, \fB\-\-state\fR \fI\,STATE\/\fR 244 | set patch state (e.g., 'Accepted', 'Superseded' etc.) 245 | 246 | .TP 247 | \fB\-a\fR \fI\,{yes,no}\/\fR, \fB\-\-archived\fR \fI\,{yes,no}\/\fR 248 | set patch archived state 249 | 250 | .SH COMMAND \fI\,'pwclient list'\/\fR 251 | usage: pwclient list [\-h] [\-s STATE] [\-a ARCHIVED] [\-p PROJECT] [\-w WHO] [\-d WHO] [\-n MAX#] [\-N MAX#] [\-m MESSAGEID] [\-H HASH] [\-f FORMAT] [STR] 252 | 253 | .TP 254 | \fBSTR\fR 255 | substring to search for patches by name 256 | 257 | .SH OPTIONS \fI\,'pwclient list'\/\fR 258 | .TP 259 | \fB\-s\fR \fI\,STATE\/\fR, \fB\-\-state\fR \fI\,STATE\/\fR 260 | filter by patch state (e.g., 'New', 'Accepted', etc.) 261 | 262 | .TP 263 | \fB\-a\fR \fI\,ARCHIVED\/\fR, \fB\-\-archived\fR \fI\,ARCHIVED\/\fR 264 | filter by patch archived state 265 | 266 | .TP 267 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 268 | filter by project name (see 'projects' for list) 269 | 270 | .TP 271 | \fB\-w\fR \fI\,WHO\/\fR, \fB\-\-submitter\fR \fI\,WHO\/\fR 272 | filter by submitter (name, e\-mail substring search) 273 | 274 | .TP 275 | \fB\-d\fR \fI\,WHO\/\fR, \fB\-\-delegate\fR \fI\,WHO\/\fR 276 | filter by delegate (name, e\-mail substring search) 277 | 278 | .TP 279 | \fB\-n\fR \fI\,MAX#\/\fR 280 | limit results to first n 281 | 282 | .TP 283 | \fB\-N\fR \fI\,MAX#\/\fR 284 | limit results to last N 285 | 286 | .TP 287 | \fB\-m\fR \fI\,MESSAGEID\/\fR, \fB\-\-msgid\fR \fI\,MESSAGEID\/\fR 288 | filter by Message\-Id 289 | 290 | .TP 291 | \fB\-H\fR \fI\,HASH\/\fR, \fB\-\-hash\fR \fI\,HASH\/\fR 292 | filter by hash 293 | 294 | .TP 295 | \fB\-f\fR \fI\,FORMAT\/\fR, \fB\-\-format\fR \fI\,FORMAT\/\fR 296 | print output in the given format. You can use tags matching fields, e.g. %{id}, %{state}, or %{msgid}. 297 | 298 | .SH COMMAND \fI\,'pwclient search'\/\fR 299 | usage: pwclient search [\-h] [\-s STATE] [\-a ARCHIVED] [\-p PROJECT] [\-w WHO] [\-d WHO] [\-n MAX#] [\-N MAX#] [\-m MESSAGEID] [\-H HASH] [\-f FORMAT] [STR] 300 | 301 | .TP 302 | \fBSTR\fR 303 | substring to search for patches by name 304 | 305 | .SH OPTIONS \fI\,'pwclient search'\/\fR 306 | .TP 307 | \fB\-s\fR \fI\,STATE\/\fR, \fB\-\-state\fR \fI\,STATE\/\fR 308 | filter by patch state (e.g., 'New', 'Accepted', etc.) 309 | 310 | .TP 311 | \fB\-a\fR \fI\,ARCHIVED\/\fR, \fB\-\-archived\fR \fI\,ARCHIVED\/\fR 312 | filter by patch archived state 313 | 314 | .TP 315 | \fB\-p\fR \fI\,PROJECT\/\fR, \fB\-\-project\fR \fI\,PROJECT\/\fR 316 | filter by project name (see 'projects' for list) 317 | 318 | .TP 319 | \fB\-w\fR \fI\,WHO\/\fR, \fB\-\-submitter\fR \fI\,WHO\/\fR 320 | filter by submitter (name, e\-mail substring search) 321 | 322 | .TP 323 | \fB\-d\fR \fI\,WHO\/\fR, \fB\-\-delegate\fR \fI\,WHO\/\fR 324 | filter by delegate (name, e\-mail substring search) 325 | 326 | .TP 327 | \fB\-n\fR \fI\,MAX#\/\fR 328 | limit results to first n 329 | 330 | .TP 331 | \fB\-N\fR \fI\,MAX#\/\fR 332 | limit results to last N 333 | 334 | .TP 335 | \fB\-m\fR \fI\,MESSAGEID\/\fR, \fB\-\-msgid\fR \fI\,MESSAGEID\/\fR 336 | filter by Message\-Id 337 | 338 | .TP 339 | \fB\-H\fR \fI\,HASH\/\fR, \fB\-\-hash\fR \fI\,HASH\/\fR 340 | filter by hash 341 | 342 | .TP 343 | \fB\-f\fR \fI\,FORMAT\/\fR, \fB\-\-format\fR \fI\,FORMAT\/\fR 344 | print output in the given format. You can use tags matching fields, e.g. %{id}, %{state}, or %{msgid}. 345 | 346 | .SH COMMENTS 347 | Use 'pwclient \-\-help' for more info. 348 | 349 | To avoid unicode encode/decode errors, you should export the LANG or LC_ALL 350 | environment variables according to the configured locales on your system. If 351 | these variables are already set, make sure that they point to valid and 352 | installed locales. 353 | 354 | .SH AUTHOR 355 | .nf 356 | Patchwork Developers 357 | .fi 358 | .nf 359 | patchwork@lists.ozlabs.org 360 | .fi 361 | 362 | .SH DISTRIBUTION 363 | The latest version of pwclient may be downloaded from 364 | .UR https://github.com/getpatchwork/patchwork 365 | .UE 366 | -------------------------------------------------------------------------------- /pwclient/__init__.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2022 Stephen Finucane 3 | # 4 | # SPDX-License-Identifier: GPL-2.0-or-later 5 | 6 | # TODO(stephenfin): Simplyify this when we drop support for Python 3.7 7 | try: 8 | import importlib.metadata as importlib_metadata 9 | except ImportError: 10 | import importlib_metadata as importlib_metadata # type: ignore 11 | 12 | try: 13 | __version__ = importlib_metadata.version(__package__ or __name__) 14 | except importlib_metadata.PackageNotFoundError: 15 | __version__ = '0.0.0' 16 | -------------------------------------------------------------------------------- /pwclient/api.py: -------------------------------------------------------------------------------- 1 | """A proxy layer for the REST API. 2 | 3 | Manipulate responses from the REST API to look like those retrieved from the 4 | XML-RPC API. 5 | """ 6 | 7 | import abc 8 | import base64 9 | import json 10 | import http 11 | import re 12 | import sys 13 | import urllib.error 14 | import urllib.parse 15 | import urllib.request 16 | 17 | from . import exceptions 18 | from . import xmlrpc 19 | from . import __version__ 20 | from .xmlrpc import xmlrpclib 21 | 22 | 23 | class API(metaclass=abc.ABCMeta): 24 | @abc.abstractmethod 25 | def __init__(self, server, *, username=None, password=None, token=None): 26 | if (username and not password) or (password and not username): 27 | raise exceptions.ConfigError( 28 | 'You must provide both a username and a password or a token' 29 | ) 30 | 31 | if (username and password) and token: 32 | raise exceptions.ConfigError( 33 | 'You must provide either a username and password or a token, ' 34 | 'not both' 35 | ) 36 | 37 | # project 38 | 39 | @abc.abstractmethod 40 | def project_list(self, search_str=None, max_count=0): 41 | pass 42 | 43 | @abc.abstractmethod 44 | def project_get(self, project_id): 45 | pass 46 | 47 | # person 48 | 49 | @abc.abstractmethod 50 | def person_list(self, search_str=None, max_count=0): 51 | pass 52 | 53 | @abc.abstractmethod 54 | def person_get(self, person_id): 55 | pass 56 | 57 | # patch 58 | 59 | @abc.abstractmethod 60 | def patch_list( 61 | self, 62 | project, 63 | submitter, 64 | delegate, 65 | state, 66 | archived, 67 | msgid, 68 | name, 69 | hash, 70 | max_count=None, 71 | ): 72 | pass 73 | 74 | @abc.abstractmethod 75 | def patch_get(self, patch_id): 76 | pass 77 | 78 | @abc.abstractmethod 79 | def patch_get_by_hash(self, hash): 80 | pass 81 | 82 | @abc.abstractmethod 83 | def patch_get_by_project_hash(self, project, hash): 84 | pass 85 | 86 | @abc.abstractmethod 87 | def patch_get_mbox(self, patch_id): 88 | pass 89 | 90 | @abc.abstractmethod 91 | def patch_get_diff(self, patch_id): 92 | pass 93 | 94 | @abc.abstractmethod 95 | def patch_set( 96 | self, 97 | patch_id, 98 | state=None, 99 | archived=None, 100 | commit_ref=None, 101 | ): 102 | pass 103 | 104 | # states 105 | 106 | @abc.abstractmethod 107 | def state_list(self, search_str=None, max_count=0): 108 | pass 109 | 110 | @abc.abstractmethod 111 | def state_get(self, state_id): 112 | pass 113 | 114 | # checks 115 | 116 | @abc.abstractmethod 117 | def check_list(self, patch_id, user): 118 | pass 119 | 120 | @abc.abstractmethod 121 | def check_get(self, patch_id, check_id): 122 | pass 123 | 124 | @abc.abstractmethod 125 | def check_create( 126 | self, 127 | patch_id, 128 | context, 129 | state, 130 | target_url="", 131 | description="", 132 | ): 133 | pass 134 | 135 | 136 | class XMLRPC(API): 137 | def __init__(self, server, *, username=None, password=None, token=None): 138 | super().__init__( 139 | server, 140 | username=username, 141 | password=password, 142 | token=token, 143 | ) 144 | 145 | if token: 146 | raise exceptions.ConfigError( 147 | 'The XML-RPC API does not support API tokens' 148 | ) 149 | 150 | self._server = server 151 | 152 | transport = xmlrpc.Transport(self._server) 153 | if username and password: 154 | transport.set_credentials(username, password) 155 | 156 | try: 157 | rpc = xmlrpc.xmlrpclib.ServerProxy( 158 | self._server, 159 | transport=transport, 160 | allow_none=True, 161 | ) 162 | except (IOError, OSError): 163 | raise exceptions.APIError(f'Unable to connect to {self._server}') 164 | 165 | self._client = rpc 166 | 167 | # project 168 | 169 | def project_list(self, search_str=None, max_count=0): 170 | return self._client.project_list(search_str, max_count) 171 | 172 | def project_get(self, project_id): 173 | return self._client.project_get(project_id) 174 | 175 | # person 176 | 177 | def person_list(self, search_str=None, max_count=0): 178 | return self._client.person_list(search_str, max_count) 179 | 180 | def person_get(self, person_id): 181 | return self._client.person_get(person_id) 182 | 183 | # patch 184 | 185 | def _state_id_by_name(self, name): 186 | """Given a partial state name, look up the state ID.""" 187 | if len(name) == 0: 188 | return 0 189 | states = self.state_list(name, 0) 190 | for state in states: 191 | if state['name'].lower().startswith(name.lower()): 192 | return state['id'] 193 | return 0 194 | 195 | def _patch_id_from_hash(self, project, hash): 196 | patch = self.patch_get_by_project_hash(project, hash) 197 | 198 | if patch == {}: 199 | sys.stderr.write("No patch has the hash provided\n") 200 | sys.exit(1) 201 | 202 | patch_id = patch['id'] 203 | # be super paranoid 204 | try: 205 | patch_id = int(patch_id) 206 | except ValueError: 207 | sys.stderr.write("Invalid patch ID obtained from server\n") 208 | sys.exit(1) 209 | return patch_id 210 | 211 | def _project_id_by_name(self, linkname): 212 | """Given a project short name, look up the Project ID.""" 213 | if len(linkname) == 0: 214 | return 0 215 | projects = self.project_list(linkname, 0) 216 | for project in projects: 217 | if project['linkname'] == linkname: 218 | return project['id'] 219 | return 0 220 | 221 | def _person_ids_by_name(self, name): 222 | """Given a partial name or email address, return a list of the 223 | person IDs that match.""" 224 | if len(name) == 0: 225 | return [] 226 | people = self.person_list(name, 0) 227 | return [x['id'] for x in people] 228 | 229 | @staticmethod 230 | def _decode_patch(patch): 231 | # Some values are transferred as Binary data, these are encoded in 232 | # utf-8. As of Python 3.9 xmlrpclib.Binary.__str__ however assumes 233 | # latin1, so decode explicitly 234 | return { 235 | k: v.data.decode('utf-8') if isinstance(v, xmlrpclib.Binary) else v 236 | for k, v in patch.items() 237 | } 238 | 239 | def patch_list( 240 | self, 241 | project, 242 | submitter, 243 | delegate, 244 | state, 245 | archived, 246 | msgid, 247 | name, 248 | hash, 249 | max_count=None, 250 | ): 251 | filters = {} 252 | 253 | if max_count: 254 | filters['max_count'] = max_count 255 | 256 | if archived is not None: 257 | filters['archived'] = archived 258 | 259 | if msgid: 260 | filters['msgid'] = msgid 261 | 262 | if name: 263 | filters['name__icontains'] = name 264 | 265 | if hash: 266 | filters['hash'] = hash 267 | 268 | if state is not None: 269 | state_id = self._state_id_by_name(state) 270 | if state_id == 0: 271 | sys.stderr.write( 272 | "Note: No State found matching %s*, " 273 | "ignoring filter\n" % state 274 | ) 275 | else: 276 | filters['state_id'] = state_id 277 | 278 | if project is not None: 279 | project_id = self._project_id_by_name(project) 280 | if project_id == 0: 281 | sys.stderr.write( 282 | "Note: No Project found matching %s, " 283 | "ignoring filter\n" % project 284 | ) 285 | else: 286 | filters['project_id'] = project_id 287 | 288 | # TODO(stephenfin): This is unfortunate. We don't allow you to filter 289 | # by both submitter and delegate. This is due to the fact that both are 290 | # partial string matches and we emit a log message for each match. We 291 | # really need to get rid of that log and print a combined (and 292 | # de-duplicated) list to fix this. 293 | 294 | if submitter is not None: 295 | patches = [] 296 | person_ids = self._person_ids_by_name(submitter) 297 | if len(person_ids) == 0: 298 | sys.stderr.write( 299 | "Note: Nobody found matching *%s*\n" % submitter 300 | ) 301 | else: 302 | for person_id in person_ids: 303 | filters['submitter_id'] = person_id 304 | patches += self._client.patch_list(filters) 305 | return [self._decode_patch(patch) for patch in patches] 306 | 307 | if delegate is not None: 308 | patches = [] 309 | delegate_ids = self._person_ids_by_name(delegate) 310 | if len(delegate_ids) == 0: 311 | sys.stderr.write( 312 | "Note: Nobody found matching *%s*\n" % delegate 313 | ) 314 | else: 315 | for delegate_id in delegate_ids: 316 | filters['delegate_id'] = delegate_id 317 | patches += self._client.patch_list(filters) 318 | return [self._decode_patch(patch) for patch in patches] 319 | 320 | patches = self._client.patch_list(filters) 321 | return [self._decode_patch(patch) for patch in patches] 322 | 323 | def patch_get(self, patch_id): 324 | patch = self._client.patch_get(patch_id) 325 | if patch == {}: 326 | raise exceptions.APIError( 327 | 'Unable to fetch patch %d; does it exist?' % patch_id 328 | ) 329 | 330 | return self._decode_patch(patch) 331 | 332 | def patch_get_by_hash(self, hash): 333 | return self._client.patch_get_by_hash(hash) 334 | 335 | def patch_get_by_project_hash(self, project, hash): 336 | return self._client.patch_get_by_project_hash(project, hash) 337 | 338 | def patch_get_mbox(self, patch_id): 339 | patch = self.patch_get(patch_id) 340 | 341 | mbox = self._client.patch_get_mbox(patch_id) 342 | if len(mbox) == 0: 343 | raise exceptions.APIError( 344 | 'Unable to fetch mbox for patch %d; does it exist?' % patch_id 345 | ) 346 | 347 | return mbox, patch['filename'] 348 | 349 | def patch_get_diff(self, patch_id): 350 | return self._client.patch_get_diff(patch_id) 351 | 352 | def patch_set( 353 | self, 354 | patch_id, 355 | state=None, 356 | archived=None, 357 | commit_ref=None, 358 | ): 359 | params = {} 360 | 361 | if state: 362 | state_id = self._state_id_by_name(state) 363 | if state_id == 0: 364 | raise exceptions.APIError( 365 | 'No State found matching %s*' % state 366 | ) 367 | sys.exit(1) 368 | 369 | params['state'] = state_id 370 | 371 | if commit_ref: 372 | params['commit_ref'] = commit_ref 373 | 374 | if archived: 375 | params['archived'] = archived == 'yes' 376 | 377 | try: 378 | self._client.patch_set(patch_id, params) 379 | except xmlrpclib.Fault as f: 380 | raise exceptions.APIError( 381 | 'Error updating patch: %s' % f.faultString 382 | ) 383 | 384 | # states 385 | 386 | def state_list(self, search_str=None, max_count=0): 387 | return self._client.state_list(search_str, max_count) 388 | 389 | def state_get(self, state_id): 390 | return self._client.state_get(state_id) 391 | 392 | # checks 393 | 394 | def check_list(self, patch_id, user): 395 | filters = {} 396 | 397 | if patch_id is not None: 398 | filters['patch_id'] = patch_id 399 | 400 | if user is not None: 401 | filters['user'] = user 402 | 403 | return self._client.check_list(filters) 404 | 405 | def check_get(self, patch_id, check_id): 406 | # patch_id is not necessary for the XML-RPC API 407 | return self._client.check_get(check_id) 408 | 409 | def check_create( 410 | self, 411 | patch_id, 412 | context, 413 | state, 414 | target_url="", 415 | description="", 416 | ): 417 | try: 418 | self._client.check_create( 419 | patch_id, 420 | context, 421 | state, 422 | target_url, 423 | description, 424 | ) 425 | except xmlrpclib.Fault as f: 426 | raise exceptions.APIError( 427 | 'Error creating check: %s' % f.faultString 428 | ) 429 | 430 | 431 | class REST(API): 432 | def __init__(self, server, *, username=None, password=None, token=None): 433 | # TODO(stephenfin): We want to deprecate this behavior at some point 434 | parsed_server = urllib.parse.urlparse(server) 435 | scheme = parsed_server.scheme or 'http' 436 | hostname = parsed_server.netloc 437 | path = parsed_server.path.rstrip('/') 438 | if path.rstrip('/') == '/xmlrpc': 439 | sys.stderr.write( 440 | f"Automatically converted XML-RPC URL to REST API URL. This " 441 | f"is deprecated behavior and will be removed in a future " 442 | f"release. Update your pwclientrc to use the following URL: " 443 | f"{scheme}://{hostname}/api\n" 444 | ) 445 | path = '/api' 446 | 447 | self._server = f'{scheme}://{hostname}{path}' 448 | 449 | self._username = username 450 | self._password = password 451 | self._token = token 452 | 453 | def _generate_headers(self, additional_headers=None): 454 | headers = { 455 | 'User-Agent': f'pwclient ({__version__})', 456 | } 457 | 458 | if self._token: 459 | headers['Authorization'] = f"Token {self._token}" 460 | elif self._username and self._password: 461 | credentials = base64.b64encode( 462 | f'{self._username}:{self._password}'.encode('ascii') 463 | ).decode('ascii') 464 | headers['Authorization'] = f'Basic {credentials}' 465 | 466 | if additional_headers: 467 | headers = dict(headers, **additional_headers) 468 | 469 | return headers 470 | 471 | def _get(self, url): 472 | request = urllib.request.Request( 473 | url=url, method='GET', headers=self._generate_headers() 474 | ) 475 | try: 476 | with urllib.request.urlopen(request) as resp: 477 | data = resp.read() 478 | headers = resp.getheaders() 479 | except urllib.error.HTTPError as exc: 480 | # the XML-RPC API returns an empty body, annoyingly, so we must 481 | # emulate this 482 | if exc.status == http.HTTPStatus.NOT_FOUND: 483 | return {}, {} 484 | 485 | sys.stderr.write('Request failed\n\n') 486 | sys.stderr.write('Response:\n') 487 | sys.stderr.write(exc.read().decode('utf-8')) 488 | sys.exit(1) 489 | 490 | return data, headers 491 | 492 | def _post(self, url, data): 493 | request = urllib.request.Request( 494 | url=url, 495 | data=json.dumps(data).encode('utf-8'), 496 | method='POST', 497 | headers=self._generate_headers( 498 | { 499 | 'Content-Type': 'application/json', 500 | }, 501 | ), 502 | ) 503 | try: 504 | with urllib.request.urlopen(request) as resp: 505 | data = resp.read() 506 | headers = resp.getheaders() 507 | except urllib.error.HTTPError as exc: 508 | sys.stderr.write('Request failed\n\n') 509 | sys.stderr.write('Response:\n') 510 | sys.stderr.write(exc.read().decode('utf-8')) 511 | sys.exit(1) 512 | 513 | return data, headers 514 | 515 | def _put(self, url, data): 516 | request = urllib.request.Request( 517 | url=url, 518 | data=json.dumps(data).encode('utf-8'), 519 | method='PATCH', 520 | headers=self._generate_headers( 521 | { 522 | 'Content-Type': 'application/json', 523 | }, 524 | ), 525 | ) 526 | try: 527 | with urllib.request.urlopen(request) as resp: 528 | data = resp.read() 529 | headers = resp.getheaders() 530 | except urllib.error.HTTPError as exc: 531 | sys.stderr.write('Request failed\n\n') 532 | sys.stderr.write('Response:\n') 533 | sys.stderr.write(exc.read().decode('utf-8')) 534 | sys.exit(1) 535 | 536 | return data, headers 537 | 538 | def _create( 539 | self, 540 | resource_type, 541 | data, 542 | *, 543 | resource_id=None, 544 | subresource_type=None, 545 | ): 546 | url = f'{self._server}/{resource_type}/' 547 | if resource_id: 548 | url = f'{url}{resource_id}/{subresource_type}/' 549 | data, _ = self._post(url, data) 550 | return json.loads(data) 551 | 552 | def _update( 553 | self, 554 | resource_type, 555 | resource_id, 556 | data, 557 | *, 558 | subresource_type=None, 559 | subresource_id=None, 560 | ): 561 | url = f'{self._server}/{resource_type}/{resource_id}/' 562 | if subresource_id: 563 | url = f'{url}{subresource_type}/{subresource_id}/' 564 | data, _ = self._put(url, data) 565 | return json.loads(data) 566 | 567 | def _detail( 568 | self, 569 | resource_type, 570 | resource_id, 571 | params=None, 572 | *, 573 | subresource_type=None, 574 | subresource_id=None, 575 | ): 576 | url = f'{self._server}/{resource_type}/{resource_id}/' 577 | if subresource_type: 578 | url = f'{url}{subresource_type}/{subresource_id}/' 579 | if params: 580 | url = f'{url}?{urllib.parse.urlencode(params)}' 581 | data, _ = self._get(url) 582 | return json.loads(data) 583 | 584 | def _list( 585 | self, 586 | resource_type, 587 | params=None, 588 | *, 589 | resource_id=None, 590 | subresource_type=None, 591 | ): 592 | url = f'{self._server}/{resource_type}/' 593 | if resource_id: 594 | url = f'{url}{resource_id}/{subresource_type}/' 595 | if params: 596 | url = f'{url}?{urllib.parse.urlencode(params)}' 597 | data, _ = self._get(url) 598 | return json.loads(data) 599 | 600 | # project 601 | 602 | @staticmethod 603 | def _project_to_dict(obj): 604 | """Serialize a project response. 605 | 606 | Return a trimmed down dictionary representation of the API response 607 | that matches what we got from the XML-RPC API. 608 | """ 609 | return { 610 | 'id': obj['id'], 611 | 'linkname': obj['linkname'] 612 | if 'linkname' in obj 613 | else obj['link_name'], 614 | 'name': obj['name'], 615 | } 616 | 617 | def project_list(self, search_str=None, max_count=0): 618 | # we could implement these but we don't need them 619 | if search_str: 620 | raise NotImplementedError( 621 | 'The search_str parameter is not supported', 622 | ) 623 | 624 | if max_count: 625 | raise NotImplementedError( 626 | 'The max_count parameter is not supported', 627 | ) 628 | 629 | projects = self._list('projects') 630 | return [self._project_to_dict(project) for project in projects] 631 | 632 | def project_get(self, project_id): 633 | project = self._detail('projects', project_id) 634 | return self._project_to_dict(project) 635 | 636 | # person 637 | 638 | @staticmethod 639 | def _person_to_dict(obj): 640 | """Serialize a person response. 641 | 642 | Return a trimmed down dictionary representation of the API response 643 | that matches what we got from the XML-RPC API. 644 | """ 645 | return { 646 | 'id': obj['id'], 647 | 'email': obj['email'], 648 | 'name': obj['name'] if obj['name'] else obj['email'], 649 | 'user': obj['user']['username'] if obj['username'] else '', 650 | } 651 | 652 | def person_list(self, search_str=None, max_count=0): 653 | # we could implement these but we don't need them 654 | if search_str: 655 | raise NotImplementedError( 656 | 'The search_str parameter is not supported', 657 | ) 658 | 659 | if max_count: 660 | raise NotImplementedError( 661 | 'The max_count parameter is not supported', 662 | ) 663 | 664 | people = self._list('people') 665 | return [self._person_to_dict(person) for person in people] 666 | 667 | def person_get(self, person_id): 668 | person = self._detail('people', person_id) 669 | return self._person_to_dict(person) 670 | 671 | # patch 672 | 673 | @staticmethod 674 | def _patch_to_dict(obj): 675 | """Serialize a patch response. 676 | 677 | Return a trimmed down dictionary representation of the API response 678 | that matches what we got from the XML-RPC API. 679 | """ 680 | 681 | def _format_person(person): 682 | if not person: 683 | return '' 684 | 685 | if person['name']: 686 | return f"{person['name']} <{person['email']}>" 687 | return person['email'] 688 | 689 | def _format_user(user): 690 | if not user: 691 | return '' 692 | 693 | return user['username'] 694 | 695 | return { 696 | 'id': obj['id'], 697 | 'date': obj['date'], 698 | 'filename': obj.get('filename') or '', 699 | 'msgid': obj['msgid'], 700 | 'name': obj['name'], 701 | 'project': obj['project']['name'], 702 | 'project_id': obj['project']['id'], 703 | 'state': obj['state'], 704 | 'state_id': '', # NOTE: this isn't exposed 705 | 'archived': obj['archived'], 706 | 'submitter': _format_person(obj['submitter']), 707 | 'submitter_id': obj['submitter']['id'], 708 | 'delegate': _format_user(obj['delegate']), 709 | 'delegate_id': obj['delegate']['id'] if obj['delegate'] else '', 710 | 'commit_ref': obj['commit_ref'] or '', 711 | 'hash': obj['hash'] or '', 712 | } 713 | 714 | def patch_list( 715 | self, 716 | project, 717 | submitter, 718 | delegate, 719 | state, 720 | archived, 721 | msgid, 722 | name, 723 | hash, 724 | max_count=None, 725 | ): 726 | # we could implement these but we don't need them 727 | if max_count: 728 | raise NotImplementedError( 729 | 'The max_count parameter is not supported', 730 | ) 731 | 732 | filters = {} 733 | 734 | if state is not None: 735 | # we slugify this since that's what the API expects 736 | filters['state'] = state.lower().replace(' ', '-') 737 | 738 | if project is not None: 739 | filters['project'] = project 740 | 741 | if hash is not None: 742 | filters['hash'] = hash 743 | 744 | if msgid is not None: 745 | filters['msgid'] = msgid 746 | 747 | if archived is not None: 748 | filters['archived'] = archived 749 | 750 | if delegate is not None: 751 | filters['delegate'] = delegate 752 | 753 | patches = self._list('patches', params=filters) 754 | return [self._patch_to_dict(patch) for patch in patches] 755 | 756 | def patch_get(self, patch_id): 757 | patch = self._detail('patches', patch_id) 758 | return self._patch_to_dict(patch) 759 | 760 | def patch_get_by_hash(self, hash): 761 | patches = self._list('patches', {'hash': hash}) 762 | if len(patches) != 1: 763 | return {} # emulate xmlrpc behavior 764 | return self._patch_to_dict(patches[0]) 765 | 766 | def patch_get_by_project_hash(self, project, hash): 767 | patches = self._list('patches', {'project': project, 'hash': hash}) 768 | if len(patches) != 1: 769 | return {} # emulate xmlrpc behavior 770 | return self._patch_to_dict(patches[0]) 771 | 772 | def patch_get_mbox(self, patch_id): 773 | patch = self._detail('patches', patch_id) 774 | data, headers = self._get(patch['mbox']) 775 | header = '' 776 | for name, value in headers: 777 | if name.lower() == 'content-disposition': 778 | header = value 779 | break 780 | header_re = re.search('filename=(.+)', header) 781 | if not header_re: 782 | raise Exception('filename header was missing from the response') 783 | 784 | filename = header_re.group(1)[:-6] # remove the extension 785 | 786 | return data.decode('utf-8'), filename 787 | 788 | def patch_get_diff(self, patch_id): 789 | patch = self._detail('patches', patch_id) 790 | return patch['diff'] 791 | 792 | def patch_set( 793 | self, 794 | patch_id, 795 | state=None, 796 | archived=None, 797 | commit_ref=None, 798 | ): 799 | params = {} 800 | 801 | if state is not None: 802 | # we slugify this since that's what the API expects 803 | params['state'] = state.lower().replace(' ', '-') 804 | 805 | if commit_ref is not None: 806 | params['commit_ref'] = commit_ref 807 | 808 | if archived is not None: 809 | params['archived'] = archived 810 | 811 | self._update('patches', patch_id, params) 812 | 813 | # states 814 | 815 | # Patch states are not exposed via the REST API, on the basis that they 816 | # will eventually be a static list as opposed to something configurable. As 817 | # such, we simply emulate the behavior here. 818 | 819 | def state_list(self, search_str=None, max_count=0): 820 | raise NotImplementedError('The REST API does not expose state objects') 821 | 822 | def state_get(self, state_id): 823 | raise NotImplementedError('The REST API does not expose state objects') 824 | 825 | # checks 826 | 827 | @staticmethod 828 | def _check_to_dict(obj, patch): 829 | """Serialize a check response. 830 | 831 | Return a trimmed down dictionary representation of the API response 832 | that matches what we got from the XML-RPC API. 833 | """ 834 | return { 835 | 'id': obj['id'], 836 | 'date': obj['date'], 837 | 'patch': patch['name'], 838 | 'patch_id': patch['id'], 839 | 'user': obj['user']['username'] if obj['user'] else '', 840 | 'user_id': obj['user']['id'], 841 | 'state': obj['state'], 842 | 'target_url': obj['target_url'], 843 | 'description': obj['description'], 844 | 'context': obj['context'], 845 | } 846 | 847 | def check_list(self, patch_id, user): 848 | if not patch_id: 849 | raise NotImplementedError( 850 | 'The REST API does not allow listing of all checks by ' 851 | 'project; listing of checks requires a target patch' 852 | ) 853 | 854 | filters = {} 855 | 856 | if user is not None: 857 | filters['user'] = user 858 | 859 | # this is icky, but alas we don't provide this information in the 860 | # response 861 | patch = self._detail( 862 | 'patches', 863 | patch_id, 864 | ) 865 | checks = self._list( 866 | 'patches', 867 | filters, 868 | resource_id=patch_id, 869 | subresource_type='checks', 870 | ) 871 | return [self._check_to_dict(check, patch) for check in checks] 872 | 873 | def check_get(self, patch_id, check_id): 874 | if not patch_id: 875 | raise NotImplementedError( 876 | 'The REST API does not allow listing of all checks by ' 877 | 'project; listing of checks requires a target patch' 878 | ) 879 | 880 | # this is icky, but alas we don't provide this information in the 881 | # response 882 | patch = self._detail( 883 | 'patches', 884 | patch_id, 885 | ) 886 | check = self._detail( 887 | 'patches', 888 | resource_id=patch_id, 889 | subresource_type='checks', 890 | subresource_id=check_id, 891 | ) 892 | return self._check_to_dict(check, patch) 893 | 894 | def check_create( 895 | self, 896 | patch_id, 897 | context, 898 | state, 899 | target_url="", 900 | description="", 901 | ): 902 | self._create( 903 | 'patches', 904 | resource_id=patch_id, 905 | subresource_type='checks', 906 | data={ 907 | 'context': context, 908 | 'state': state, 909 | 'target_url': target_url, 910 | 'description': description, 911 | }, 912 | ) 913 | -------------------------------------------------------------------------------- /pwclient/checks.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2018 Stephen Finucane 3 | # Copyright (C) 2008 Nate Case 4 | # 5 | # SPDX-License-Identifier: GPL-2.0-or-later 6 | 7 | import re 8 | import sys 9 | 10 | from . import exceptions 11 | 12 | 13 | def action_list(api, patch_id=None, user=None): 14 | checks = api.check_list(patch_id, user) 15 | print("%-5s %-16s %-8s %s" % ("ID", "Context", "State", "Patch")) 16 | print("%-5s %-16s %-8s %s" % ("--", "-------", "-----", "-----")) 17 | for check in checks: 18 | print( 19 | "%-5s %-16s %-8s %s" 20 | % (check['id'], check['context'], check['state'], check['patch']) 21 | ) 22 | 23 | 24 | def action_info(api, patch_id, check_id): 25 | check = api.check_get(patch_id, check_id) 26 | s = "Information for check id %d" % (check_id) 27 | print(s) 28 | print('-' * len(s)) 29 | for key, value in sorted(check.items()): 30 | print("- %- 14s: %s" % (key, value)) 31 | 32 | 33 | def action_get(api, patch_id, format_str=None): 34 | checks = api.check_list(patch_id, user=None) 35 | if checks is None: 36 | return 37 | 38 | if format_str: 39 | format_field_re = re.compile('%{([a-z0-9_]+)}') 40 | 41 | def check_field(matchobj): 42 | fieldname = matchobj.group(1) 43 | 44 | return str(check[fieldname]) 45 | 46 | for check in checks: 47 | print(format_field_re.sub(check_field, format_str)) 48 | else: 49 | s = "Check information for patch id %d" % patch_id 50 | print(s) 51 | print('-' * len(s)) 52 | out = [] 53 | for check in checks: 54 | cout = [] 55 | for key, value in sorted(check.items()): 56 | value = ' ' + str(value) if value else value 57 | cout.append("- %- 14s:%s" % (key, value)) 58 | out.append("\n".join(cout)) 59 | print("\n\n".join(out)) 60 | 61 | 62 | def action_create(api, patch_id, context, state, url, description): 63 | try: 64 | api.check_create(patch_id, context, state, url, description) 65 | except exceptions.APIError as exc: 66 | sys.stderr.write(str(exc)) 67 | -------------------------------------------------------------------------------- /pwclient/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions.""" 2 | 3 | 4 | class ConfigError(Exception): 5 | """Exception for all configuration-related errors.""" 6 | 7 | pass 8 | 9 | 10 | class APIError(Exception): 11 | """Exception for all API-related errors.""" 12 | 13 | pass 14 | -------------------------------------------------------------------------------- /pwclient/parser.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2018 Stephen Finucane 3 | # Copyright (C) 2008 Nate Case 4 | # 5 | # SPDX-License-Identifier: GPL-2.0-or-later 6 | 7 | import argparse 8 | 9 | 10 | def _get_hash_parser(): 11 | hash_parser = argparse.ArgumentParser(add_help=False) 12 | hash_parser.add_argument( 13 | '-h', '--use-hashes', action='store_true', help="lookup by patch hash" 14 | ) 15 | hash_parser.add_argument( 16 | '-p', '--project', metavar='PROJECT', help="lookup patch in project" 17 | ) 18 | hash_parser.add_argument( 19 | 'id', 20 | metavar='PATCH_ID', 21 | nargs='+', 22 | action='store', 23 | default=[], 24 | help="patch ID", 25 | ) 26 | 27 | return hash_parser 28 | 29 | 30 | class NegateIntegerAction(argparse.Action): 31 | """A custom action to negate an integer type.""" 32 | 33 | def __call__(self, parser, namespace, values, option_string=None): 34 | setattr(namespace, self.dest, -int(values)) 35 | 36 | 37 | class BooleanStringAction(argparse.Action): 38 | """A custom action to parse arguments as yes/no values as booleans.""" 39 | 40 | def __call__(self, parser, namespace, values, option_string=None): 41 | if values not in ('yes', 'no'): 42 | msg = "invalid choice: %(value)r (choose from 'yes', 'no')" 43 | args = {'value': values} 44 | raise argparse.ArgumentError(self, msg % args) 45 | 46 | setattr(namespace, self.dest, values == 'yes') 47 | 48 | 49 | def _get_filter_parser(): 50 | filter_parser = argparse.ArgumentParser(add_help=False) 51 | filter_parser.add_argument( 52 | '-s', 53 | '--state', 54 | metavar='STATE', 55 | help="filter by patch state (e.g., 'New', 'Accepted', etc.)", 56 | ) 57 | filter_parser.add_argument( 58 | '-a', 59 | '--archived', 60 | action=BooleanStringAction, 61 | help="filter by patch archived state", 62 | ) 63 | filter_parser.add_argument( 64 | '-p', 65 | '--project', 66 | metavar='PROJECT', 67 | help="filter by project name (see 'projects' for list)", 68 | ) 69 | filter_parser.add_argument( 70 | '-w', 71 | '--submitter', 72 | metavar='WHO', 73 | help="filter by submitter (name, e-mail substring search)", 74 | ) 75 | filter_parser.add_argument( 76 | '-d', 77 | '--delegate', 78 | metavar='WHO', 79 | help="filter by delegate (name, e-mail substring search)", 80 | ) 81 | filter_parser.add_argument( 82 | '-n', 83 | metavar='MAX#', 84 | type=int, 85 | dest='max_count', 86 | help="limit results to first n", 87 | ) 88 | filter_parser.add_argument( 89 | '-N', 90 | metavar='MAX#', 91 | type=int, 92 | dest='max_count', 93 | action=NegateIntegerAction, 94 | help="limit results to last N", 95 | ) 96 | filter_parser.add_argument( 97 | '-m', '--msgid', metavar='MESSAGEID', help="filter by Message-Id" 98 | ) 99 | filter_parser.add_argument( 100 | '-H', '--hash', metavar='HASH', help="filter by hash" 101 | ) 102 | filter_parser.add_argument( 103 | '-f', 104 | '--format', 105 | metavar='FORMAT', 106 | help=( 107 | "print output in the given format. You can use tags matching " 108 | "fields, e.g. %%{id}, %%{state}, or %%{msgid}." 109 | ), 110 | ) 111 | filter_parser.add_argument( 112 | 'patch_name', 113 | metavar='STR', 114 | nargs='?', 115 | help='substring to search for patches by name', 116 | ) 117 | 118 | return filter_parser 119 | 120 | 121 | def get_parser(): 122 | hash_parser = _get_hash_parser() 123 | filter_parser = _get_filter_parser() 124 | 125 | action_parser = argparse.ArgumentParser( 126 | prog='pwclient', 127 | formatter_class=argparse.RawTextHelpFormatter, 128 | epilog="""Use 'pwclient --help' for more info. 129 | 130 | To avoid unicode encode/decode errors, you should export the LANG or LC_ALL 131 | environment variables according to the configured locales on your system. If 132 | these variables are already set, make sure that they point to valid and 133 | installed locales. 134 | """, 135 | ) 136 | 137 | subparsers = action_parser.add_subparsers(title='Commands') 138 | 139 | apply_parser = subparsers.add_parser( 140 | 'apply', 141 | parents=[hash_parser], 142 | conflict_handler='resolve', 143 | help="apply a patch in the current directory using 'patch -p1'", 144 | ) 145 | apply_parser.set_defaults(subcmd='apply') 146 | 147 | git_am_parser = subparsers.add_parser( 148 | 'git-am', 149 | parents=[hash_parser], 150 | conflict_handler='resolve', 151 | help="apply a patch to current git branch using 'git am'", 152 | ) 153 | git_am_parser.add_argument( 154 | '-s', 155 | '--signoff', 156 | action='store_true', 157 | help="pass '--signoff' to 'git-am'", 158 | ) 159 | git_am_parser.add_argument( 160 | '-3', 161 | '--3way', 162 | action='store_true', 163 | dest='three_way', 164 | help="pass '--3way' to 'git-am'", 165 | ) 166 | git_am_parser.add_argument( 167 | '-m', 168 | '--msgid', 169 | action='store_true', 170 | dest='msg_id', 171 | help="pass '--message-id' to 'git-am'", 172 | ) 173 | git_am_parser.set_defaults(subcmd='git_am') 174 | 175 | get_parser = subparsers.add_parser( 176 | 'get', 177 | parents=[hash_parser], 178 | conflict_handler='resolve', 179 | help="download a patch and save it locally", 180 | ) 181 | get_parser.set_defaults(subcmd='get') 182 | 183 | info_parser = subparsers.add_parser( 184 | 'info', 185 | parents=[hash_parser], 186 | conflict_handler='resolve', 187 | help="show information for a given patch ID", 188 | ) 189 | info_parser.set_defaults(subcmd='info') 190 | 191 | projects_parser = subparsers.add_parser( 192 | 'projects', help="list all projects" 193 | ) 194 | projects_parser.set_defaults(subcmd='projects') 195 | 196 | check_get_parser = subparsers.add_parser( 197 | 'check-get', 198 | parents=[hash_parser], 199 | conflict_handler='resolve', 200 | help="get checks for a patch", 201 | ) 202 | check_get_parser.add_argument( 203 | '-f', 204 | '--format', 205 | metavar='FORMAT', 206 | help=( 207 | "print output in the given format. You can use tags matching " 208 | "fields, e.g. %%{context}, %%{state}, or %%{msgid}." 209 | ), 210 | ) 211 | check_get_parser.set_defaults(subcmd='check_get') 212 | 213 | check_list_parser = subparsers.add_parser( 214 | 'check-list', help="list all checks" 215 | ) 216 | # TODO: Make this non-optional in a future version 217 | check_list_parser.add_argument( 218 | 'patch_id', 219 | metavar='PATCH_ID', 220 | action='store', 221 | nargs='?', 222 | type=int, 223 | help="patch ID (required if using the REST API backend)", 224 | ) 225 | check_list_parser.add_argument( 226 | '-u', 227 | '--user', 228 | metavar='USER', 229 | action='store', 230 | help="user (name or ID) to filter checks by", 231 | ) 232 | check_list_parser.set_defaults(subcmd='check_list') 233 | 234 | check_info_parser = subparsers.add_parser( 235 | 'check-info', 236 | help="show information for a given check", 237 | ) 238 | # TODO: Make this non-optional in a future version 239 | check_info_parser.add_argument( 240 | 'patch_id', 241 | metavar='PATCH_ID', 242 | action='store', 243 | nargs='?', 244 | type=int, 245 | help="patch ID (required if using the REST API backend)", 246 | ) 247 | check_info_parser.add_argument( 248 | 'check_id', 249 | metavar='CHECK_ID', 250 | action='store', 251 | type=int, 252 | help="check ID", 253 | ) 254 | check_info_parser.set_defaults(subcmd='check_info') 255 | 256 | check_create_parser = subparsers.add_parser( 257 | 'check-create', 258 | parents=[hash_parser], 259 | conflict_handler='resolve', 260 | help="add a check to a patch", 261 | ) 262 | check_create_parser.add_argument('-c', '--context', metavar='CONTEXT') 263 | check_create_parser.add_argument( 264 | '-s', '--state', choices=('pending', 'success', 'warning', 'fail') 265 | ) 266 | check_create_parser.add_argument( 267 | '-u', '--target-url', metavar='TARGET_URL', default='' 268 | ) 269 | check_create_parser.add_argument( 270 | '-d', '--description', metavar='DESCRIPTION', default='' 271 | ) 272 | check_create_parser.set_defaults(subcmd='check_create') 273 | 274 | states_parser = subparsers.add_parser( 275 | 'states', help="show list of potential patch states" 276 | ) 277 | states_parser.set_defaults(subcmd='states') 278 | 279 | view_parser = subparsers.add_parser( 280 | 'view', 281 | parents=[hash_parser], 282 | conflict_handler='resolve', 283 | help="view a patch", 284 | ) 285 | view_parser.set_defaults(subcmd='view') 286 | 287 | update_parser = subparsers.add_parser( 288 | 'update', 289 | parents=[hash_parser], 290 | conflict_handler='resolve', 291 | help="update patch", 292 | epilog="using a COMMIT-REF allows for only one ID to be specified", 293 | ) 294 | update_parser.add_argument( 295 | '-c', 296 | '--commit-ref', 297 | metavar='COMMIT-REF', 298 | help="commit reference hash", 299 | ) 300 | update_parser.add_argument( 301 | '-s', 302 | '--state', 303 | metavar='STATE', 304 | help="set patch state (e.g., 'Accepted', 'Superseded' etc.)", 305 | ) 306 | update_parser.add_argument( 307 | '-a', 308 | '--archived', 309 | choices=['yes', 'no'], 310 | help="set patch archived state", 311 | ) 312 | update_parser.set_defaults(subcmd='update') 313 | 314 | list_parser = subparsers.add_parser( 315 | 'list', 316 | parents=[filter_parser], 317 | help='list patches using optional filters', 318 | ) 319 | list_parser.set_defaults(subcmd='list') 320 | 321 | # Poor man's argparse aliases: we register the "search" parser but 322 | # effectively use "list" for the help-text. 323 | search_parser = subparsers.add_parser( 324 | 'search', parents=[filter_parser], help="alias for 'list'" 325 | ) 326 | search_parser.set_defaults(subcmd='list') 327 | 328 | return action_parser 329 | -------------------------------------------------------------------------------- /pwclient/patches.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2018 Stephen Finucane 3 | # Copyright (C) 2008 Nate Case 4 | # 5 | # SPDX-License-Identifier: GPL-2.0-or-later 6 | 7 | import io 8 | import itertools 9 | import os 10 | import re 11 | import subprocess 12 | import sys 13 | 14 | 15 | def patch_id_from_hash(api, project, hash): 16 | patch = api.patch_get_by_project_hash(project, hash) 17 | 18 | if patch == {}: 19 | sys.stderr.write("No patch has the hash provided\n") 20 | sys.exit(1) 21 | 22 | patch_id = patch['id'] 23 | # be super paranoid 24 | try: 25 | patch_id = int(patch_id) 26 | except ValueError: 27 | sys.stderr.write("Invalid patch ID obtained from server\n") 28 | sys.exit(1) 29 | return patch_id 30 | 31 | 32 | def _list_patches(patches, format_str=None): 33 | """Dump a list of patches to stdout.""" 34 | if format_str: 35 | format_field_re = re.compile("%{([a-z0-9_]+)}") 36 | 37 | def patch_field(matchobj): 38 | fieldname = matchobj.group(1) 39 | 40 | if fieldname == "_msgid_": 41 | # naive way to strip < and > from message-id 42 | val = str(patch["msgid"]).strip("<>") 43 | else: 44 | val = str(patch[fieldname]) 45 | 46 | return val 47 | 48 | for patch in patches: 49 | print(format_field_re.sub(patch_field, format_str)) 50 | else: 51 | print("%-7s %-12s %s" % ("ID", "State", "Name")) 52 | print("%-7s %-12s %s" % ("--", "-----", "----")) 53 | for patch in patches: 54 | print( 55 | "%-7d %-12s %s" % (patch['id'], patch['state'], patch['name']) 56 | ) 57 | 58 | 59 | def action_list( 60 | api, 61 | project=None, 62 | submitter=None, 63 | delegate=None, 64 | state=None, 65 | archived=None, 66 | msgid=None, 67 | name=None, 68 | hash=None, 69 | max_count=None, 70 | format_str=None, 71 | ): 72 | # We exclude submitter and delegate since these are handled specially 73 | filters = { 74 | 'project': project, 75 | 'state': state, 76 | 'archived': archived, 77 | 'msgid': msgid, 78 | 'name': name, 79 | 'hash': hash, 80 | 'max_count': max_count, 81 | 'submitter': None, 82 | 'delegate': None, 83 | } 84 | 85 | # TODO(stephenfin): Remove these logs since they break our ability to 86 | # filter on both submitter and delegate 87 | 88 | if submitter is not None: 89 | filters['submitter'] = submitter 90 | 91 | patches = api.patch_list(**filters) 92 | patches.sort(key=lambda x: x['submitter']) 93 | 94 | for person, person_patches in itertools.groupby( 95 | patches, key=lambda x: x['submitter'] 96 | ): 97 | print(f'Patches submitted by {person}:') 98 | _list_patches(list(person_patches), format_str) 99 | 100 | return 101 | 102 | if delegate is not None: 103 | filters['delegate'] = delegate 104 | 105 | patches = api.patch_list(**filters) 106 | patches.sort(key=lambda x: x['delegate']) 107 | 108 | for delegate, delegate_patches in itertools.groupby( 109 | patches, key=lambda x: x['delegate'] 110 | ): 111 | print(f'Patches delegated to {delegate}:') 112 | _list_patches(list(delegate_patches), format_str) 113 | 114 | return 115 | 116 | patches = api.patch_list(**filters) 117 | _list_patches(patches, format_str) 118 | 119 | 120 | def action_info(api, patch_id): 121 | try: 122 | patch = api.patch_get(patch_id) 123 | except Exception as exc: 124 | print(str(exc), file=sys.stderr) 125 | sys.exit(1) 126 | 127 | s = "Information for patch id %d" % (patch_id) 128 | print(s) 129 | print('-' * len(s)) 130 | for key, value in sorted(patch.items()): 131 | if value != '': 132 | print("- %- 14s: %s" % (key, value)) 133 | else: 134 | print("- %- 14s:" % key) 135 | 136 | 137 | def action_get(api, patch_id): 138 | try: 139 | mbox, filename = api.patch_get_mbox(patch_id) 140 | except Exception as exc: 141 | print(str(exc), file=sys.stderr) 142 | sys.exit(1) 143 | 144 | base_fname = fname = os.path.basename(filename) 145 | fname += '.patch' 146 | i = 0 147 | while os.path.exists(fname): 148 | fname = "%s.%d.patch" % (base_fname, i) 149 | i += 1 150 | 151 | with io.open(fname, 'x', encoding='utf-8') as f: 152 | f.write(mbox) 153 | print('Saved patch to %s' % fname) 154 | 155 | 156 | def action_view(api, patch_ids): 157 | mboxes = [] 158 | 159 | for patch_id in patch_ids: 160 | try: 161 | mbox, _ = api.patch_get_mbox(patch_id) 162 | except Exception: 163 | # TODO(stephenfin): We skip this for historical reasons, but should 164 | # we log/raise an error? 165 | continue 166 | 167 | mboxes.append(mbox) 168 | 169 | if not mboxes: 170 | return 171 | 172 | pager = os.environ.get('PAGER') 173 | if pager: 174 | # TODO(stephenfin): Use as a context manager when we drop support for 175 | # Python 2.7 176 | pager = subprocess.Popen(pager.split(), stdin=subprocess.PIPE) 177 | try: 178 | pager.communicate(input='\n'.join(mboxes).encode('utf-8')) 179 | finally: 180 | if pager.stdout: 181 | pager.stdout.close() 182 | if pager.stderr: 183 | pager.stderr.close() 184 | if pager.stdin: 185 | pager.stdin.close() 186 | pager.wait() 187 | else: 188 | for mbox in mboxes: 189 | print(mbox) 190 | 191 | 192 | def action_apply(api, patch_id, apply_cmd=None): 193 | try: 194 | patch = api.patch_get(patch_id) 195 | except Exception as exc: 196 | print(str(exc), file=sys.stderr) 197 | sys.exit(1) 198 | 199 | if apply_cmd is None: 200 | print('Applying patch #%d to current directory' % patch_id) 201 | apply_cmd = ['patch', '-p1'] 202 | else: 203 | print( 204 | 'Applying patch #%d using "%s"' % (patch_id, ' '.join(apply_cmd)) 205 | ) 206 | 207 | print('Description: %s' % patch['name']) 208 | 209 | try: 210 | mbox, _ = api.patch_get_mbox(patch_id) 211 | except Exception as exc: 212 | print(str(exc), file=sys.stderr) 213 | sys.exit(1) 214 | 215 | proc = subprocess.Popen(apply_cmd, stdin=subprocess.PIPE) 216 | proc.communicate(mbox.encode('utf-8')) 217 | return proc.returncode 218 | 219 | 220 | def action_update(api, patch_id, state=None, archived=None, commit_ref=None): 221 | # ensure the patch actually exists first 222 | try: 223 | api.patch_get(patch_id) 224 | except Exception as exc: 225 | print(str(exc), file=sys.stderr) 226 | sys.exit(1) 227 | 228 | try: 229 | api.patch_set( 230 | patch_id, 231 | state=state, 232 | archived=archived, 233 | commit_ref=commit_ref, 234 | ) 235 | except Exception as exc: 236 | print(str(exc), file=sys.stderr) 237 | sys.exit(1) 238 | -------------------------------------------------------------------------------- /pwclient/projects.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2018 Stephen Finucane 3 | # Copyright (C) 2008 Nate Case 4 | # 5 | # SPDX-License-Identifier: GPL-2.0-or-later 6 | 7 | 8 | def action_list(api): 9 | projects = api.project_list("", 0) 10 | print("%-5s %-24s %s" % ("ID", "Name", "Description")) 11 | print("%-5s %-24s %s" % ("--", "----", "-----------")) 12 | for project in projects: 13 | print( 14 | "%-5d %-24s %s" 15 | % (project['id'], project['linkname'], project['name']) 16 | ) 17 | -------------------------------------------------------------------------------- /pwclient/shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Patchwork command line client 4 | # Copyright (C) 2008 Nate Case 5 | # 6 | # SPDX-License-Identifier: GPL-2.0-or-later 7 | 8 | import configparser 9 | import os 10 | import sys 11 | 12 | from . import api as pw_api 13 | from . import checks 14 | from . import exceptions 15 | from . import parser 16 | from . import patches 17 | from . import projects 18 | from . import states 19 | from . import utils 20 | 21 | CONFIG_FILE = os.environ.get('PWCLIENTRC', os.path.expanduser('~/.pwclientrc')) 22 | 23 | BACKEND_XMLRPC = 'xmlrpc' 24 | BACKEND_REST = 'rest' 25 | BACKENDS = (BACKEND_XMLRPC, BACKEND_REST) 26 | 27 | auth_actions = ['check_create', 'update'] 28 | 29 | 30 | def main(argv=sys.argv[1:]): 31 | action_parser = parser.get_parser() 32 | 33 | if not argv: 34 | action_parser.print_help() 35 | sys.exit(0) 36 | 37 | args = action_parser.parse_args(argv) 38 | 39 | action = args.subcmd 40 | 41 | if not os.path.exists(CONFIG_FILE): 42 | sys.stderr.write('Config file not found at %s.\n' % CONFIG_FILE) 43 | sys.exit(1) 44 | 45 | # grab settings from config files 46 | config = configparser.ConfigParser() 47 | config.read([CONFIG_FILE]) 48 | 49 | if config.has_section('base'): 50 | utils.migrate_old_config_file(CONFIG_FILE, config) 51 | sys.exit(1) 52 | 53 | if not config.has_section('options'): 54 | sys.stderr.write( 55 | 'No options section in %s. Did you forget to uncomment it?\n' 56 | % CONFIG_FILE 57 | ) 58 | sys.exit(1) 59 | 60 | if 'project' in args and args.project: 61 | project_str = args.project 62 | else: 63 | try: 64 | project_str = config.get('options', 'default') 65 | except ( 66 | configparser.NoSectionError, 67 | configparser.NoOptionError, 68 | ): 69 | sys.stderr.write( 70 | 'No default project configured in %s\n' % CONFIG_FILE 71 | ) 72 | sys.exit(1) 73 | 74 | if not config.has_section(project_str): 75 | sys.stderr.write( 76 | 'No section for project %s in %s\n' % (project_str, CONFIG_FILE) 77 | ) 78 | sys.exit(1) 79 | 80 | if not config.has_option(project_str, 'url'): 81 | sys.stderr.write( 82 | 'No URL for project %s in %s\n' % (project_str, CONFIG_FILE) 83 | ) 84 | sys.exit(1) 85 | 86 | backend = config.get(project_str, 'backend', fallback=None) 87 | if backend is not None and backend not in BACKENDS: 88 | sys.stderr.write( 89 | f"The 'backend' option is invalid. Expected one of: rest, xmlrpc; " 90 | f"got: {backend}\n" 91 | ) 92 | sys.exit(1) 93 | 94 | if backend is None: 95 | sys.stderr.write( 96 | f"The default backend will change from 'xmlrpc' to 'rest' in a " 97 | f"future version. You should explicitly set " 98 | f"'[{project_str}] backend' in pwclientrc to explicitly opt-in " 99 | f"to a specific backend\n" 100 | ) 101 | 102 | backend = backend or BACKEND_XMLRPC 103 | 104 | if action in auth_actions: 105 | if backend == 'rest': 106 | if not ( 107 | ( 108 | config.has_option(project_str, 'username') 109 | and config.has_option(project_str, 'password') 110 | ) 111 | or config.has_option(project_str, 'token') 112 | ): 113 | sys.stderr.write( 114 | "The %s action requires authentication, but no " 115 | "username/password or\n" 116 | "token is configured\n" % action 117 | ) 118 | sys.exit(1) 119 | else: 120 | if not ( 121 | config.has_option(project_str, 'username') 122 | and config.has_option(project_str, 'password') 123 | ): 124 | sys.stderr.write( 125 | "The %s action requires authentication, but no " 126 | "username or password\n" 127 | "is configured\n" % action 128 | ) 129 | sys.exit(1) 130 | 131 | url = config.get(project_str, 'url') 132 | 133 | kwargs = {} 134 | if action in auth_actions: 135 | if config.has_option(project_str, 'token'): 136 | kwargs['token'] = config.get(project_str, 'token') 137 | else: 138 | kwargs['username'] = config.get(project_str, 'username') 139 | kwargs['password'] = config.get(project_str, 'password') 140 | 141 | try: 142 | if backend == 'rest': 143 | api = pw_api.REST(url, **kwargs) 144 | else: 145 | api = pw_api.XMLRPC(url, **kwargs) 146 | except exceptions.APIError as exc: 147 | sys.stderr.write(str(exc)) 148 | sys.exit(1) 149 | 150 | patch_ids = args.id if 'id' in args and args.id else [] 151 | if 'use_hashes' in args and args.use_hashes: 152 | patch_ids = [ 153 | patches.patch_id_from_hash(api, project_str, x) for x in patch_ids 154 | ] 155 | else: 156 | try: 157 | patch_ids = [int(x) for x in patch_ids] 158 | except ValueError: 159 | sys.stderr.write('Patch IDs must be integers\n') 160 | sys.exit(1) 161 | 162 | if action == 'list' or action == 'search': 163 | patches.action_list( 164 | api, 165 | project=project_str, 166 | submitter=args.submitter, 167 | delegate=args.delegate, 168 | state=args.state, 169 | archived=args.archived, 170 | msgid=args.msgid, 171 | name=args.patch_name, 172 | hash=args.hash, 173 | max_count=args.max_count, 174 | format_str=args.format, 175 | ) 176 | 177 | elif action.startswith('project'): 178 | projects.action_list(api) 179 | 180 | elif action.startswith('state'): 181 | states.action_list(api) 182 | 183 | elif action == 'view': 184 | patches.action_view(api, patch_ids) 185 | 186 | elif action == 'info': 187 | for patch_id in patch_ids: 188 | patches.action_info(api, patch_id) 189 | 190 | elif action == 'get': 191 | for patch_id in patch_ids: 192 | patches.action_get(api, patch_id) 193 | 194 | elif action == 'apply': 195 | for patch_id in patch_ids: 196 | ret = patches.action_apply(api, patch_id) 197 | if ret: 198 | sys.stderr.write("Apply failed with exit status %d\n" % ret) 199 | sys.exit(1) 200 | 201 | elif action == 'git_am': 202 | cmd = ['git', 'am'] 203 | 204 | do_signoff = None 205 | if args.signoff: 206 | do_signoff = args.signoff 207 | elif config.has_option('options', 'signoff'): 208 | do_signoff = config.getboolean('options', 'signoff') 209 | elif config.has_option(project_str, 'signoff'): 210 | do_signoff = config.getboolean(project_str, 'signoff') 211 | 212 | if do_signoff: 213 | cmd.append('-s') 214 | 215 | do_three_way = None 216 | if args.three_way: 217 | do_three_way = args.three_way 218 | elif config.has_option('options', '3way'): 219 | do_three_way = config.getboolean('options', '3way') 220 | elif config.has_option(project_str, '3way'): 221 | do_three_way = config.getboolean(project_str, '3way') 222 | 223 | if do_three_way: 224 | cmd.append('-3') 225 | 226 | do_msg_id = None 227 | if args.msg_id: 228 | do_msg_id = args.msg_id 229 | elif config.has_option('options', 'msgid'): 230 | do_msg_id = config.getboolean('options', 'msgid') 231 | elif config.has_option(project_str, 'msgid'): 232 | do_msg_id = config.getboolean(project_str, 'msgid') 233 | 234 | if do_msg_id: 235 | cmd.append('-m') 236 | 237 | for patch_id in patch_ids: 238 | ret = patches.action_apply(api, patch_id, cmd) 239 | if ret: 240 | sys.stderr.write("'git am' failed with exit status %d\n" % ret) 241 | sys.exit(1) 242 | 243 | elif action == 'update': 244 | if args.commit_ref and len(patch_ids) > 1: 245 | # update multiple IDs with a single commit-hash does not make sense 246 | sys.stderr.write( 247 | 'Declining update with COMMIT-REF on multiple IDs\n' 248 | ) 249 | sys.exit(1) 250 | 251 | if not any([args.state, args.archived]): 252 | sys.stderr.write( 253 | 'Must specify one or more update options (-a or -s)\n' 254 | ) 255 | sys.exit(1) 256 | 257 | for patch_id in patch_ids: 258 | patches.action_update( 259 | api, 260 | patch_id, 261 | state=args.state, 262 | archived=args.archived, 263 | commit_ref=args.commit_ref, 264 | ) 265 | 266 | elif action == 'check_get': 267 | format_str = args.format 268 | for patch_id in patch_ids: 269 | checks.action_get(api, patch_id, format_str) 270 | 271 | elif action == 'check_list': 272 | checks.action_list(api, args.patch_id, args.user) 273 | 274 | elif action == 'check_info': 275 | patch_id = args.patch_id 276 | check_id = args.check_id 277 | checks.action_info(api, patch_id, check_id) 278 | 279 | elif action == 'check_create': 280 | for patch_id in patch_ids: 281 | checks.action_create( 282 | api, 283 | patch_id, 284 | args.context, 285 | args.state, 286 | args.target_url, 287 | args.description, 288 | ) 289 | 290 | 291 | if __name__ == "__main__": 292 | try: 293 | main() 294 | except (UnicodeEncodeError, UnicodeDecodeError): 295 | import traceback 296 | 297 | traceback.print_exc() 298 | sys.stderr.write( 299 | 'Try exporting the LANG or LC_ALL env vars. See ' 300 | 'pwclient --help for more details.\n' 301 | ) 302 | sys.exit(1) 303 | -------------------------------------------------------------------------------- /pwclient/states.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2018 Stephen Finucane 3 | # Copyright (C) 2008 Nate Case 4 | # 5 | # SPDX-License-Identifier: GPL-2.0-or-later 6 | 7 | 8 | def action_list(api): 9 | states = api.state_list("", 0) 10 | print("%-5s %s" % ("ID", "Name")) 11 | print("%-5s %s" % ("--", "----")) 12 | for state in states: 13 | print("%-5d %s" % (state['id'], state['name'])) 14 | -------------------------------------------------------------------------------- /pwclient/utils.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2018 Stephen Finucane 3 | # Copyright (C) 2008 Nate Case 4 | # 5 | # SPDX-License-Identifier: GPL-2.0-or-later 6 | 7 | import configparser 8 | import shutil 9 | import sys 10 | 11 | 12 | def migrate_old_config_file(config_file, config): 13 | """Convert a config file to the Patchwork 1.0 format.""" 14 | sys.stderr.write('%s is in the old format. Migrating it...' % config_file) 15 | 16 | old_project = config.get('base', 'project') 17 | 18 | new_config = configparser.ConfigParser() 19 | new_config.add_section('options') 20 | 21 | new_config.set('options', 'default', old_project) 22 | new_config.add_section(old_project) 23 | 24 | new_config.set(old_project, 'url', config.get('base', 'url')) 25 | if config.has_option('auth', 'username'): 26 | new_config.set(old_project, 'username', config.get('auth', 'username')) 27 | if config.has_option('auth', 'password'): 28 | new_config.set(old_project, 'password', config.get('auth', 'password')) 29 | 30 | old_config_file = config_file + '.orig' 31 | shutil.copy2(config_file, old_config_file) 32 | 33 | with open(config_file, 'w') as fd: 34 | new_config.write(fd) 35 | 36 | sys.stderr.write(' Done.\n') 37 | sys.stderr.write( 38 | 'Your old %s was saved to %s\n' % (config_file, old_config_file) 39 | ) 40 | sys.stderr.write('and was converted to the new format. You may want to\n') 41 | sys.stderr.write('inspect it before continuing.\n') 42 | 43 | 44 | if __name__ == '__main__': 45 | config = configparser.ConfigParser() 46 | config.read(sys.argv[1]) 47 | 48 | migrate_old_config_file(sys.argv[1], config) 49 | -------------------------------------------------------------------------------- /pwclient/xmlrpc.py: -------------------------------------------------------------------------------- 1 | # Patchwork command line client 2 | # Copyright (C) 2018 Stephen Finucane 3 | # Copyright (C) 2008 Nate Case 4 | # 5 | # SPDX-License-Identifier: GPL-2.0-or-later 6 | 7 | import os 8 | import xmlrpc.client as xmlrpclib 9 | 10 | 11 | class Transport(xmlrpclib.SafeTransport): 12 | def __init__(self, url): 13 | xmlrpclib.SafeTransport.__init__(self) 14 | self.credentials = None 15 | self.host = None 16 | self.proxy = None 17 | self.scheme = url.split('://', 1)[0] 18 | self.https = url.startswith('https') 19 | if self.https: 20 | self.proxy = os.environ.get('https_proxy') 21 | else: 22 | self.proxy = os.environ.get('http_proxy') 23 | if self.proxy: 24 | self.https = self.proxy.startswith('https') 25 | 26 | def set_credentials(self, username=None, password=None): 27 | self.credentials = '%s:%s' % (username, password) 28 | 29 | def make_connection(self, host): 30 | self.host = host 31 | if self.proxy: 32 | host = self.proxy.split('://', 1)[-1].rstrip('/') 33 | if self.credentials: 34 | host = '@'.join([self.credentials, host]) 35 | if self.https: 36 | return xmlrpclib.SafeTransport.make_connection(self, host) 37 | else: 38 | return xmlrpclib.Transport.make_connection(self, host) 39 | 40 | def send_request(self, host, handler, request_body, debug): 41 | handler = '%s://%s%s' % (self.scheme, host, handler) 42 | return xmlrpclib.Transport.send_request( 43 | self, 44 | host, 45 | handler, 46 | request_body, 47 | debug, 48 | ) 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | skip-string-normalization = true 4 | -------------------------------------------------------------------------------- /releasenotes/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | default_branch: main 3 | -------------------------------------------------------------------------------- /releasenotes/notes/add-long-opts-4611e7cce3993f08.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Most options now have a long opt equivalent. For example: 5 | 6 | $ pwclient update --archived yes 123 7 | -------------------------------------------------------------------------------- /releasenotes/notes/add-python3-10-drop-python-3-6-support-32b91d5753adfc29.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | Python 3.6 support has been removed. 5 | - | 6 | Python 3.10 support has been added. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/add-python3-11-support-eb86886925d2e5ec.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Python 3.10 support has been added. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/add-python3-13-support-57096ea159e1f6af.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Python 3.13 support has been added. 5 | upgrade: 6 | - | 7 | Python 3.8 is not longer supported. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/check-get-4f010b2c4fdcd55c.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Add a new ``pwclient check-get`` command to query the checks run against 5 | a specific patch. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/check-list-create-help-94ccb51660af1138.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | The ``check-list`` and ``check-create`` commands now accept a ``-h`` (help) 5 | paramter. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/check-patch-id-82f673f7c520ca24.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | The ``check-list`` command now accepts a ``PATCH_ID`` positional 5 | argument and ``--user USER`` option to allow users to filter checks by 6 | patch and creator respectively. The ``PATCH_ID`` argument is currently 7 | optional for the XML-RPC API backend but is required for the REST API 8 | backend. 9 | - | 10 | The ``check-info`` command now takes two positional arguments, ``PATCH_ID`` 11 | and ``CHECK_ID``. Previously, it only took ``CHECK_ID``. The ``PATCH_ID`` 12 | argument remains optional for the XML-RPC API backend but is required for 13 | the REST API backend. 14 | -------------------------------------------------------------------------------- /releasenotes/notes/drop-pypy-support-17f1f95b9394b257.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | PyPy is no longer officially supported. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/drop-python2-support-0351245b41052e20.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | Support for Python 2.7 and 3.5 has been dropped. The minimum Python version 5 | now supported in Python 3.6. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/git-am--m-flag-190f3a7e17cec6f4.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | The ``pwclient git-am`` command can now passthrough the ``-m`` flag to 5 | ``-m``. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/initial-release-eb74a7ae0ce3b1fb.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | prelude: > 3 | Initial release of *pwclient* package. 4 | -------------------------------------------------------------------------------- /releasenotes/notes/issue-1-c7e4c3e4e57c1c22.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | The ``pwclient view`` command will now decode received mboxes on Python 5 | 2.7. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/list--hash-option-ebb96d3a70920cf5.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | The ``pwclient list`` command now accepts a ``--hash`` option to list 5 | patches by hash. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/pwclientrc-environment-variable-e070047b82e3b77f.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | It is now possible to set the path of the ``pwclientrc`` file using the 5 | ``PWCLIENRC`` environment variable. This can be useful for automation. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/rest-api-support-4341d2884f8d41c8.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | ``pwclient`` now provides experimental support for the REST API first 5 | introduced in Patchwork 2.0. This can be enabled by adding the 6 | ``backend = rest`` setting to your ``pwclientrc`` file. For example: 7 | 8 | .. code-block:: ini 9 | 10 | [options] 11 | default = patchwork 12 | 13 | [patchwork] 14 | backend = rest 15 | url = https://patchwork.ozlabs.org/api/ 16 | token = 088cade25e52482e6486794ef4a4561d3e5fe727 17 | 18 | There are likely bugs and features gaps in this implementation. Report 19 | any bugs to either `the mailing list`__ or `GitHub issue tracker`__. 20 | 21 | .. __: mailto:patchwork@ozlabs.org 22 | .. __: https://github.com/getpatchwork/pwclient/issues 23 | -------------------------------------------------------------------------------- /releasenotes/notes/version-2-2-0d142cb0ab85eb67.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | prelude: > 3 | *pwclient* version 2.2 introduces preliminary support for the Patchwork 4 | REST API, first introduced in Patchwork 2.0. We encourage users to test 5 | this functionality by setting the new ``[$project] backend`` setting to 6 | ``rest``. Please report any bugs encountered. More details are provided 7 | below and in the documentation. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/version-2-3-fd18e538b15396d8.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | prelude: > 3 | *pwclient* version 2.3.0 fixes a number of bugs introduced in 2.2.0 as 4 | part of the Patchwork REST API feature. 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pwclient 3 | summary = The command-line client for the Patchwork patch tracking tool 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst; charset=UTF-8 6 | license = GPL v2 7 | license_files = 8 | COPYING 9 | classifiers = 10 | Development Status :: 5 - Production/Stable 11 | Environment :: Console 12 | Intended Audience :: Developers 13 | Intended Audience :: Information Technology 14 | Programming Language :: Python :: 3 15 | Programming Language :: Python :: 3 :: Only 16 | Programming Language :: Python 17 | License :: OSI Approved :: GNU General Public License v2 (GPLv2) 18 | Operating System :: OS Independent 19 | keywords = patchwork api 20 | author = Nate Case 21 | author_email = ncase@xes-inc.com 22 | maintainer = Stephen Finucane 23 | maintainer_email = stephen@that.guru 24 | url = https://github.com/getpatchwork/pwclient 25 | project_urls = 26 | Bug Tracker = https://github.com/getpatchwork/pwclient/issues 27 | Source Code = https://github.com/getpatchwork/pwclient 28 | Documentation = https://pwclient.readthedocs.io 29 | python_requires = >=3.9 30 | 31 | [files] 32 | packages = 33 | pwclient 34 | 35 | [entry_points] 36 | console_scripts = 37 | pwclient = pwclient.shell:main 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | setup_requires=['pbr'], 7 | pbr=True, 8 | ) 9 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getpatchwork/pwclient/cca48a5f3864fbed6c80306a2915ad688dd0426d/tests/__init__.py -------------------------------------------------------------------------------- /tests/fakes.py: -------------------------------------------------------------------------------- 1 | """Generate fake data from the XML-RPC API.""" 2 | 3 | 4 | def fake_patches(): 5 | return [ 6 | { 7 | 'id': 1157169, 8 | 'date': '2000-12-31 00:11:22', 9 | 'filename': '1-3--Drop-support-for-Python-3-4--add-Python-3-7', 10 | 'msgid': '<20190903170304.24325-1-stephen@that.guru>', 11 | 'name': '[1/3] Drop support for Python 3.4, add Python 3.7', 12 | 'project': 'my-project', 13 | 'project_id': 1, 14 | 'state': 'New', 15 | 'state_id': 1, 16 | 'archived': False, 17 | 'submitter': 'Joe Bloggs ', 18 | 'submitter_id': 1, 19 | 'delegate': 'admin', 20 | 'delegate_id': 1, 21 | 'commit_ref': '', 22 | 'hash': '', 23 | }, 24 | { 25 | 'id': 1157170, 26 | 'date': '2000-12-31 00:11:22', 27 | 'filename': '2-3--docker--Simplify-MySQL-reset', 28 | 'msgid': '<20190903170304.24325-2-stephen@that.guru>', 29 | 'name': '[2/3] docker: Simplify MySQL reset', 30 | 'project': 'my-project', 31 | 'project_id': 1, 32 | 'state': 'Accepted', 33 | 'state_id': 3, 34 | 'archived': False, 35 | 'submitter': 'Joe Bloggs ', 36 | 'submitter_id': 1, 37 | 'delegate': 'admin', 38 | 'delegate_id': 1, 39 | 'commit_ref': '', 40 | 'hash': '', 41 | }, 42 | { 43 | 'id': 1157168, 44 | 'date': '2000-12-31 00:11:22', 45 | 'filename': '3-3--docker--Use-pyenv-for-Python-versions', 46 | 'msgid': '<20190903170304.24325-3-stephen@that.guru>', 47 | 'name': '[3/3] docker: Use pyenv for Python versions', 48 | 'project': 'my-project', 49 | 'project_id': 1, 50 | 'state': 'Rejected', 51 | 'state_id': 4, 52 | 'archived': True, 53 | 'submitter': 'Joe Bloggs ', 54 | 'submitter_id': 1, 55 | 'delegate': 'admin', 56 | 'delegate_id': 1, 57 | 'commit_ref': '', 58 | 'hash': '', 59 | }, 60 | ] 61 | 62 | 63 | def fake_people(): 64 | return [ 65 | { 66 | 'id': 1, 67 | 'name': 'Jeremy Kerr', 68 | 'email': 'jk@ozlabs.org', 69 | }, 70 | { 71 | 'id': 4, 72 | 'name': 'Michael Ellerman', 73 | 'email': 'michael@ellerman.id.au', 74 | }, 75 | { 76 | 'id': 5, 77 | 'name': 'Kumar Gala', 78 | 'email': 'galak@example.com', 79 | }, 80 | ] 81 | 82 | 83 | def fake_projects(): 84 | return [ 85 | { 86 | 'id': 1, 87 | 'name': 'Patchwork', 88 | 'linkname': 'patchwork', 89 | 'listid': 'patchwork.lists.ozlabs.org', 90 | 'listemail': 'patchwork@lists.ozlabs.org', 91 | }, 92 | ] 93 | 94 | 95 | def fake_checks(): 96 | return [ 97 | { 98 | 'id': 1, 99 | 'patch': 'A sample patch', 100 | 'patch_id': 1, 101 | 'user': 'Joe Bloggs', 102 | 'user_id': 1, 103 | 'state': 'success', 104 | 'target_url': 'https://example.com/', 105 | 'context': 'hello-world', 106 | }, 107 | ] 108 | 109 | 110 | def fake_states(): 111 | return [ 112 | { 113 | 'id': 1, 114 | 'name': 'New', 115 | } 116 | ] 117 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pwclient import api 4 | from pwclient import exceptions 5 | 6 | 7 | def test_xmlrpc_init__missing_username(): 8 | with pytest.raises(exceptions.ConfigError) as exc: 9 | api.XMLRPC('https://example.com/xmlrpc', username='user') 10 | 11 | assert 'You must provide both a username and a password ' in str(exc.value) 12 | 13 | 14 | def test_xmlrpc_init__invalid_auth(): 15 | """The XML-RPC API doesn't support tokens.""" 16 | with pytest.raises(exceptions.ConfigError) as exc: 17 | api.XMLRPC('https://example.com/xmlrpc', token='foo') 18 | 19 | assert 'The XML-RPC API does not support API tokens' in str(exc.value) 20 | 21 | 22 | def test_rest_init__strip_trailing_slash(): 23 | """Ensure we strip the trailing slash.""" 24 | client = api.REST('https://patchwork.kernel.org/api/') 25 | assert 'https://patchwork.kernel.org/api' == client._server 26 | 27 | 28 | def test_rest_init__transform_legacy_url(capsys): 29 | """Ensure we handle legacy XML-RPC URLs.""" 30 | client = api.REST('https://patchwork.kernel.org/xmlrpc/') 31 | assert 'https://patchwork.kernel.org/api' == client._server 32 | 33 | captured = capsys.readouterr() 34 | 35 | assert ( 36 | 'Automatically converted XML-RPC URL to REST API URL.' in captured.err 37 | ) 38 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pwclient import checks 4 | from pwclient import exceptions 5 | 6 | from . import fakes 7 | 8 | 9 | def test_action_check_get(capsys): 10 | rpc = mock.Mock() 11 | rpc.check_list.return_value = fakes.fake_checks() 12 | 13 | checks.action_get(rpc, 1) 14 | 15 | captured = capsys.readouterr() 16 | 17 | assert ( 18 | captured.out 19 | == """\ 20 | Check information for patch id 1 21 | -------------------------------- 22 | - context : hello-world 23 | - id : 1 24 | - patch : A sample patch 25 | - patch_id : 1 26 | - state : success 27 | - target_url : https://example.com/ 28 | - user : Joe Bloggs 29 | - user_id : 1 30 | """ 31 | ) 32 | 33 | 34 | def test_action_check_list(capsys): 35 | rpc = mock.Mock() 36 | rpc.check_list.return_value = fakes.fake_checks() 37 | 38 | checks.action_list(rpc) 39 | 40 | captured = capsys.readouterr() 41 | 42 | assert ( 43 | captured.out 44 | == """\ 45 | ID Context State Patch 46 | -- ------- ----- ----- 47 | 1 hello-world success A sample patch 48 | """ 49 | ) 50 | 51 | 52 | def test_action_check_info(capsys): 53 | fake_check = fakes.fake_checks()[0] 54 | 55 | rpc = mock.Mock() 56 | rpc.check_get.return_value = fake_check 57 | 58 | checks.action_info(rpc, 1, 1) 59 | 60 | captured = capsys.readouterr() 61 | 62 | assert ( 63 | captured.out 64 | == """\ 65 | Information for check id 1 66 | -------------------------- 67 | - context : hello-world 68 | - id : 1 69 | - patch : A sample patch 70 | - patch_id : 1 71 | - state : success 72 | - target_url : https://example.com/ 73 | - user : Joe Bloggs 74 | - user_id : 1 75 | """ 76 | ) 77 | 78 | 79 | def test_action_check_create(): 80 | rpc = mock.Mock() 81 | 82 | args = ( 83 | 1, 84 | 'hello-world', 85 | 'success', 86 | 'https://example.com', 87 | 'This is a sample check', 88 | ) 89 | 90 | checks.action_create(rpc, *args) 91 | 92 | rpc.check_create.assert_called_once_with(*args) 93 | 94 | 95 | def test_action_check_create__error(capsys): 96 | rpc = mock.Mock() 97 | rpc.check_create.side_effect = exceptions.APIError('whoops') 98 | 99 | args = ( 100 | 1, 101 | 'hello-world', 102 | 'success', 103 | 'https://example.com', 104 | 'This is a sample check', 105 | ) 106 | 107 | checks.action_create(rpc, *args) 108 | 109 | captured = capsys.readouterr() 110 | 111 | assert captured.err == 'whoops' 112 | -------------------------------------------------------------------------------- /tests/test_patches.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from pwclient import exceptions 6 | from pwclient import patches 7 | 8 | from . import fakes 9 | 10 | FAKE_PROJECT = 'defaultproject' 11 | FAKE_PROJECT_ID = 42 12 | 13 | 14 | def test_patch_id_from_hash__no_matches(capsys): 15 | api = mock.Mock() 16 | api.patch_get_by_project_hash.return_value = {} 17 | 18 | with pytest.raises(SystemExit): 19 | patches.patch_id_from_hash(api, 'foo', '698fa7f') 20 | 21 | captured = capsys.readouterr() 22 | 23 | assert 'No patch has the hash provided' in captured.err 24 | assert captured.out == '' 25 | 26 | 27 | def test_patch_id_from_hash__invalid_id(capsys): 28 | api = mock.Mock() 29 | api.patch_get_by_project_hash.return_value = {'id': 'xyz'} 30 | 31 | with pytest.raises(SystemExit): 32 | patches.patch_id_from_hash(api, 'foo', '698fa7f') 33 | 34 | captured = capsys.readouterr() 35 | 36 | assert 'Invalid patch ID obtained from server' in captured.err 37 | assert captured.out == '' 38 | 39 | 40 | def test_patch_id_from_hash(): 41 | api = mock.Mock() 42 | api.patch_get_by_project_hash.return_value = {'id': '1'} 43 | 44 | result = patches.patch_id_from_hash(api, 'foo', '698fa7f') 45 | 46 | assert result == 1 47 | api.patch_get_by_project_hash.assert_called_once_with('foo', '698fa7f') 48 | api.patch_get_by_hash.assert_not_called() 49 | 50 | 51 | def test_list_patches(capsys): 52 | fake_patches = fakes.fake_patches() 53 | 54 | patches._list_patches(fake_patches) 55 | 56 | captured = capsys.readouterr() 57 | 58 | assert ( 59 | captured.out 60 | == """\ 61 | ID State Name 62 | -- ----- ---- 63 | 1157169 New [1/3] Drop support for Python 3.4, add Python 3.7 64 | 1157170 Accepted [2/3] docker: Simplify MySQL reset 65 | 1157168 Rejected [3/3] docker: Use pyenv for Python versions 66 | """ 67 | ) 68 | 69 | 70 | def test_list_patches__format_option(capsys): 71 | fake_patches = fakes.fake_patches() 72 | 73 | patches._list_patches(fake_patches, '%{state}') 74 | 75 | captured = capsys.readouterr() 76 | 77 | assert ( 78 | captured.out 79 | == """\ 80 | New 81 | Accepted 82 | Rejected 83 | """ 84 | ) 85 | 86 | 87 | def test_list_patches__format_option_with_msgid(capsys): 88 | fake_patches = fakes.fake_patches() 89 | 90 | patches._list_patches(fake_patches, '%{_msgid_}') 91 | 92 | captured = capsys.readouterr() 93 | 94 | assert ( 95 | captured.out 96 | == """\ 97 | 20190903170304.24325-1-stephen@that.guru 98 | 20190903170304.24325-2-stephen@that.guru 99 | 20190903170304.24325-3-stephen@that.guru 100 | """ 101 | ) 102 | 103 | 104 | @mock.patch.object(patches, '_list_patches') 105 | def test_action_list__no_submitter_no_delegate(mock_list_patches, capsys): 106 | api = mock.Mock() 107 | 108 | patches.action_list(api, FAKE_PROJECT) 109 | 110 | api.patch_list.assert_called_once_with( 111 | project='defaultproject', 112 | submitter=None, 113 | delegate=None, 114 | state=None, 115 | archived=None, 116 | msgid=None, 117 | name=None, 118 | hash=None, 119 | max_count=None, 120 | ) 121 | mock_list_patches.assert_called_once_with( 122 | api.patch_list.return_value, 123 | None, 124 | ) 125 | 126 | 127 | @mock.patch.object(patches, '_list_patches') 128 | def test_action_list__submitter_filter(mock_list_patches, capsys): 129 | api = mock.Mock() 130 | api.patch_list.return_value = fakes.fake_patches() 131 | 132 | patches.action_list(api, FAKE_PROJECT, submitter='Joe Bloggs') 133 | 134 | captured = capsys.readouterr() 135 | 136 | assert ( 137 | 'Patches submitted by Joe Bloggs :' 138 | in captured.out 139 | ) # noqa: E501 140 | 141 | api.patch_list.assert_called_once_with( 142 | project='defaultproject', 143 | submitter='Joe Bloggs', 144 | delegate=None, 145 | state=None, 146 | archived=None, 147 | msgid=None, 148 | name=None, 149 | hash=None, 150 | max_count=None, 151 | ) 152 | mock_list_patches.assert_called_once_with( 153 | api.patch_list.return_value, 154 | None, 155 | ) 156 | 157 | 158 | @mock.patch.object(patches, '_list_patches') 159 | def test_action_list__delegate_filter(mock_list_patches, capsys): 160 | api = mock.Mock() 161 | api.patch_list.return_value = fakes.fake_patches() 162 | 163 | patches.action_list(api, FAKE_PROJECT, delegate='admin') 164 | 165 | captured = capsys.readouterr() 166 | 167 | assert 'Patches delegated to admin:' in captured.out 168 | 169 | api.patch_list.assert_called_once_with( 170 | project='defaultproject', 171 | submitter=None, 172 | delegate='admin', 173 | state=None, 174 | archived=None, 175 | msgid=None, 176 | name=None, 177 | hash=None, 178 | max_count=None, 179 | ) 180 | mock_list_patches.assert_called_once_with( 181 | api.patch_list.return_value, 182 | None, 183 | ) 184 | 185 | 186 | def test_action_info(capsys): 187 | api = mock.Mock() 188 | api.patch_get.return_value = fakes.fake_patches()[0] 189 | 190 | patches.action_info(api, 1157169) 191 | 192 | captured = capsys.readouterr() 193 | 194 | assert ( 195 | captured.out 196 | == """\ 197 | Information for patch id 1157169 198 | -------------------------------- 199 | - archived : False 200 | - commit_ref : 201 | - date : 2000-12-31 00:11:22 202 | - delegate : admin 203 | - delegate_id : 1 204 | - filename : 1-3--Drop-support-for-Python-3-4--add-Python-3-7 205 | - hash : 206 | - id : 1157169 207 | - msgid : <20190903170304.24325-1-stephen@that.guru> 208 | - name : [1/3] Drop support for Python 3.4, add Python 3.7 209 | - project : my-project 210 | - project_id : 1 211 | - state : New 212 | - state_id : 1 213 | - submitter : Joe Bloggs 214 | - submitter_id : 1 215 | """ 216 | ) 217 | 218 | 219 | def test_action_info__invalid_id(capsys): 220 | api = mock.Mock() 221 | api.patch_get.side_effect = exceptions.APIError('foo') 222 | 223 | with pytest.raises(SystemExit): 224 | patches.action_info(api, 1) 225 | 226 | captured = capsys.readouterr() 227 | 228 | assert captured.out == '' 229 | assert captured.err == 'foo\n' 230 | 231 | 232 | @mock.patch.object(patches.io, 'open') 233 | @mock.patch.object(patches.os.path, 'basename') 234 | @mock.patch.object(patches.os.path, 'exists') 235 | def test_action_get(mock_exists, mock_basename, mock_open, capsys): 236 | api = mock.Mock() 237 | api.patch_get_mbox.return_value = ( 238 | 'foo', 239 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7', 240 | ) 241 | mock_exists.side_effect = [True, False] 242 | mock_basename.return_value = api.patch_get_mbox.return_value[1] 243 | 244 | patches.action_get(api, 1157169) 245 | 246 | captured = capsys.readouterr() 247 | 248 | mock_basename.assert_called_once_with( 249 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7' 250 | ) 251 | mock_exists.assert_has_calls( 252 | [ 253 | mock.call( 254 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7.patch' 255 | ), 256 | mock.call( 257 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7.0.patch' 258 | ), 259 | ] 260 | ) 261 | mock_open.assert_called_once_with( 262 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7.0.patch', 263 | 'x', 264 | encoding='utf-8', 265 | ) 266 | 267 | assert ( 268 | captured.out 269 | == """\ 270 | Saved patch to 1-3--Drop-support-for-Python-3-4--add-Python-3-7.0.patch 271 | """ 272 | ) 273 | 274 | 275 | def test_action_get__invalid_id(capsys): 276 | api = mock.Mock() 277 | api.patch_get_mbox.side_effect = exceptions.APIError('foo') 278 | 279 | with pytest.raises(SystemExit): 280 | patches.action_get(api, 1) 281 | 282 | captured = capsys.readouterr() 283 | 284 | assert captured.out == '' 285 | assert captured.err == 'foo\n' 286 | 287 | 288 | @mock.patch.object(patches.os.environ, 'get') 289 | @mock.patch.object(patches.subprocess, 'Popen') 290 | def test_action_view__no_pager(mock_popen, mock_env, capsys): 291 | api = mock.Mock() 292 | api.patch_get_mbox.return_value = ( 293 | 'foo', 294 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7', 295 | ) 296 | mock_env.return_value = None 297 | 298 | patches.action_view(api, [1]) 299 | 300 | mock_popen.assert_not_called() 301 | api.patch_get_mbox.assert_called_once_with(1) 302 | 303 | captured = capsys.readouterr() 304 | 305 | assert captured.out == 'foo\n' 306 | 307 | 308 | @mock.patch.object(patches.os.environ, 'get') 309 | @mock.patch.object(patches.subprocess, 'Popen') 310 | def test_action_view__no_pager_multiple_patches(mock_popen, mock_env, capsys): 311 | api = mock.Mock() 312 | api.patch_get_mbox.side_effect = [ 313 | ( 314 | 'foo', 315 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7', 316 | ), 317 | ( 318 | 'bar', 319 | '2-3-docker-Simplify-MySQL-reset', 320 | ), 321 | ( 322 | 'baz', 323 | '3-3-docker-Use-pyenv-for-Python-versions', 324 | ), 325 | ] 326 | mock_env.return_value = None 327 | 328 | patches.action_view(api, [1, 2, 3]) 329 | 330 | captured = capsys.readouterr() 331 | 332 | mock_popen.assert_not_called() 333 | assert captured.out == 'foo\nbar\nbaz\n' 334 | 335 | 336 | @mock.patch.object(patches.os.environ, 'get') 337 | @mock.patch.object(patches.subprocess, 'Popen') 338 | def test_view__with_pager(mock_popen, mock_env, capsys): 339 | api = mock.Mock() 340 | api.patch_get_mbox.return_value = ( 341 | 'foo', 342 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7', 343 | ) 344 | mock_env.return_value = 'less' 345 | 346 | patches.action_view(api, [1]) 347 | 348 | mock_popen.assert_called_once_with(['less'], stdin=mock.ANY) 349 | mock_popen.return_value.communicate.assert_has_calls( 350 | [ 351 | mock.call(input=b'foo'), 352 | ] 353 | ) 354 | 355 | captured = capsys.readouterr() 356 | assert captured.out == '' 357 | 358 | 359 | @mock.patch.object(patches.os.environ, 'get') 360 | @mock.patch.object(patches.subprocess, 'Popen') 361 | def test_view__with_pager_multiple_ids(mock_popen, mock_env, capsys): 362 | api = mock.Mock() 363 | api.patch_get_mbox.side_effect = [ 364 | ( 365 | 'foo', 366 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7', 367 | ), 368 | ( 369 | 'bar', 370 | '2-3-docker-Simplify-MySQL-reset', 371 | ), 372 | ( 373 | 'baz', 374 | '3-3-docker-Use-pyenv-for-Python-versions', 375 | ), 376 | ] 377 | mock_env.return_value = 'less' 378 | 379 | patches.action_view(api, [1, 2, 3]) 380 | 381 | mock_popen.assert_called_once_with(['less'], stdin=mock.ANY) 382 | mock_popen.return_value.communicate.assert_has_calls( 383 | [ 384 | mock.call(input=b'foo\nbar\nbaz'), 385 | ] 386 | ) 387 | 388 | captured = capsys.readouterr() 389 | assert captured.out == '' 390 | 391 | 392 | @mock.patch.object(patches.subprocess, 'Popen') 393 | def _test_action_apply(apply_cmd, mock_popen): 394 | api = mock.Mock() 395 | api.patch_get.return_value = fakes.fake_patches()[0] 396 | api.patch_get_mbox.return_value = ( 397 | 'foo', 398 | '1-3--Drop-support-for-Python-3-4--add-Python-3-7', 399 | ) 400 | 401 | args = [api, 1157169] 402 | if apply_cmd: 403 | args.append(apply_cmd) 404 | 405 | result = patches.action_apply(*args) 406 | 407 | if not apply_cmd: 408 | apply_cmd = ['patch', '-p1'] 409 | 410 | mock_popen.assert_called_once_with( 411 | apply_cmd, stdin=patches.subprocess.PIPE 412 | ) 413 | mock_popen.return_value.communicate.assert_called_once_with(b'foo') 414 | assert result == mock_popen.return_value.returncode 415 | 416 | 417 | def test_action_apply(capsys): 418 | _test_action_apply(None) 419 | 420 | captured = capsys.readouterr() 421 | 422 | assert ( 423 | captured.out 424 | == """\ 425 | Applying patch #1157169 to current directory 426 | Description: [1/3] Drop support for Python 3.4, add Python 3.7 427 | """ 428 | ) 429 | assert captured.err == '' 430 | 431 | 432 | def test_action_apply__with_apply_cmd(capsys): 433 | _test_action_apply(['git-am', '-3']) 434 | 435 | captured = capsys.readouterr() 436 | 437 | assert ( 438 | captured.out 439 | == """\ 440 | Applying patch #1157169 using "git-am -3" 441 | Description: [1/3] Drop support for Python 3.4, add Python 3.7 442 | """ 443 | ) 444 | assert captured.err == '' 445 | 446 | 447 | @mock.patch.object(patches.subprocess, 'Popen') 448 | def test_action_apply__failed(mock_popen, capsys): 449 | api = mock.Mock() 450 | api.patch_get.return_value = fakes.fake_patches()[0] 451 | api.patch_get_mbox.side_effect = exceptions.APIError('foo') 452 | 453 | with pytest.raises(SystemExit): 454 | patches.action_apply(api, 1) 455 | 456 | captured = capsys.readouterr() 457 | 458 | assert ( 459 | captured.out 460 | == """\ 461 | Applying patch #1 to current directory 462 | Description: [1/3] Drop support for Python 3.4, add Python 3.7 463 | """ 464 | ) 465 | assert captured.err == 'foo\n' 466 | 467 | mock_popen.assert_not_called() 468 | 469 | 470 | def test_action_apply__invalid_id(capsys): 471 | api = mock.Mock() 472 | api.patch_get.side_effect = exceptions.APIError('foo') 473 | 474 | with pytest.raises(SystemExit): 475 | patches.action_apply(api, 1) 476 | 477 | captured = capsys.readouterr() 478 | 479 | assert captured.out == '' 480 | assert captured.err == 'foo\n' 481 | 482 | 483 | def test_action_update__invalid_id(capsys): 484 | api = mock.Mock() 485 | api.patch_get.side_effect = exceptions.APIError('foo') 486 | 487 | with pytest.raises(SystemExit): 488 | patches.action_update(api, 1) 489 | 490 | captured = capsys.readouterr() 491 | 492 | assert captured.out == '' 493 | assert captured.err == 'foo\n' 494 | 495 | 496 | def test_action_update(capsys): 497 | api = mock.Mock() 498 | api.patch_get.return_value = fakes.fake_patches()[0] 499 | api.patch_set.return_value = True 500 | 501 | patches.action_update(api, 1157169, 'Accepted', 'yes', '698fa7f') 502 | 503 | api.patch_set.assert_called_once_with( 504 | 1157169, 505 | state='Accepted', 506 | archived='yes', 507 | commit_ref='698fa7f', 508 | ) 509 | 510 | 511 | def test_action_update__error(capsys): 512 | api = mock.Mock() 513 | api.patch_get.return_value = fakes.fake_patches()[0] 514 | api.patch_set.side_effect = exceptions.APIError('foo') 515 | 516 | with pytest.raises(SystemExit): 517 | patches.action_update(api, 1157169) 518 | 519 | api.patch_set.assert_called_once_with( 520 | 1157169, archived=None, commit_ref=None, state=None 521 | ) 522 | 523 | captured = capsys.readouterr() 524 | 525 | assert captured.out == '' 526 | assert ( 527 | captured.err 528 | == """\ 529 | foo 530 | """ 531 | ) 532 | -------------------------------------------------------------------------------- /tests/test_projects.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pwclient import projects 4 | 5 | from . import fakes 6 | 7 | 8 | def test_action_list(capsys): 9 | rpc = mock.Mock() 10 | rpc.project_list.return_value = fakes.fake_projects() 11 | 12 | projects.action_list(rpc) 13 | 14 | captured = capsys.readouterr() 15 | 16 | assert ( 17 | captured.out 18 | == """\ 19 | ID Name Description 20 | -- ---- ----------- 21 | 1 patchwork Patchwork 22 | """ 23 | ) 24 | -------------------------------------------------------------------------------- /tests/test_shell.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from pwclient import api 6 | from pwclient import checks 7 | from pwclient import exceptions 8 | from pwclient import patches 9 | from pwclient import projects 10 | from pwclient import shell 11 | from pwclient import states 12 | from pwclient import utils 13 | 14 | DEFAULT_PROJECT = 'defaultproject' 15 | _UNSET = object() 16 | 17 | 18 | class FakeConfig(object): 19 | def __init__(self, updates=None): 20 | self._data = { 21 | 'options': { 22 | 'default': DEFAULT_PROJECT, 23 | }, 24 | DEFAULT_PROJECT: { 25 | 'url': 'https://example.com/xmlrpc', 26 | 'backend': 'xmlrpc', 27 | }, 28 | } 29 | 30 | # merge updates into defaults 31 | for section in updates or {}: 32 | if section not in self._data: 33 | self._data[section] = {} 34 | 35 | for option in updates[section]: 36 | self._data[section][option] = updates[section][option] 37 | 38 | def write(self, fd): 39 | pass 40 | 41 | def read(self, files): 42 | pass 43 | 44 | def add_section(self, section): 45 | self._data[section] = {} 46 | 47 | def has_section(self, section): 48 | return section in self._data 49 | 50 | def has_option(self, section, option): 51 | return self.has_section(section) and option in self._data[section] 52 | 53 | def set(self, section, option, value): 54 | if section not in self._data: 55 | raise utils.configparser.NoSectionError(section) 56 | 57 | self._data[section][option] = value 58 | 59 | def get(self, section, option, *, fallback=_UNSET): 60 | if section not in self._data: 61 | raise utils.configparser.NoSectionError(section) 62 | 63 | if option not in self._data[section] and fallback is _UNSET: 64 | raise utils.configparser.NoOptionError(option, section) 65 | 66 | return self._data[section].get(option) or fallback 67 | 68 | def getboolean(self, section, option): 69 | return self.get(section, option) 70 | 71 | 72 | def test_no_args(capsys): 73 | with pytest.raises(SystemExit): 74 | shell.main([]) 75 | 76 | captured = capsys.readouterr() 77 | 78 | assert 'usage: pwclient [-h]' in captured.out 79 | assert captured.err == '' 80 | 81 | 82 | def test_help(capsys): 83 | with pytest.raises(SystemExit): 84 | shell.main(['-h']) 85 | 86 | captured = capsys.readouterr() 87 | 88 | assert 'usage: pwclient [-h]' in captured.out 89 | assert captured.err == '' 90 | 91 | 92 | @mock.patch.object(utils.configparser, 'ConfigParser') 93 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 94 | def test_no_project(mock_config, capsys): 95 | fake_config = FakeConfig() 96 | del fake_config._data['options']['default'] 97 | 98 | mock_config.return_value = fake_config 99 | 100 | with pytest.raises(SystemExit): 101 | shell.main(['get', '1']) 102 | 103 | captured = capsys.readouterr() 104 | 105 | assert 'No default project configured' in captured.err 106 | assert captured.out == '' 107 | 108 | 109 | @mock.patch.object(utils.configparser, 'ConfigParser') 110 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 111 | def test_no_project_url(mock_config, capsys): 112 | fake_config = FakeConfig() 113 | del fake_config._data[DEFAULT_PROJECT]['url'] 114 | 115 | mock_config.return_value = fake_config 116 | 117 | with pytest.raises(SystemExit): 118 | shell.main(['get', '1']) 119 | 120 | captured = capsys.readouterr() 121 | 122 | assert 'No URL for project %s' % DEFAULT_PROJECT in captured.err 123 | assert captured.out == '' 124 | 125 | 126 | @mock.patch.object(utils.configparser, 'ConfigParser') 127 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 128 | def test_missing_project(mock_config, capsys): 129 | mock_config.return_value = FakeConfig() 130 | 131 | with pytest.raises(SystemExit): 132 | shell.main(['get', '1', '-p', 'foo']) 133 | 134 | captured = capsys.readouterr() 135 | 136 | assert 'No section for project foo' in captured.err 137 | assert captured.out == '' 138 | 139 | 140 | @mock.patch.object(utils.configparser, 'ConfigParser') 141 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 142 | @mock.patch.object(utils, 'migrate_old_config_file') 143 | def test_migrate_config(mock_migrate, mock_config): 144 | fake_config = FakeConfig( 145 | { 146 | 'base': { 147 | 'project': 'foo', 148 | 'url': 'https://example.com/', 149 | }, 150 | 'auth': { 151 | 'username': 'user', 152 | 'password': 'pass', 153 | }, 154 | } 155 | ) 156 | del fake_config._data['options'] 157 | mock_config.return_value = fake_config 158 | 159 | with pytest.raises(SystemExit): 160 | shell.main(['get', '1', '-p', 'foo']) 161 | 162 | mock_migrate.assert_called_once_with(mock.ANY, mock_config.return_value) 163 | 164 | 165 | @mock.patch.object(utils.configparser, 'ConfigParser') 166 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 167 | @mock.patch.object(api, 'XMLRPC') 168 | @mock.patch.object(patches, 'action_apply') 169 | def test_server_error(mock_action, mock_api, mock_config, capsys): 170 | mock_config.return_value = FakeConfig() 171 | mock_api.side_effect = exceptions.APIError('Unable to connect') 172 | 173 | with pytest.raises(SystemExit): 174 | shell.main(['get', '1']) 175 | 176 | captured = capsys.readouterr() 177 | 178 | assert 'Unable to connect' in captured.err 179 | assert captured.out == '' 180 | 181 | 182 | @mock.patch.object(utils.configparser, 'ConfigParser') 183 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 184 | @mock.patch.object(api, 'XMLRPC') 185 | @mock.patch.object(patches, 'action_apply') 186 | def test_apply(mock_action, mock_api, mock_config): 187 | mock_config.return_value = FakeConfig() 188 | mock_action.return_value = None 189 | 190 | # test firstly with a single patch ID 191 | 192 | shell.main(['apply', '1']) 193 | 194 | mock_action.assert_called_once_with(mock_api.return_value, 1) 195 | mock_action.reset_mock() 196 | 197 | # then with multiple patch IDs 198 | 199 | shell.main(['apply', '1', '2', '3']) 200 | 201 | mock_action.assert_has_calls( 202 | [ 203 | mock.call(mock_api.return_value, 1), 204 | mock.call(mock_api.return_value, 2), 205 | mock.call(mock_api.return_value, 3), 206 | ] 207 | ) 208 | 209 | 210 | @mock.patch.object(utils.configparser, 'ConfigParser') 211 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 212 | @mock.patch.object(api, 'XMLRPC') 213 | @mock.patch.object(patches, 'action_apply') 214 | def test_apply__failed(mock_action, mock_api, mock_config, capsys): 215 | mock_config.return_value = FakeConfig() 216 | mock_action.side_effect = [0, 0, 1] 217 | 218 | with pytest.raises(SystemExit): 219 | shell.main(['apply', '1', '2', '3']) 220 | 221 | captured = capsys.readouterr() 222 | 223 | mock_action.assert_has_calls( 224 | [ 225 | mock.call(mock_api.return_value, 1), 226 | mock.call(mock_api.return_value, 2), 227 | mock.call(mock_api.return_value, 3), 228 | ] 229 | ) 230 | assert 'Apply failed with exit status 1' in captured.err 231 | 232 | 233 | @mock.patch.object(utils.configparser, 'ConfigParser') 234 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 235 | @mock.patch.object(api, 'XMLRPC') 236 | @mock.patch.object(checks, 'action_create') 237 | def test_check_create(mock_action, mock_api, mock_config): 238 | mock_config.return_value = FakeConfig( 239 | { 240 | DEFAULT_PROJECT: { 241 | 'username': 'user', 242 | 'password': 'pass', 243 | }, 244 | } 245 | ) 246 | 247 | shell.main( 248 | [ 249 | 'check-create', 250 | '-c', 251 | 'testing', 252 | '-s', 253 | 'pending', 254 | '-u', 255 | 'https://example.com/', 256 | '-d', 257 | 'hello, world', 258 | '1', 259 | ] 260 | ) 261 | 262 | mock_action.assert_called_once_with( 263 | mock_api.return_value, 264 | 1, 265 | 'testing', 266 | 'pending', 267 | 'https://example.com/', 268 | 'hello, world', 269 | ) 270 | 271 | 272 | @mock.patch.object(utils.configparser, 'ConfigParser') 273 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 274 | @mock.patch.object(api, 'XMLRPC') 275 | @mock.patch.object(checks, 'action_create') 276 | def test_check_create__no_auth( 277 | mock_action, 278 | mock_api, 279 | mock_config, 280 | capsys, 281 | ): 282 | mock_config.return_value = FakeConfig() 283 | 284 | with pytest.raises(SystemExit): 285 | shell.main( 286 | [ 287 | 'check-create', 288 | '-c', 289 | 'testing', 290 | '-s', 291 | 'pending', 292 | '-u', 293 | 'https://example.com/', 294 | '-d', 295 | 'hello, world', 296 | '1', 297 | ] 298 | ) 299 | 300 | captured = capsys.readouterr() 301 | 302 | mock_action.assert_not_called() 303 | assert 'The check_create action requires authentication,' in captured.err 304 | 305 | 306 | @mock.patch.object(utils.configparser, 'ConfigParser') 307 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 308 | @mock.patch.object(api, 'XMLRPC') 309 | @mock.patch.object(checks, 'action_info') 310 | def test_check_info(mock_action, mock_api, mock_config): 311 | mock_config.return_value = FakeConfig() 312 | 313 | shell.main(['check-info', '1', '1']) 314 | 315 | mock_action.assert_called_once_with(mock_api.return_value, 1, 1) 316 | 317 | 318 | @mock.patch.object(utils.configparser, 'ConfigParser') 319 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 320 | @mock.patch.object(api, 'XMLRPC') 321 | @mock.patch.object(checks, 'action_info') 322 | def test_check_info__no_patch_id(mock_action, mock_api, mock_config): 323 | mock_config.return_value = FakeConfig() 324 | 325 | shell.main(['check-info', '1']) 326 | 327 | mock_action.assert_called_once_with(mock_api.return_value, None, 1) 328 | 329 | 330 | @mock.patch.object(utils.configparser, 'ConfigParser') 331 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 332 | @mock.patch.object(api, 'XMLRPC') 333 | @mock.patch.object(checks, 'action_list') 334 | def test_check_list(mock_action, mock_api, mock_config): 335 | mock_config.return_value = FakeConfig() 336 | 337 | shell.main(['check-list']) 338 | 339 | mock_action.assert_called_once_with(mock_api.return_value, None, None) 340 | 341 | 342 | @mock.patch.object(utils.configparser, 'ConfigParser') 343 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 344 | @mock.patch.object(api, 'XMLRPC') 345 | @mock.patch.object(patches, 'action_get') 346 | def test_get__numeric_id(mock_action, mock_api, mock_config): 347 | mock_config.return_value = FakeConfig() 348 | mock_action.return_value = None 349 | 350 | shell.main(['get', '1']) 351 | 352 | mock_action.assert_called_once_with(mock_api.return_value, 1) 353 | 354 | 355 | @mock.patch.object(utils.configparser, 'ConfigParser') 356 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 357 | @mock.patch.object(api, 'XMLRPC') 358 | @mock.patch.object(patches, 'action_get') 359 | def test_get__multiple_ids(mock_action, mock_api, mock_config): 360 | mock_config.return_value = FakeConfig() 361 | mock_action.return_value = None 362 | 363 | shell.main(['get', '1', '2', '3']) 364 | 365 | mock_action.assert_has_calls( 366 | [ 367 | mock.call(mock_api.return_value, 1), 368 | mock.call(mock_api.return_value, 2), 369 | mock.call(mock_api.return_value, 3), 370 | ] 371 | ) 372 | 373 | 374 | @mock.patch.object(utils.configparser, 'ConfigParser') 375 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 376 | @mock.patch.object(api, 'XMLRPC') 377 | @mock.patch.object(patches, 'patch_id_from_hash') 378 | @mock.patch.object(patches, 'action_get') 379 | def test_get__hash_ids(mock_action, mock_hash, mock_api, mock_config): 380 | mock_config.return_value = FakeConfig() 381 | mock_action.return_value = 0 382 | mock_hash.return_value = 1 383 | 384 | shell.main(['get', '-h', '698fa7f']) 385 | 386 | mock_action.assert_called_once_with(mock_api.return_value, 1) 387 | mock_hash.assert_called_once_with( 388 | mock_api.return_value, 'defaultproject', '698fa7f' 389 | ) 390 | 391 | 392 | @mock.patch.object(utils.configparser, 'ConfigParser') 393 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 394 | @mock.patch.object(api, 'XMLRPC') 395 | @mock.patch.object(patches, 'action_get') 396 | def test_get__no_ids(mock_action, mock_api, mock_config, capsys): 397 | mock_config.return_value = FakeConfig() 398 | mock_action.return_value = None 399 | 400 | with pytest.raises(SystemExit): 401 | shell.main(['get']) 402 | 403 | captured = capsys.readouterr() 404 | 405 | assert 'the following arguments are required: PATCH_ID' in captured.err 406 | assert captured.out == '' 407 | 408 | 409 | @mock.patch.object(utils.configparser, 'ConfigParser') 410 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 411 | @mock.patch.object(api, 'XMLRPC') 412 | @mock.patch.object(patches, 'action_apply') 413 | def test_git_am__no_args(mock_action, mock_api, mock_config): 414 | mock_config.return_value = FakeConfig() 415 | mock_action.return_value = 0 416 | 417 | # test firstly with a single patch ID 418 | 419 | shell.main(['git-am', '1']) 420 | 421 | mock_action.assert_called_once_with( 422 | mock_api.return_value, 1, ['git', 'am'] 423 | ) 424 | mock_action.reset_mock() 425 | 426 | # then with multiple patch IDs 427 | 428 | shell.main(['git-am', '1', '2', '3']) 429 | 430 | mock_action.assert_has_calls( 431 | [ 432 | mock.call(mock_api.return_value, 1, ['git', 'am']), 433 | mock.call(mock_api.return_value, 2, ['git', 'am']), 434 | mock.call(mock_api.return_value, 3, ['git', 'am']), 435 | ] 436 | ) 437 | 438 | 439 | @mock.patch.object(utils.configparser, 'ConfigParser') 440 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 441 | @mock.patch.object(api, 'XMLRPC') 442 | @mock.patch.object(patches, 'action_apply') 443 | def test_git_am__threeway_option(mock_action, mock_api, mock_config): 444 | mock_config.return_value = FakeConfig() 445 | mock_action.return_value = 0 446 | 447 | shell.main(['git-am', '1', '-3']) 448 | 449 | mock_action.assert_called_once_with( 450 | mock_api.return_value, 1, ['git', 'am', '-3'] 451 | ) 452 | 453 | 454 | @mock.patch.object(utils.configparser, 'ConfigParser') 455 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 456 | @mock.patch.object(api, 'XMLRPC') 457 | @mock.patch.object(patches, 'action_apply') 458 | def test_git_am__signoff_option(mock_action, mock_api, mock_config): 459 | mock_config.return_value = FakeConfig() 460 | mock_action.return_value = 0 461 | 462 | shell.main(['git-am', '1', '-s']) 463 | 464 | mock_action.assert_called_once_with( 465 | mock_api.return_value, 1, ['git', 'am', '-s'] 466 | ) 467 | mock_action.reset_mock() 468 | 469 | 470 | @mock.patch.object(utils.configparser, 'ConfigParser') 471 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 472 | @mock.patch.object(api, 'XMLRPC') 473 | @mock.patch.object(patches, 'action_apply') 474 | def test_git_am__threeway_global_conf(mock_action, mock_api, mock_config): 475 | mock_config.return_value = FakeConfig( 476 | { 477 | 'options': { 478 | '3way': True, 479 | } 480 | } 481 | ) 482 | mock_action.return_value = 0 483 | 484 | shell.main(['git-am', '1']) 485 | 486 | mock_action.assert_called_once_with( 487 | mock_api.return_value, 1, ['git', 'am', '-3'] 488 | ) 489 | 490 | 491 | @mock.patch.object(utils.configparser, 'ConfigParser') 492 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 493 | @mock.patch.object(api, 'XMLRPC') 494 | @mock.patch.object(patches, 'action_apply') 495 | def test_git_am__signoff_global_conf(mock_action, mock_api, mock_config): 496 | mock_config.return_value = FakeConfig( 497 | { 498 | 'options': { 499 | 'signoff': True, 500 | } 501 | } 502 | ) 503 | mock_action.return_value = 0 504 | 505 | shell.main(['git-am', '1']) 506 | 507 | mock_action.assert_called_once_with( 508 | mock_api.return_value, 1, ['git', 'am', '-s'] 509 | ) 510 | mock_action.reset_mock() 511 | 512 | 513 | @mock.patch.object(utils.configparser, 'ConfigParser') 514 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 515 | @mock.patch.object(api, 'XMLRPC') 516 | @mock.patch.object(patches, 'action_apply') 517 | def test_git_am__threeway_project_conf(mock_action, mock_api, mock_config): 518 | mock_config.return_value = FakeConfig( 519 | { 520 | DEFAULT_PROJECT: { 521 | '3way': True, 522 | } 523 | } 524 | ) 525 | mock_action.return_value = 0 526 | 527 | shell.main(['git-am', '1']) 528 | 529 | mock_action.assert_called_once_with( 530 | mock_api.return_value, 1, ['git', 'am', '-3'] 531 | ) 532 | 533 | 534 | @mock.patch.object(utils.configparser, 'ConfigParser') 535 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 536 | @mock.patch.object(api, 'XMLRPC') 537 | @mock.patch.object(patches, 'action_apply') 538 | def test_git_am__signoff_project_conf(mock_action, mock_api, mock_config): 539 | mock_config.return_value = FakeConfig( 540 | { 541 | DEFAULT_PROJECT: { 542 | 'signoff': True, 543 | } 544 | } 545 | ) 546 | mock_action.return_value = 0 547 | 548 | shell.main(['git-am', '1']) 549 | 550 | mock_action.assert_called_once_with( 551 | mock_api.return_value, 1, ['git', 'am', '-s'] 552 | ) 553 | mock_action.reset_mock() 554 | 555 | 556 | @mock.patch.object(utils.configparser, 'ConfigParser') 557 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 558 | @mock.patch.object(api, 'XMLRPC') 559 | @mock.patch.object(patches, 'action_apply') 560 | def test_git_am__failure(mock_action, mock_api, mock_config, capsys): 561 | mock_config.return_value = FakeConfig() 562 | mock_action.return_value = 1 563 | 564 | with pytest.raises(SystemExit): 565 | shell.main(['git-am', '1']) 566 | 567 | mock_action.assert_called_once_with( 568 | mock_api.return_value, 1, ['git', 'am'] 569 | ) 570 | mock_action.reset_mock() 571 | 572 | captured = capsys.readouterr() 573 | 574 | assert "'git am' failed with exit status 1\n" in captured.err 575 | assert captured.out == '' 576 | 577 | 578 | @mock.patch.object(utils.configparser, 'ConfigParser') 579 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 580 | @mock.patch.object(api, 'XMLRPC') 581 | @mock.patch.object(patches, 'action_info') 582 | def test_info(mock_action, mock_api, mock_config): 583 | mock_config.return_value = FakeConfig() 584 | mock_action.return_value = None 585 | 586 | # test firstly with a single patch ID 587 | 588 | shell.main(['info', '1']) 589 | 590 | mock_action.assert_called_once_with(mock_api.return_value, 1) 591 | mock_action.reset_mock() 592 | 593 | # then with multiple patch IDs 594 | 595 | shell.main(['info', '1', '2', '3']) 596 | 597 | mock_action.assert_has_calls( 598 | [ 599 | mock.call(mock_api.return_value, 1), 600 | mock.call(mock_api.return_value, 2), 601 | mock.call(mock_api.return_value, 3), 602 | ] 603 | ) 604 | 605 | 606 | @mock.patch.object(utils.configparser, 'ConfigParser') 607 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 608 | @mock.patch.object(api, 'XMLRPC') 609 | @mock.patch.object(patches, 'action_list') 610 | def test_list__no_options(mock_action, mock_api, mock_config): 611 | mock_config.return_value = FakeConfig() 612 | 613 | shell.main(['list']) 614 | 615 | mock_action.assert_called_once_with( 616 | mock_api.return_value, 617 | project=DEFAULT_PROJECT, 618 | submitter=None, 619 | delegate=None, 620 | state=None, 621 | archived=None, 622 | msgid=None, 623 | name=None, 624 | hash=None, 625 | max_count=None, 626 | format_str=None, 627 | ) 628 | 629 | 630 | @mock.patch.object(utils.configparser, 'ConfigParser') 631 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 632 | @mock.patch.object(api, 'XMLRPC') 633 | @mock.patch.object(patches, 'action_list') 634 | def test_list__state_filter(mock_action, mock_api, mock_config): 635 | mock_config.return_value = FakeConfig() 636 | 637 | shell.main(['list', '-s', 'Accepted']) 638 | 639 | mock_action.assert_called_once_with( 640 | mock_api.return_value, 641 | project=DEFAULT_PROJECT, 642 | submitter=None, 643 | delegate=None, 644 | state='Accepted', 645 | archived=None, 646 | msgid=None, 647 | name=None, 648 | hash=None, 649 | max_count=None, 650 | format_str=None, 651 | ) 652 | 653 | 654 | @mock.patch.object(utils.configparser, 'ConfigParser') 655 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 656 | @mock.patch.object(api, 'XMLRPC') 657 | @mock.patch.object(patches, 'action_list') 658 | def test_list__archived_filter(mock_action, mock_api, mock_config): 659 | mock_config.return_value = FakeConfig() 660 | 661 | shell.main(['list', '-a', 'yes']) 662 | 663 | mock_action.assert_called_once_with( 664 | mock_api.return_value, 665 | project=DEFAULT_PROJECT, 666 | submitter=None, 667 | delegate=None, 668 | state=None, 669 | archived=True, 670 | msgid=None, 671 | name=None, 672 | hash=None, 673 | max_count=None, 674 | format_str=None, 675 | ) 676 | 677 | 678 | @mock.patch.object(utils.configparser, 'ConfigParser') 679 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 680 | @mock.patch.object(api, 'XMLRPC') 681 | @mock.patch.object(patches, 'action_list') 682 | def test_list__project_filter(mock_action, mock_api, mock_config): 683 | mock_config.return_value = FakeConfig( 684 | { 685 | 'fakeproject': { 686 | 'url': 'https://example.com/fakeproject', 687 | } 688 | } 689 | ) 690 | 691 | shell.main(['list', '-p', 'fakeproject']) 692 | 693 | mock_action.assert_called_once_with( 694 | mock_api.return_value, 695 | project='fakeproject', 696 | submitter=None, 697 | delegate=None, 698 | state=None, 699 | archived=None, 700 | msgid=None, 701 | name=None, 702 | hash=None, 703 | max_count=None, 704 | format_str=None, 705 | ) 706 | 707 | 708 | @mock.patch.object(utils.configparser, 'ConfigParser') 709 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 710 | @mock.patch.object(api, 'XMLRPC') 711 | @mock.patch.object(patches, 'action_list') 712 | def test_list__submitter_filter(mock_action, mock_api, mock_config): 713 | mock_config.return_value = FakeConfig() 714 | 715 | shell.main(['list', '-w', 'fakesubmitter']) 716 | 717 | mock_action.assert_called_once_with( 718 | mock_api.return_value, 719 | project=DEFAULT_PROJECT, 720 | submitter='fakesubmitter', 721 | delegate=None, 722 | state=None, 723 | archived=None, 724 | msgid=None, 725 | name=None, 726 | hash=None, 727 | max_count=None, 728 | format_str=None, 729 | ) 730 | 731 | 732 | @mock.patch.object(utils.configparser, 'ConfigParser') 733 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 734 | @mock.patch.object(api, 'XMLRPC') 735 | @mock.patch.object(patches, 'action_list') 736 | def test_list__delegate_filter(mock_action, mock_api, mock_config): 737 | mock_config.return_value = FakeConfig() 738 | 739 | shell.main(['list', '-d', 'fakedelegate']) 740 | 741 | mock_action.assert_called_once_with( 742 | mock_api.return_value, 743 | project=DEFAULT_PROJECT, 744 | submitter=None, 745 | delegate='fakedelegate', 746 | state=None, 747 | archived=None, 748 | msgid=None, 749 | name=None, 750 | hash=None, 751 | max_count=None, 752 | format_str=None, 753 | ) 754 | 755 | 756 | @mock.patch.object(utils.configparser, 'ConfigParser') 757 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 758 | @mock.patch.object(api, 'XMLRPC') 759 | @mock.patch.object(patches, 'action_list') 760 | def test_list__msgid_filter(mock_action, mock_api, mock_config): 761 | mock_config.return_value = FakeConfig() 762 | 763 | shell.main(['list', '-m', 'fakemsgid']) 764 | 765 | mock_action.assert_called_once_with( 766 | mock_api.return_value, 767 | project=DEFAULT_PROJECT, 768 | submitter=None, 769 | delegate=None, 770 | state=None, 771 | archived=None, 772 | msgid='fakemsgid', 773 | name=None, 774 | hash=None, 775 | max_count=None, 776 | format_str=None, 777 | ) 778 | 779 | 780 | @mock.patch.object(utils.configparser, 'ConfigParser') 781 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 782 | @mock.patch.object(api, 'XMLRPC') 783 | @mock.patch.object(patches, 'action_list') 784 | def test_list__name_filter(mock_action, mock_api, mock_config): 785 | mock_config.return_value = FakeConfig() 786 | 787 | shell.main(['list', 'fake patch name']) 788 | 789 | mock_action.assert_called_once_with( 790 | mock_api.return_value, 791 | project=DEFAULT_PROJECT, 792 | submitter=None, 793 | delegate=None, 794 | state=None, 795 | archived=None, 796 | msgid=None, 797 | name='fake patch name', 798 | hash=None, 799 | max_count=None, 800 | format_str=None, 801 | ) 802 | 803 | 804 | @mock.patch.object(utils.configparser, 'ConfigParser') 805 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 806 | @mock.patch.object(api, 'XMLRPC') 807 | @mock.patch.object(patches, 'action_list') 808 | def test_list__limit_filter(mock_action, mock_api, mock_config): 809 | mock_config.return_value = FakeConfig() 810 | 811 | shell.main(['list', '-n', '5']) 812 | 813 | mock_action.assert_called_once_with( 814 | mock_api.return_value, 815 | project=DEFAULT_PROJECT, 816 | submitter=None, 817 | delegate=None, 818 | state=None, 819 | archived=None, 820 | msgid=None, 821 | name=None, 822 | hash=None, 823 | max_count=5, 824 | format_str=None, 825 | ) 826 | 827 | 828 | @mock.patch.object(utils.configparser, 'ConfigParser') 829 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 830 | @mock.patch.object(api, 'XMLRPC') 831 | @mock.patch.object(patches, 'action_list') 832 | def test_list__limit_reverse_filter(mock_action, mock_api, mock_config): 833 | mock_config.return_value = FakeConfig() 834 | 835 | shell.main(['list', '-N', '5']) 836 | 837 | mock_action.assert_called_once_with( 838 | mock_api.return_value, 839 | project=DEFAULT_PROJECT, 840 | submitter=None, 841 | delegate=None, 842 | state=None, 843 | archived=None, 844 | msgid=None, 845 | name=None, 846 | hash=None, 847 | max_count=-5, 848 | format_str=None, 849 | ) 850 | 851 | 852 | @mock.patch.object(utils.configparser, 'ConfigParser') 853 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 854 | @mock.patch.object(api, 'XMLRPC') 855 | @mock.patch.object(patches, 'action_list') 856 | def test_list__hash_filter(mock_action, mock_api, mock_config): 857 | mock_config.return_value = FakeConfig() 858 | 859 | shell.main(['list', '-H', '3143a71a9d33f4f12b4469818d205125cace6535']) 860 | 861 | mock_action.assert_called_once_with( 862 | mock_api.return_value, 863 | project=DEFAULT_PROJECT, 864 | submitter=None, 865 | delegate=None, 866 | state=None, 867 | archived=None, 868 | msgid=None, 869 | name=None, 870 | hash='3143a71a9d33f4f12b4469818d205125cace6535', 871 | max_count=None, 872 | format_str=None, 873 | ) 874 | 875 | 876 | @mock.patch.object(utils.configparser, 'ConfigParser') 877 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 878 | @mock.patch.object(api, 'XMLRPC') 879 | @mock.patch.object(projects, 'action_list') 880 | def test_projects(mock_action, mock_api, mock_config): 881 | mock_config.return_value = FakeConfig() 882 | 883 | shell.main(['projects']) 884 | 885 | mock_action.assert_called_once_with(mock_api.return_value) 886 | 887 | 888 | @mock.patch.object(utils.configparser, 'ConfigParser') 889 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 890 | @mock.patch.object(api, 'XMLRPC') 891 | @mock.patch.object(states, 'action_list') 892 | def test_states(mock_action, mock_api, mock_config): 893 | mock_config.return_value = FakeConfig() 894 | 895 | shell.main(['states']) 896 | 897 | mock_action.assert_called_once_with(mock_api.return_value) 898 | 899 | 900 | @mock.patch.object(utils.configparser, 'ConfigParser') 901 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 902 | @mock.patch.object(api, 'XMLRPC') 903 | @mock.patch.object(patches, 'action_update') 904 | def test_update__no_options( 905 | mock_action, 906 | mock_api, 907 | mock_config, 908 | capsys, 909 | ): 910 | mock_config.return_value = FakeConfig( 911 | { 912 | DEFAULT_PROJECT: { 913 | 'username': 'user', 914 | 'password': 'pass', 915 | }, 916 | } 917 | ) 918 | 919 | with pytest.raises(SystemExit): 920 | shell.main(['update', '1']) 921 | 922 | captured = capsys.readouterr() 923 | 924 | assert 'Must specify one or more update options (-a or -s)' in captured.err 925 | assert captured.out == '' 926 | 927 | 928 | @mock.patch.object(utils.configparser, 'ConfigParser') 929 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 930 | @mock.patch.object(api, 'XMLRPC') 931 | @mock.patch.object(patches, 'action_update') 932 | def test_update__no_auth( 933 | mock_action, 934 | mock_api, 935 | mock_config, 936 | capsys, 937 | ): 938 | mock_config.return_value = FakeConfig() 939 | 940 | with pytest.raises(SystemExit): 941 | shell.main(['update', '1', '-a', 'yes']) 942 | 943 | captured = capsys.readouterr() 944 | 945 | mock_action.assert_not_called() 946 | assert 'The update action requires authentication,' in captured.err 947 | 948 | 949 | @mock.patch.object(utils.configparser, 'ConfigParser') 950 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 951 | @mock.patch.object(api, 'XMLRPC') 952 | @mock.patch.object(patches, 'action_update') 953 | def test_update__state_option(mock_action, mock_api, mock_config): 954 | mock_config.return_value = FakeConfig( 955 | { 956 | DEFAULT_PROJECT: { 957 | 'username': 'user', 958 | 'password': 'pass', 959 | }, 960 | } 961 | ) 962 | 963 | shell.main(['update', '1', '-s', 'Accepted']) 964 | 965 | mock_action.assert_called_once_with( 966 | mock_api.return_value, 967 | 1, 968 | state='Accepted', 969 | archived=None, 970 | commit_ref=None, 971 | ) 972 | 973 | 974 | @mock.patch.object(utils.configparser, 'ConfigParser') 975 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 976 | @mock.patch.object(api, 'XMLRPC') 977 | @mock.patch.object(patches, 'action_update') 978 | def test_update__archive_option(mock_action, mock_api, mock_config): 979 | mock_config.return_value = FakeConfig( 980 | { 981 | DEFAULT_PROJECT: { 982 | 'username': 'user', 983 | 'password': 'pass', 984 | }, 985 | } 986 | ) 987 | 988 | shell.main(['update', '1', '-a', 'yes']) 989 | 990 | mock_action.assert_called_once_with( 991 | mock_api.return_value, 1, state=None, archived='yes', commit_ref=None 992 | ) 993 | 994 | 995 | @mock.patch.object(utils.configparser, 'ConfigParser') 996 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 997 | @mock.patch.object(api, 'XMLRPC') 998 | @mock.patch.object(patches, 'action_update') 999 | def test_update__commitref_option(mock_action, mock_api, mock_config): 1000 | mock_config.return_value = FakeConfig( 1001 | { 1002 | DEFAULT_PROJECT: { 1003 | 'username': 'user', 1004 | 'password': 'pass', 1005 | }, 1006 | } 1007 | ) 1008 | 1009 | shell.main(['update', '1', '-s', 'Accepted', '-c', '698fa7f']) 1010 | 1011 | mock_action.assert_called_once_with( 1012 | mock_api.return_value, 1013 | 1, 1014 | state='Accepted', 1015 | archived=None, 1016 | commit_ref='698fa7f', 1017 | ) 1018 | 1019 | 1020 | @mock.patch.object(utils.configparser, 'ConfigParser') 1021 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 1022 | @mock.patch.object(api, 'XMLRPC') 1023 | @mock.patch.object(patches, 'action_update') 1024 | def test_update__commitref_with_multiple_patches( 1025 | mock_action, 1026 | mock_api, 1027 | mock_config, 1028 | capsys, 1029 | ): 1030 | mock_config.return_value = FakeConfig( 1031 | { 1032 | DEFAULT_PROJECT: { 1033 | 'username': 'user', 1034 | 'password': 'pass', 1035 | }, 1036 | } 1037 | ) 1038 | 1039 | with pytest.raises(SystemExit): 1040 | shell.main(['update', '-s', 'Accepted', '-c', '698fa7f', '1', '2']) 1041 | 1042 | captured = capsys.readouterr() 1043 | 1044 | mock_action.assert_not_called() 1045 | assert 'Declining update with COMMIT-REF on multiple IDs' in captured.err 1046 | 1047 | 1048 | @mock.patch.object(utils.configparser, 'ConfigParser') 1049 | @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) 1050 | @mock.patch.object(api, 'XMLRPC') 1051 | @mock.patch.object(patches, 'action_view') 1052 | def test_view(mock_action, mock_api, mock_config, capsys): 1053 | mock_config.return_value = FakeConfig() 1054 | mock_api.return_value.patch_get_mbox.return_value = 'foo' 1055 | 1056 | shell.main(['view', '1']) 1057 | 1058 | mock_action.assert_called_once_with(mock_api.return_value, [1]) 1059 | -------------------------------------------------------------------------------- /tests/test_states.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pwclient import states 4 | 5 | from . import fakes 6 | 7 | 8 | def test_action_list(capsys): 9 | rpc = mock.Mock() 10 | rpc.state_list.return_value = fakes.fake_states() 11 | 12 | states.action_list(rpc) 13 | 14 | captured = capsys.readouterr() 15 | 16 | assert ( 17 | captured.out 18 | == """\ 19 | ID Name 20 | -- ---- 21 | 1 New 22 | """ 23 | ) 24 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pwclient import utils 4 | 5 | from .test_shell import FakeConfig 6 | 7 | 8 | @mock.patch.object(utils.configparser, 'ConfigParser') 9 | @mock.patch.object(utils.shutil, 'copy2', new=mock.Mock()) 10 | @mock.patch.object(utils, 'open', new_callable=mock.mock_open, read_data='1') 11 | def test_migrate_config(mock_open, mock_config, capsys): 12 | old_config = FakeConfig( 13 | { 14 | 'base': { 15 | 'project': 'foo', 16 | 'url': 'https://example.com/', 17 | }, 18 | 'auth': { 19 | 'username': 'user', 20 | 'password': 'pass', 21 | }, 22 | } 23 | ) 24 | new_config = FakeConfig() 25 | mock_config.return_value = new_config 26 | 27 | utils.migrate_old_config_file('foo', old_config) 28 | 29 | captured = capsys.readouterr() 30 | 31 | assert 'foo is in the old format. Migrating it...' in captured.err 32 | assert captured.out == '' 33 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.1 3 | envlist = pep8,clean,py{39,310,311,312,313},report,docs,man 4 | ignore_basepython_conflict = true 5 | 6 | [testenv] 7 | basepython = python3 8 | deps = 9 | -r{toxinidir}/test-requirements.txt 10 | commands = 11 | pytest -Wall --cov=pwclient --cov-report term-missing {posargs} 12 | 13 | [testenv:pep8] 14 | skip_install = true 15 | deps = 16 | pre-commit 17 | commands = 18 | pre-commit run --all-files --show-diff-on-failure 19 | 20 | [testenv:mypy] 21 | deps= 22 | mypy 23 | commands= 24 | mypy {posargs:--ignore-missing-imports --follow-imports=skip} pwclient 25 | 26 | [testenv:report] 27 | skip_install = true 28 | deps = 29 | coverage 30 | commands = 31 | coverage report 32 | coverage html 33 | 34 | [testenv:clean] 35 | envdir = {toxworkdir}/report 36 | skip_install = true 37 | deps = 38 | {[testenv:report]deps} 39 | commands = 40 | coverage erase 41 | 42 | [testenv:docs] 43 | deps = 44 | -r{toxinidir}/docs/requirements.txt 45 | commands = 46 | sphinx-build {posargs:-E -W} docs docs/_build/html 47 | 48 | [testenv:man] 49 | allowlist_externals = 50 | bash 51 | mkdir 52 | deps = 53 | argparse-manpage 54 | commands = 55 | # argparse-manpage doesn't have an OUTPUT flag and tox doesn't support 56 | # redirection, so we need to wrap this in 'bash' 57 | mkdir -p man 58 | bash -c 'argparse-manpage --project-name=pwclient \ 59 | --url https://github.com/getpatchwork/patchwork \ 60 | --author="Patchwork Developers" --author-email="patchwork@lists.ozlabs.org" \ 61 | --module pwclient.parser --function get_parser > man/pwclient.1' 62 | 63 | [flake8] 64 | show-source = true 65 | --------------------------------------------------------------------------------