├── .flake8 ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── Makefile ├── README.md ├── man ├── Makefile └── signoff.1.txt ├── setup.py ├── signoff └── __init__.py └── test └── test_cli.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ['https://www.archlinux.org/donate/'] 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Github-Actions 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.7 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.7 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install flake8 20 | - name: Lint with flake8 21 | run: | 22 | flake8 signoff 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # man pages 104 | man/signoff.1 105 | 106 | # coverage 107 | test/coverage 108 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: archlinux 2 | 3 | before_script: 4 | - > 5 | pacman -Syu --needed --noconfirm 6 | flake8 git make 7 | python-click python-dateutil python-requests pyalpm 8 | python-pytest-pacman python-pytest-cov 9 | 10 | lint: 11 | script: 12 | - make lint 13 | 14 | test: 15 | script: 16 | - PYTEST_OPTIONS=--junitxml=report.xml make test 17 | - coverage xml 18 | artifacts: 19 | when: always 20 | reports: 21 | junit: report.xml 22 | cobertura: coverage.xml 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Håvard Pettersson 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Inspired by https://github.com/archlinux/arch-security-tracker/blob/master/Makefile 2 | 3 | PYTHON?=python 4 | FLAKE8?=flake8 5 | PYTEST?=py.test 6 | PYTEST_OPTIONS+=-s 7 | PYTEST_INPUT?=test 8 | PYTEST_COVERAGE_OPTIONS+=--cov-report=term-missing --cov-report=html:test/coverage --cov=signoff 9 | 10 | .PHONY: test lint 11 | 12 | build: 13 | $(PYTHON) setup.py build 14 | 15 | test: test-py 16 | 17 | test-py coverage: 18 | PYTHONPATH="${BUILD_DIR}:.:${PYTHONPATH}" ${PYTEST} ${PYTEST_INPUT} ${PYTEST_OPTIONS} ${PYTEST_COVERAGE_OPTIONS} 19 | 20 | open-coverage: coverage 21 | ${BROWSER} test/coverage/index.html 22 | 23 | lint: 24 | $(FLAKE8) signoff 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arch Linux Signoff Tool 2 | 3 | The `signoff` tool can be used by members of the [Arch Testing Team](https://wiki.archlinux.org/index.php/Arch_Testing_Team) to make it easier 4 | to sign off packages. `signoff -i` lets you interactively sign off packages. See [asciinema](https://asciinema.org/a/nfTIZNEVcJmP0a8uEfe5MCiej) for a demo. 5 | 6 | To simplify authentication, specify your ArchWeb username and password in the 7 | `ARCHWEB_USERNAME` and `ARCHWEB_PASSWORD` environment variables. For instance, 8 | using [pass](https://www.passwordstore.org/) 9 | 10 | ``` 11 | alias signoff='ARCHWEB_PASSWORD="$(pass archweb)" signoff' 12 | ``` 13 | 14 | ## Dependencies 15 | 16 | * pyalpm 17 | * python-click 18 | * python-dateutil 19 | * python-requests 20 | * python-setuptools 21 | 22 | ## Test 23 | 24 | Test dependencies: 25 | 26 | * python-pytest 27 | * python-pytest-cov 28 | * python-pytest-pacman 29 | 30 | Running tests: 31 | ``` 32 | make test 33 | ``` 34 | 35 | ## LICENSE 36 | 37 | See LICENSE for license details. 38 | -------------------------------------------------------------------------------- /man/Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE_NAME = signoff 2 | 3 | VERSION := $(shell cd .. && python3 setup.py -V) 4 | 5 | all: $(PACKAGE_NAME).1 6 | 7 | $(PACKAGE_NAME).1: $(PACKAGE_NAME).1.txt Makefile 8 | $(V_GEN) a2x \ 9 | -d manpage \ 10 | -f manpage \ 11 | -a manversion="$(PACKAGE_NAME) $(VERSION)" \ 12 | -a manmanual="$(PACKAGE_NAME) manual" $< 13 | 14 | .PHONY: clean 15 | clean: 16 | $(RM) $(PACKAGE_NAME).1 17 | -------------------------------------------------------------------------------- /man/signoff.1.txt: -------------------------------------------------------------------------------- 1 | ///// 2 | vim:set ts=4 sw=4 syntax=asciidoc noet: 3 | ///// 4 | signoff(1) 5 | ========== 6 | 7 | Name 8 | ---- 9 | signoff - Signoff or revoke Arch Linux Testing packages 10 | 11 | Synopsis 12 | -------- 13 | signoff [options] [package] 14 | 15 | Description 16 | ----------- 17 | Packages in Arch Linux's testing repositories can be signed off as fully 18 | functional by the Arch Linux Testing Team and after a certain amount of 19 | signoffs promoted to the normal repositories. The 'signoff' tool is created for 20 | the Arch Linux Testing Team to make it easier to sign off packages in an 21 | interactive way. 22 | 23 | Options 24 | ------- 25 | *-s, \--signoff* 'package':: 26 | Signoff the specified package. 27 | 28 | *-r, \--revoke* 'package':: 29 | Revoke the specified package. 30 | 31 | *-l, \--list*:: 32 | List of packages that can be signed off. 33 | 34 | *-i, \--interactive*:: 35 | Interactively sign off packages. 36 | 37 | *-u, \--uninstalled*:: 38 | Include uninstalled packages when listing. 39 | 40 | *-a, \--signed-off*:: 41 | Include signed-off packages when listing. 42 | 43 | *-q, \--quiet*:: 44 | Be less verbose when listing packages. 45 | 46 | *-b, \--db-path* :: 47 | pacman database path 48 | 49 | *\--username* 'username':: 50 | ArchWeb username. 51 | 52 | *\--password* 'password':: 53 | ArchWeb password. 54 | 55 | *\--noconfirm*:: 56 | Don't ask for confirmation. 57 | 58 | *\--nocolor*:: 59 | Don't use color in output. 60 | 61 | *-h, \--help*:: 62 | Show this message and exit. 63 | 64 | Environment 65 | ----------- 66 | *ARCHWEB_USERNAME*:: 67 | The archweb username to use to log in to archweb. 68 | 69 | *ARCHWEB_PASSWORD*:: 70 | The archweb password to use to log in to archweb. 71 | 72 | Authors 73 | ------- 74 | Jelle van der Waa 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | with open("README.md") as readme_file: 7 | readme_data = readme_file.read() 8 | 9 | setup( 10 | name="arch-signoff", 11 | use_scm_version=True, 12 | description="Sign off Arch Linux test packages", 13 | long_description=readme_data, 14 | author="Håvard Pettersson", 15 | author_email="mail@haavard.me", 16 | url="https://github.com/archlinux/arch-signoff", 17 | classifiers=[ 18 | "Development Status :: 5 - Production/Stable", 19 | "License :: OSI Approved :: ISC License (ISCL)", 20 | "Topic :: Software Development", 21 | "Intended Audience :: Developers", 22 | "Environment :: Console", 23 | "Natural Language :: English", 24 | "Programming Language :: Python :: 3", 25 | "Operating System :: POSIX :: Linux", 26 | ], 27 | 28 | setup_requires=["setuptools_scm"], 29 | install_requires=[ 30 | "click", 31 | "python-dateutil", 32 | "pyalpm>=0.9.0", 33 | "requests" 34 | ], 35 | tests_require=[ 36 | "pytest", 37 | "pytest-pacman", 38 | ], 39 | packages=["signoff"], 40 | entry_points=""" 41 | [console_scripts] 42 | signoff=signoff:main 43 | """ 44 | ) 45 | -------------------------------------------------------------------------------- /signoff/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from operator import itemgetter 5 | import urllib 6 | 7 | import click 8 | import dateutil.parser 9 | import os 10 | import pyalpm 11 | import pycman.pkginfo 12 | import requests 13 | import sys 14 | 15 | # monkeypatch pycman 16 | pycman.pkginfo.ATTRNAME_FORMAT = '%-13s : ' 17 | pycman.pkginfo.ATTR_INDENT = 16 * ' ' 18 | 19 | 20 | class SignoffSession: 21 | """ 22 | Helper class for talking to ArchWeb. 23 | """ 24 | 25 | def __init__(self, 26 | username, 27 | password, 28 | base_url="https://archlinux.org/"): 29 | self.base_url = base_url 30 | self.username = username 31 | self.session = requests.Session() 32 | self._login(username, password) 33 | 34 | def _login(self, username, password): 35 | # get CSRF token 36 | self.session.get(self._login_url()) 37 | csrftoken = self.session.cookies["csrftoken"] 38 | 39 | # login 40 | response = self.session.post( 41 | self._login_url(), 42 | data={ 43 | "username": username, 44 | "password": password, 45 | "csrfmiddlewaretoken": csrftoken 46 | }, 47 | headers={"referer": self._login_url()}, 48 | allow_redirects=False) 49 | 50 | if not 300 <= response.status_code < 400: 51 | raise click.BadParameter("could not log in -- check credentials") 52 | 53 | def logout(self): 54 | """ 55 | Log out of Archweb. 56 | """ 57 | self.session.get(self._logout_url()) 58 | 59 | def get_signoffs(self): 60 | """ 61 | Fetch signoff packages. 62 | """ 63 | response = self.session.get(self._signoffs_url()) 64 | response_json = response.json() 65 | assert response_json["version"] == 2 66 | 67 | return response_json["signoff_groups"] 68 | 69 | def signoff_package(self, package): 70 | """ 71 | Signoff a package. 72 | """ 73 | self.session.get(self._signoff_url(package)).raise_for_status() 74 | 75 | def revoke_package(self, package): 76 | """ 77 | Revoke a package signoff. 78 | """ 79 | self.session.get(self._revoke_url(package)).raise_for_status() 80 | 81 | def _login_url(self): 82 | return urllib.parse.urljoin(self.base_url, "/login/") 83 | 84 | def _logout_url(self): 85 | return urllib.parse.urljoin(self.base_url, "/logout/") 86 | 87 | def _signoffs_url(self): 88 | return urllib.parse.urljoin(self.base_url, "/packages/signoffs/json/") 89 | 90 | def _signoff_url(self, package): 91 | return urllib.parse.urljoin( 92 | self.base_url, 93 | "/packages/{repo}/{arch}/{pkgbase}/signoff/".format(**package)) 94 | 95 | def _revoke_url(self, package): 96 | return urllib.parse.urljoin(self._signoff_url(package), "revoke/") 97 | 98 | 99 | def signoff_status(package, user): 100 | """ 101 | Return the current sign-off status of package and user. Returns either 102 | "signed-off", "revoked", or None. 103 | """ 104 | # sort by latest signoffs first 105 | signoffs = sorted( 106 | package["signoffs"], 107 | key=lambda s: dateutil.parser.parse(s["created"]), 108 | reverse=True) 109 | 110 | for signoff in signoffs: 111 | # skip until we find a signoff by the specified user 112 | if signoff["user"] != user: 113 | continue 114 | 115 | return "revoked" if signoff["revoked"] else "signed-off" 116 | else: 117 | return None 118 | 119 | 120 | def list_signoffs(signoff_session, alpm_handle): 121 | """ 122 | Generator for (signoff_pkg, local_pkg) tuples, sorted by pkgbase. 123 | """ 124 | signoffs = signoff_session.get_signoffs() 125 | 126 | for signoff_package in sorted(signoffs, key=itemgetter("pkgbase")): 127 | pkgbase = signoff_package["pkgbase"] 128 | search = alpm_handle.get_localdb().search(pkgbase) 129 | local = filter(lambda pkg: pkgbase == (pkg.base or pkg.name), search) 130 | local_package = next(local, None) 131 | 132 | yield (signoff_package, local_package) 133 | 134 | 135 | def filter_signoffs(signoffs, options): 136 | """ 137 | Filter a sequence of (signoff_package, local_package) tuples with respect 138 | to the given options. 139 | """ 140 | for signoff_package, local_package in signoffs: 141 | # command-line packages override other filters 142 | if options.packages: 143 | if signoff_package["pkgbase"] in options.packages: 144 | yield (signoff_package, local_package) 145 | continue 146 | 147 | # check signoff status 148 | signed_off = signoff_status(signoff_package, 149 | options.username) == "signed-off" 150 | if signed_off and not options.show_signed_off: 151 | continue 152 | 153 | # check install status 154 | if local_package is None and not options.show_uninstalled: 155 | continue 156 | 157 | yield (signoff_package, local_package) 158 | 159 | 160 | def format_signoff_user(signoff): 161 | """ 162 | Format a single user signoff dictionary. Return "" if signoff is 163 | valid, and " (revoked)" if the signoff is revoked. 164 | """ 165 | if signoff["revoked"]: 166 | return signoff["user"] + " (revoked)" 167 | else: 168 | return signoff["user"] 169 | 170 | 171 | def format_attr(*args, **kwargs): 172 | """ 173 | Format an attribute in pacman -Qi style. 174 | """ 175 | formatted = pycman.pkginfo.format_attr(*args, **kwargs) 176 | if not formatted: 177 | return click.style("") 178 | index = formatted.index(":") + 1 179 | return click.style(formatted[:index], bold=True) + formatted[index:] 180 | 181 | 182 | def format_signoff(signoff_pkg, local_pkg, options): 183 | """ 184 | Format a signoff package dictionary and optional local package. 185 | """ 186 | if options.quiet: 187 | return format_signoff_short(signoff_pkg, local_pkg, options) 188 | else: 189 | return format_signoff_long(signoff_pkg, local_pkg, options) 190 | 191 | 192 | def format_signoff_short(signoff_pkg, local_pkg, options): 193 | """ 194 | Format a signoff package in a one-line style. 195 | """ 196 | formatted = "{name} {version}".format( 197 | name=click.style(signoff_pkg["pkgbase"], bold=True), 198 | version=click.style(signoff_pkg["version"], bold=True, fg="green")) 199 | 200 | # show outdated or installed indicator if appropriate 201 | if local_pkg is not None and local_pkg.version != signoff_pkg["version"]: 202 | formatted += click.style(" (outdated)", bold=True, fg="red") 203 | elif options.show_uninstalled and local_pkg: 204 | formatted += click.style(" (installed)", bold=True, fg="blue") 205 | 206 | # show known bad indicator 207 | if signoff_pkg["known_bad"]: 208 | formatted += click.style(" (known bad)", bold=True, fg="red") 209 | 210 | # show signed-off indicator if we're listing signed off packages 211 | status = signoff_status(signoff_pkg, options.username) 212 | if options.show_signed_off and status == "signed-off": 213 | formatted += click.style(" [signed off]", bold=True, fg="cyan") 214 | elif status == "revoked": 215 | formatted += click.style(" [revoked]", bold=True, fg="red") 216 | 217 | return formatted 218 | 219 | 220 | def format_signoff_long(signoff_pkg, local_pkg, options): 221 | """ 222 | Format a signoff package in pacman -Qi style. 223 | """ 224 | attributes = [] 225 | 226 | # package base/name as appropriate 227 | if len(signoff_pkg["pkgnames"]) > 1: 228 | attributes.append(format_attr("Package base", signoff_pkg["pkgbase"])) 229 | attributes.append(format_attr("Packages", signoff_pkg["pkgnames"])) 230 | else: 231 | attributes.append(format_attr("Package", signoff_pkg["pkgbase"])) 232 | 233 | # version, including local version if they differ 234 | attributes.append(format_attr("Version", signoff_pkg["version"])) 235 | if local_pkg: 236 | if local_pkg.version != signoff_pkg["version"]: 237 | attributes.append(format_attr("Local version", local_pkg.version)) 238 | 239 | # last update, and install date if package is installed 240 | last_update = dateutil.parser.parse(signoff_pkg["last_update"]).timestamp() 241 | attributes.append(format_attr("Last updated", last_update, format_str="time")) 242 | if local_pkg: 243 | attributes.append( 244 | format_attr("Install date", local_pkg.installdate, format_str="time")) 245 | 246 | # packager and comments 247 | attributes.append(format_attr("Packager", signoff_pkg["packager"])) 248 | attributes.append( 249 | format_attr("Comments", signoff_pkg["comments"] or "None")) 250 | 251 | # signoffs 252 | signoffs = [ 253 | format_signoff_user(signoff) for signoff in signoff_pkg["signoffs"] 254 | ] 255 | attributes.append(format_attr("Signoffs", signoffs)) 256 | 257 | # current user signoff status 258 | status = signoff_status(signoff_pkg, options.username) 259 | if status is None: 260 | signed_off = "No" 261 | elif status == "signed-off": 262 | signed_off = "Yes" 263 | elif status == "revoked": 264 | signed_off = "Revoked" 265 | attributes.append(format_attr("Signed off", signed_off)) 266 | 267 | # bad status 268 | if signoff_pkg["known_bad"]: 269 | attributes.append(format_attr("Known bad", "Yes")) 270 | 271 | return "\n".join(attributes) 272 | 273 | 274 | def warn_if_outdated(signoff_pkg, local_pkg, color): 275 | """ 276 | Echo a warning message if local and sign-off package versions differ. 277 | """ 278 | if local_pkg.version != signoff_pkg["version"]: 279 | click.echo( 280 | click.style("warning:", fg="yellow", bold=True) + " local " 281 | "{pkg} ({local_version}) is not the same as sign-off version " 282 | "({signoff_version})".format( 283 | pkg=signoff_pkg["pkgbase"], 284 | local_version=local_pkg.version, 285 | signoff_version=signoff_pkg["version"]), color=color) 286 | 287 | 288 | def warn_if_bad(signoff_pkg, color): 289 | """ 290 | Echo a warning message if sign-off package is bad. 291 | """ 292 | if signoff_pkg["known_bad"]: 293 | click.echo( 294 | click.style("warning:", fg="yellow", bold=True) + 295 | " {pkg} is known to be bad".format( 296 | pkg=signoff_pkg["pkgbase"]), color=color) 297 | 298 | 299 | def confirm(text, color, *args, **kwargs): 300 | """ 301 | Wrapper around click.confirm that adds minor styling to the prompt. 302 | """ 303 | if color: 304 | prefix = click.style(":: ", bold=True, fg="blue") 305 | styled = click.style(text, bold=True) 306 | else: 307 | prefix = ":: " 308 | styled = click.unstyle(text) 309 | return click.confirm(prefix + styled, *args, **kwargs) 310 | 311 | 312 | class Options: 313 | """ 314 | Simple helper class for holding arbitrary command-line options. 315 | """ 316 | 317 | def __init__(self, **kwargs): 318 | for kwarg in kwargs: 319 | setattr(self, kwarg, kwargs[kwarg]) 320 | 321 | 322 | @click.command(context_settings={"help_option_names": ("-h", "--help")}) 323 | @click.option("-s", "--signoff", "action", flag_value="signoff", help="sign " 324 | "off packages") 325 | @click.option("-r", "--revoke", "action", flag_value="revoke", help="revoke " 326 | "signed-off packages") 327 | @click.option("-l", "--list", "action", flag_value="list", help="list " 328 | "packages that can be signed off") 329 | @click.option("-i", "--interactive", "action", flag_value="interactive", 330 | help="interactively sign off packages") 331 | @click.option("-u", "--uninstalled", is_flag=True, help="include uninstalled " 332 | "packages when listing") 333 | @click.option("-a", "--signed-off", is_flag=True, help="include signed-off " 334 | "packages when listing") 335 | @click.option("-q", "--quiet", is_flag=True, help="be less verbose when " 336 | "listing packages") 337 | @click.option("--username", prompt=True, envvar="ARCHWEB_USERNAME", 338 | help="ArchWeb username (ARCHWEB_USERNAME env. var.)") 339 | @click.option("--password", prompt=True, hide_input=True, 340 | envvar="ARCHWEB_PASSWORD", help="ArchWeb password (ARCHWEB_PASSWORD " 341 | "env. var.)") 342 | @click.option("-b", "--db-path", type=click.Path(), default="/var/lib/pacman", 343 | help="pacman database path") 344 | @click.option("--noconfirm", is_flag=True, help="don't ask for confirmation") 345 | @click.option("--nocolor", is_flag=True, help="strip ANSI styles from output") 346 | @click.argument("package", nargs=-1) 347 | def main(action, uninstalled, signed_off, quiet, username, password, package, 348 | db_path, noconfirm, nocolor): 349 | """ 350 | Interface with Arch Linux package signoffs. 351 | """ 352 | if action is None: 353 | if package: 354 | action = "signoff" 355 | else: 356 | action = "list" 357 | 358 | options = Options( 359 | action=action, 360 | show_uninstalled=uninstalled, 361 | show_signed_off=signed_off, 362 | quiet=quiet, 363 | packages=set(package), 364 | db_path=db_path, 365 | username=username, 366 | noconfirm=noconfirm, 367 | nocolor=nocolor) 368 | 369 | # initialize alpm handle and signoff session 370 | try: 371 | alpm_handle = pyalpm.Handle("/", options.db_path) 372 | except pyalpm.error: 373 | click.echo("error: could not read alpm database {}".format(options.db_path), 374 | err=True) 375 | sys.exit(1) 376 | 377 | session = SignoffSession(options.username, password) 378 | 379 | if options.nocolor or os.environ.get("TERM") == "dumb": 380 | colorize = False 381 | else: 382 | colorize = True 383 | 384 | # fetch and filter signoff packages 385 | signoffs = list(list_signoffs(session, alpm_handle)) 386 | packages = list(filter_signoffs(signoffs, options)) 387 | pkgbases = set(signoff_pkg["pkgbase"] for signoff_pkg, _ in packages) 388 | 389 | # if packages are supplied as parameters, validate them 390 | for pkgbase in options.packages: 391 | if pkgbase not in pkgbases: 392 | raise click.BadParameter( 393 | "package base {} not found in signoffs".format(pkgbase)) 394 | 395 | if action == "list": # output packages and exit 396 | for signoff_pkg, local_pkg in packages: 397 | click.echo(format_signoff(signoff_pkg, local_pkg, options), color=colorize) 398 | if not options.quiet: 399 | click.echo() # add a line between packages 400 | elif action == "signoff": # sign-off packages 401 | for signoff_pkg, local_pkg in packages: 402 | if not local_pkg: 403 | raise click.UsageError("{} package not installed".format(signoff_pkg['pkgbase'])) 404 | warn_if_outdated(signoff_pkg, local_pkg, colorize) 405 | warn_if_bad(signoff_pkg, colorize) 406 | if options.noconfirm or confirm("Sign off {}?".format( 407 | click.style(" ".join(pkgbases), bold=True)), colorize): 408 | for signoff_pkg, local_pkg in packages: 409 | try: 410 | session.signoff_package(signoff_pkg) 411 | except requests.exceptions.HTTPError as e: 412 | click.echo("Could not sign off {} ({})".format(signoff_pkg["pkgbase"], e)) 413 | else: 414 | click.echo("Signed off {}.".format(signoff_pkg["pkgbase"])) 415 | elif action == "revoke": # revoke sign-offs 416 | for signoff_pkg, local_pkg in packages: 417 | if not local_pkg: 418 | raise click.UsageError("{} package not installed".format(signoff_pkg['pkgbase'])) 419 | warn_if_outdated(signoff_pkg, local_pkg, colorize) 420 | warn_if_bad(signoff_pkg, colorize) 421 | if options.noconfirm or confirm("Revoke sign-off for {}?".format( 422 | click.style(" ".join(pkgbases), bold=True)), colorize): 423 | for signoff_pkg, local_pkg in packages: 424 | session.revoke_package(signoff_pkg) 425 | click.echo("Revoked sign-off for {}.".format( 426 | signoff_pkg["pkgbase"])) 427 | elif action == "interactive": # interactively sign-off or revoke 428 | for signoff_pkg, local_pkg in packages: 429 | click.echo(format_signoff(signoff_pkg, local_pkg, options)) 430 | warn_if_outdated(signoff_pkg, local_pkg, colorize) 431 | warn_if_bad(signoff_pkg, colorize) 432 | if not options.quiet: 433 | click.echo() 434 | 435 | # check if we're signing off or revoking 436 | pkgbase = signoff_pkg["pkgbase"] 437 | signed_off = signoff_status(signoff_pkg, 438 | options.username) == "signed-off" 439 | 440 | if signed_off: 441 | prompt = "Revoke sign-off for {}?".format(pkgbase) 442 | else: 443 | prompt = "Sign off {}?".format(pkgbase) 444 | 445 | # confirm and signoff/revoke 446 | if confirm(prompt, colorize): 447 | if signed_off: 448 | session.revoke_package(signoff_pkg) 449 | click.echo("Revoked sign-off for {}.".format(pkgbase)) 450 | else: 451 | session.signoff_package(signoff_pkg) 452 | click.echo("Signed off {}.".format(pkgbase)) 453 | 454 | click.echo() 455 | 456 | session.logout() 457 | 458 | 459 | if __name__ == "__main__": # pragma: no cover 460 | main() 461 | -------------------------------------------------------------------------------- /test/test_cli.py: -------------------------------------------------------------------------------- 1 | from click.testing import CliRunner 2 | 3 | import pytest 4 | 5 | from signoff import main as entrypoint, SignoffSession 6 | 7 | runner = CliRunner() 8 | 9 | STANDARD_ARGS = ["--username", "test", "--password", "test"] 10 | 11 | 12 | @pytest.fixture 13 | def mock_get_signoffs_empty(monkeypatch): 14 | def mock_get_signoffs_empty(*args, **kwargs): 15 | return [] 16 | 17 | monkeypatch.setattr(SignoffSession, "get_signoffs", mock_get_signoffs_empty) 18 | 19 | 20 | @pytest.fixture 21 | def mock_signoff_package(monkeypatch): 22 | def mock_signoff_package(*args, **kwargs): 23 | return 24 | 25 | monkeypatch.setattr(SignoffSession, "signoff_package", mock_signoff_package) 26 | 27 | 28 | @pytest.fixture 29 | def mock_revoke_package(monkeypatch): 30 | def mock_revoke_package(*args, **kwargs): 31 | return 32 | 33 | monkeypatch.setattr(SignoffSession, "revoke_package", mock_revoke_package) 34 | 35 | 36 | @pytest.fixture 37 | def mock_get_signoffs(monkeypatch): 38 | def mock_get_signoffs(*args, **kwargs): 39 | return [ 40 | { 41 | "arch": "x86_64", 42 | "last_update": "2021-04-12T01:00:22.740Z", 43 | "maintainers": ["maintainer"], 44 | "packager": "packager", 45 | "pkgbase": "linux", 46 | "repo": "Testing", 47 | "signoffs": [], 48 | "target_repo": "Core", 49 | "version": "5.5.3.arch1-1", 50 | "pkgnames": ["linux"], 51 | "package_count": 1, 52 | "approved": False, 53 | "required": 2, 54 | "enabled": True, 55 | "known_bad": False, 56 | "comments": "new release" 57 | } 58 | ] 59 | 60 | monkeypatch.setattr(SignoffSession, "get_signoffs", mock_get_signoffs) 61 | 62 | 63 | @pytest.fixture 64 | def mock_login(monkeypatch): 65 | def mock_login(*args, **kwargs): 66 | return None 67 | 68 | monkeypatch.setattr(SignoffSession, "_login", mock_login) 69 | 70 | 71 | @pytest.fixture(scope="session") 72 | def empty_localdb(generate_localdb): 73 | '''Returns the location to the local db''' 74 | 75 | return generate_localdb([]) 76 | 77 | 78 | @pytest.fixture(scope="session") 79 | def localdb(generate_localdb): 80 | '''Returns the location to the local db''' 81 | 82 | data = [{ 83 | "name": "linux", 84 | "base": "linux", 85 | "arch": "x86_64", 86 | "csize": "2483776", 87 | "version": "5.5.3.arch1-1", 88 | "builddate": "1573556456", 89 | "desc": "The linux kernel and modules", 90 | "url": "https://kernel.org", 91 | "license": "GPL2", 92 | "packager": "Arch Dev ", 93 | "conflicts": [], 94 | "replaces": [], 95 | "depends": ["coreutils"], 96 | "makedepends": ["bc"], 97 | "optdepends": ["vim"] 98 | }] 99 | return generate_localdb(data) 100 | 101 | 102 | def test_no_db(): 103 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--list']) 104 | assert result.exit_code == 2 105 | 106 | 107 | def test_list(mock_login, mock_get_signoffs, localdb): 108 | '''List to be signed off packages''' 109 | 110 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--list', '--db-path', localdb]) 111 | assert result.exit_code == 0 112 | assert 'linux' in result.output 113 | 114 | 115 | def test_list_empty_localdb(mock_login, mock_get_signoffs, empty_localdb): 116 | '''The local db is empty, signoffs contain linux''' 117 | 118 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--list', '--db-path', empty_localdb]) 119 | assert result.exit_code == 0 120 | assert result.output == '' 121 | 122 | 123 | def test_list_empty_signoffs(mock_login, mock_get_signoffs_empty, localdb): 124 | '''The local db contains linux, signoffs are empty''' 125 | 126 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--list', '--db-path', localdb]) 127 | assert result.exit_code == 0 128 | assert result.output == '' 129 | 130 | 131 | def test_signoff(mock_login, mock_get_signoffs, mock_signoff_package, localdb): 132 | '''Signoff the Linux package''' 133 | 134 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--signoff', 'linux', '--noconfirm', '--db-path', localdb]) 135 | assert result.exit_code == 0 136 | assert result.output == 'Signed off linux.\n' 137 | 138 | 139 | def test_signoff_non_existant_package(mock_login, mock_get_signoffs, mock_signoff_package, empty_localdb): 140 | '''Signoff a non-existant package''' 141 | 142 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--signoff', 'linux', '--noconfirm', '--db-path', empty_localdb]) 143 | assert result.exit_code == 2 144 | assert 'Error: linux package not installed' in result.output 145 | 146 | 147 | def test_revoke_non_existant_package(mock_login, mock_get_signoffs, mock_revoke_package, empty_localdb): 148 | '''Revoke non-existant package''' 149 | 150 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--revoke', 'linux', '--noconfirm', '--db-path', empty_localdb]) 151 | assert result.exit_code == 2 152 | assert 'Error: linux package not installed' in result.output 153 | 154 | 155 | def test_revoke(mock_login, mock_get_signoffs, mock_revoke_package, localdb): 156 | '''Revoke non-existant package''' 157 | 158 | result = runner.invoke(entrypoint, STANDARD_ARGS + ['--revoke', 'linux', '--noconfirm', '--db-path', localdb]) 159 | assert result.exit_code == 0 160 | assert result.output == 'Revoked sign-off for linux.\n' 161 | --------------------------------------------------------------------------------