├── PKGBUILD ├── README.md └── agetpkg /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Sébastien Luttringer 2 | 3 | pkgname=agetpkg-git 4 | pkgver="$(git log --pretty=format:''|wc -l)" 5 | pkgrel=1 6 | pkgdesc='Arch Linux Archive Get Package (Git version)' 7 | arch=('any') 8 | url='https://github.com/seblu/agetpkg' 9 | license=('GPL2') 10 | makedepends=('git') 11 | depends=('python' 'python-xdg') 12 | conflicts=('agetpkg') 13 | provides=('agetpkg') 14 | 15 | package() { 16 | cd "$startdir" 17 | install -Dm755 agetpkg "$pkgdir/usr/bin/agetpkg" 18 | } 19 | 20 | # vim:set ts=2 sw=2 et: 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | agetpkg 2 | ======= 3 | 4 | Introduction 5 | ------------ 6 | **agetpkg** is a command line tool used to quickly list/get/install packages stored on the [Arch Linux Archive](https://wiki.archlinux.org/index.php/Arch_Linux_Archive). 7 | 8 | **agetpkg** means **A**rchive **G**et **P**ackage. 9 | 10 | Usage 11 | ----- 12 | 13 | ###### Download a previous version of ferm package 14 | ```bash 15 | agetpkg ferm 16 | ``` 17 | 18 | ###### Download xterm version 296 19 | ```bash 20 | agetpkg ^xterm 296 21 | # or 22 | agetpkg -g ^xterm 296 23 | ``` 24 | 25 | ###### List all zsh versions 26 | ```bash 27 | agetpkg -l zsh$ 28 | ``` 29 | 30 | ###### Install all gvfs packages in version 1.26.0 release 3 31 | ```bash 32 | agetpkg -i gvfs 1.26.0 3 33 | ``` 34 | 35 | ###### Download all pwgen packages 36 | ```bash 37 | agetpkg -g -a pwgen 38 | ``` 39 | 40 | ###### List only i686 packages of nftables 41 | ```bash 42 | agetpkg -l -A i686 -- nftables 43 | ``` 44 | 45 | ###### Force update of the index before listing packages matching i3 46 | ```bash 47 | agetpkg -u -l i3-wm 48 | ``` 49 | 50 | ###### Use another archive url 51 | ```bash 52 | agetpkg --url http://archlinux.arkena.net/archive/packages/.all/ -l bash$ 53 | ``` 54 | or 55 | ```bash 56 | export ARCHIVE_URL=http://archlinux.arkena.net/archive/packages/.all/ 57 | agetpkg -l bash$ 58 | ``` 59 | 60 | ###### Run agetpkg in debug mode 61 | ```bash 62 | agetpkg --debug -l coreutils 63 | ``` 64 | 65 | ###### Display current version 66 | ```bash 67 | agetpkg --version 68 | ``` 69 | 70 | Installation 71 | ------------ 72 | 73 | To install the current released version of **agetpkg**, run: 74 | ```bash 75 | pacman -S agetpkg 76 | ``` 77 | 78 | To install the git development version, run: 79 | ```bash 80 | makepkg -i 81 | ``` 82 | 83 | Dependencies 84 | ------------ 85 | - [Python 3.x](http://python.org/download/releases/) 86 | - [PyXDG](http://freedesktop.org/wiki/Software/pyxdg) 87 | 88 | Sources 89 | ------- 90 | **agetpkg** sources are available on [github](https://github.com/seblu/agetpkg/). 91 | 92 | License 93 | ------- 94 | **agetpkg** is licensied under the term of [GPL v2](http://www.gnu.org/licenses/gpl-2.0.html). 95 | 96 | AUTHOR 97 | ------ 98 | **agetpkg** was started by Sébastien Luttringer in September 2015. 99 | -------------------------------------------------------------------------------- /agetpkg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | # agetpkg - Archive Get Package 5 | # Copyright © 2017 Sébastien Luttringer 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License 9 | # as published by the Free Software Foundation; either version 2 10 | # of the License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 | 21 | '''Arch Linux Archive Get Package''' 22 | 23 | from argparse import ArgumentParser 24 | from collections import OrderedDict 25 | from email.utils import parsedate 26 | from logging import getLogger, error, warn, debug, DEBUG 27 | from lzma import open as xzopen 28 | from os import stat, uname, getcwd, chdir, geteuid, environ 29 | from os.path import basename, exists, join 30 | from pprint import pprint 31 | from re import match, compile as recompile 32 | from shutil import copyfileobj 33 | from subprocess import call 34 | from tempfile import TemporaryDirectory 35 | from time import mktime, time 36 | from urllib.request import urlopen, Request 37 | from xdg.BaseDirectory import save_cache_path 38 | 39 | # magics 40 | NAME = "agetpkg" 41 | VERSION = "4" 42 | ARCHIVE_URL = "https://archive.archlinux.org/packages/.all/" 43 | REPORT_URL = "https://github.com/seblu/agetpkg/issues" 44 | INDEX_FILENAME = "index.0.xz" 45 | PKG_EXT = [".pkg.tar.zst", ".pkg.tar.xz"] 46 | SIG_EXT = ".sig" 47 | 48 | class Error(Exception): 49 | """Error""" 50 | ERR_USAGE = 1 51 | ERR_FATAL = 2 52 | ERR_ABORT = 3 53 | ERR_UNKNOWN = 4 54 | 55 | class Url(object): 56 | """Remote Ressource""" 57 | 58 | HTTP_HEADERS = { 59 | "User-Agent": f"{NAME} v{VERSION}", 60 | } 61 | 62 | def __init__(self, url, timeout): 63 | self.url = url 64 | self.timeout = timeout 65 | 66 | def __str__(self): 67 | return self.url 68 | 69 | @property 70 | def exists(self): 71 | try: 72 | self.headers 73 | return True 74 | except Exception: 75 | return False 76 | 77 | @property 78 | def size(self): 79 | """Return the remote file size""" 80 | try: 81 | return int(self.headers["Content-Length"]) 82 | except Exception as exp: 83 | raise Error(f"Failed to get size of {self.url}: {exp}") 84 | 85 | @property 86 | def lastmod(self): 87 | try: 88 | return int(mktime(parsedate(self.headers["Last-Modified"]))) 89 | except Exception as exp: 90 | raise Error(f"Failed to get last modification date of {self.url}: {exp}") 91 | 92 | @property 93 | def headers(self): 94 | """Return a dict with url headers""" 95 | if not hasattr(self, "_headers"): 96 | try: 97 | debug(f"Request headers on URL: {self.url}") 98 | url_req = Request(self.url, method="HEAD", headers=self.HTTP_HEADERS) 99 | remote_fd = urlopen(url_req, timeout=self.timeout) 100 | self._headers = dict(remote_fd.getheaders()) 101 | except Exception as exp: 102 | raise Error(f"Failed to get headers at {self}: {exp}") 103 | return getattr(self, "_headers") 104 | 105 | def download(self, destination, overwrite=True): 106 | """Download URL to destination""" 107 | if not overwrite and exists(destination): 108 | raise Error(f"Local file {destination} already exists") 109 | debug(f"Downloading from : {self.url}") 110 | debug(f" to : {destination}") 111 | try: 112 | url_req = Request(self.url, headers=self.HTTP_HEADERS) 113 | remote_fd = urlopen(url_req, timeout=self.timeout) 114 | local_fd = open(destination, "wb") 115 | copyfileobj(remote_fd, local_fd) 116 | except Exception as exp: 117 | raise Error(f"Failed to download {self}: {exp}") 118 | 119 | class Package(Url): 120 | """Abstract a specific package version""" 121 | 122 | def __init__(self, repourl, basename, timeout): 123 | self.repourl = repourl 124 | self.basename = basename 125 | self.timeout = timeout 126 | # regex is not strict, but we are not validating something here 127 | m = match(r"^([\w@._+-]+)-((?:(\d+):)?([^-]+)-([^-]+))-(\w+)", basename) 128 | if m is None: 129 | raise Error(f"Unable to parse package info from: {basename}") 130 | (self.name, self.full_version, self.epoch, self.version, self.release, 131 | self.arch) = m.groups() 132 | # no epoch means 0 (man PKGBUILD) 133 | if self.epoch is None: 134 | self.epoch = 0 135 | 136 | def __str__(self): 137 | return f"{self.name} {self.full_version} {self.arch}" 138 | 139 | def __getitem__(self, key): 140 | try: 141 | return getattr(self, key) 142 | except AttributeError: 143 | raise KeyError() 144 | 145 | @property 146 | def url(self): 147 | """The URL object of the package""" 148 | if not hasattr(self, "_url"): 149 | # index.0 format doesn't ship files extension, so we have to guess it. 150 | for ext in PKG_EXT: 151 | self._url = Url(self.repourl + self.basename + ext, self.timeout) 152 | if self._url.exists: 153 | return self._url 154 | raise Error(f"Unable to find package at {self.repourl}{self.basename}{PKG_EXT}") 155 | return self._url 156 | 157 | @property 158 | def filename(self): 159 | """Return package filename""" 160 | return basename(str(self.url)) 161 | 162 | @property 163 | def size(self): 164 | """Return package Content-Length (size in bytes)""" 165 | return self.url.size 166 | 167 | @property 168 | def lastmod(self): 169 | """Return package Last-Modified date (in seconds since epoch)""" 170 | return self.url.lastmod 171 | 172 | def get(self, overwrite=False): 173 | """Download the package locally""" 174 | self.url.download(self.filename, overwrite) 175 | # try to get signature when available 176 | sigurl = Url(str(self.url) + SIG_EXT, self.timeout) 177 | if sigurl.exists: 178 | sigurl.download(self.filename + SIG_EXT, overwrite) 179 | 180 | class Archive(object): 181 | """Abstract access to the package Archive""" 182 | 183 | def __init__(self, url, timeout, update=1): 184 | """Init the Archive interface 185 | url of the archive (flat style) 186 | update = update the local index cache (0: never, 1: when needed, 2: always) 187 | timeout = the socket timeout for network requests 188 | """ 189 | if url[-1] != "/": 190 | raise Error("Archive URL must end with a /") 191 | self.url = url 192 | self.remote_index = Url(self.url + INDEX_FILENAME, timeout) 193 | self.local_index = join(save_cache_path(NAME), INDEX_FILENAME) 194 | self.timeout = timeout 195 | if update > 0: 196 | self.update_index(update == 2) 197 | self._load_index() 198 | 199 | def _load_index(self): 200 | debug(f"Loading index from {self.local_index}") 201 | fd = xzopen(self.local_index, "rb") 202 | self._index = OrderedDict() 203 | for line in fd.readlines(): 204 | key = line.decode().rstrip() 205 | self._index[key] = Package(self.url, key, self.timeout) 206 | debug(f"Index loaded: {len(self._index)} packages") 207 | 208 | def update_index(self, force=False): 209 | """Update index remotely when needed""" 210 | debug("Check remote index for upgrade") 211 | if force: 212 | return self.remote_index.download(self.local_index) 213 | # get remote info 214 | try: 215 | remote_size = self.remote_index.size 216 | remote_lastmod = self.remote_index.lastmod 217 | except Exception as exp: 218 | debug(f"Failed to get remote index size/lastmod: {exp}") 219 | return self.remote_index.download(self.local_index) 220 | # get local info 221 | try: 222 | local_st = stat(self.local_index) 223 | except Exception as exp: 224 | debug(f"Failed to get local stat: {exp}") 225 | return self.remote_index.download(self.local_index) 226 | # compare size 227 | if remote_size != local_st.st_size: 228 | debug(f"Size differ between remote and local index ({remote_size} vs {local_st.st_size})") 229 | return self.remote_index.download(self.local_index) 230 | # compare date 231 | elif remote_lastmod > local_st.st_mtime: 232 | debug(f"Remote index is newer than local, updating it ({remote_lastmod} vs {local_st.st_mtime})") 233 | return self.remote_index.download(self.local_index) 234 | debug("Remote and local indexes seems equal. No update.") 235 | 236 | def search(self, name_pattern, version_pattern, release_pattern, arch_list=None): 237 | """Search for a package """ 238 | name_regex = recompile(name_pattern) 239 | version_regex = recompile(version_pattern) if version_pattern is not None else None 240 | release_regex = recompile(release_pattern) if release_pattern is not None else None 241 | res = list() 242 | for pkg in self._index.values(): 243 | if name_regex.search(pkg.name): 244 | # check against arch 245 | if arch_list is not None and len(arch_list) > 0: 246 | if pkg.arch not in arch_list: 247 | continue 248 | # check against version 249 | if version_regex is not None: 250 | if not version_regex.search(pkg.version): 251 | continue 252 | # check against release 253 | if release_regex is not None: 254 | if not release_regex.search(pkg.release): 255 | continue 256 | res += [pkg] 257 | return res 258 | 259 | def which(binary): 260 | """lookup if bin exists into PATH""" 261 | dirs = environ.get("PATH", "").split(":") 262 | for d in dirs: 263 | if exists(join(d, binary)): 264 | return True 265 | return False 266 | 267 | def pacman(args, asroot=True): 268 | """execute pacman (optionally as root)""" 269 | cmd = ["pacman" ] + args 270 | # add sudo or su if not root and 271 | if asroot and geteuid() != 0: 272 | if which("sudo"): 273 | cmd = ["sudo"] + cmd 274 | elif which("su"): 275 | cmd = ["su", "root", f"-c={' '.join(cmd)}" ] 276 | else: 277 | error(f"Unable to execute as root: {' '.join(cmd)}") 278 | debug(f"calling: {cmd}") 279 | call(cmd, close_fds=True) 280 | 281 | def list_packages(packages, long=False): 282 | """display a list of packages on stdout""" 283 | if long: 284 | pattern = "%(name)s %(full_version)s %(arch)s %(size)s %(lastmod)s %(url)s" 285 | else: 286 | pattern = "%(name)s %(full_version)s %(arch)s" 287 | for package in packages: 288 | print(pattern % package) 289 | 290 | def select_packages(packages, select_all=False): 291 | """select a package in a list""" 292 | # shortcut to one package 293 | if len(packages) == 1: 294 | yield packages[0] 295 | elif select_all: 296 | for pkg in packages: 297 | yield pkg 298 | else: 299 | # display a list of packages to select 300 | index = dict(enumerate(packages)) 301 | pad = len(f"{max(index.keys()):d}") 302 | for i, pkg in index.items(): 303 | print(f"{i:{pad}} {pkg}") 304 | selection = "" 305 | while not match(r"^(\d+ ){0,}\d+$", selection): 306 | try: 307 | selection = input("Select packages (* for all): ").strip() 308 | except EOFError: 309 | return 310 | if selection == "": 311 | return 312 | # shortcut to select all packages 313 | if selection == "*": 314 | for pkg in packages: 315 | yield pkg 316 | return 317 | # parse selection 318 | numbers = [ int(x) for x in selection.split(" ") ] 319 | for num in numbers: 320 | if num in index.keys(): 321 | yield index[num] 322 | else: 323 | warn(f"No package n°{num}") 324 | 325 | def get_packages(packages, select_all=False): 326 | """download packages""" 327 | for pkg in select_packages(packages, select_all): 328 | pkg.get() 329 | 330 | def install_packages(packages, select_all=False): 331 | """install packages in one shot to allow deps to work""" 332 | packages = list(select_packages(packages, select_all)) 333 | with TemporaryDirectory() as tmpdir: 334 | cwd = getcwd() 335 | chdir(tmpdir) 336 | for pkg in packages: 337 | pkg.get() 338 | pacman(["-U"] + [ pkg.filename for pkg in packages ]) 339 | chdir(cwd) 340 | 341 | def parse_argv(): 342 | '''Parse command line arguments''' 343 | local_arch = uname().machine 344 | p_main = ArgumentParser(prog=NAME) 345 | # update index options 346 | g_update = p_main.add_mutually_exclusive_group() 347 | g_update.add_argument("-u", "--force-update", 348 | action="store_const", dest="update", const=2, default=1, 349 | help="force index update") 350 | g_update.add_argument("-U", "--no-update", 351 | action="store_const", dest="update", const=0, 352 | help="disable index update") 353 | # action mode options 354 | g_action = p_main.add_mutually_exclusive_group() 355 | g_action.add_argument("-g", "--get", action="store_const", dest="mode", 356 | const="get", help="get matching packages (default mode)") 357 | g_action.add_argument("-l", "--list", action="store_const", dest="mode", 358 | const="list", help="only list matching packages") 359 | g_action.add_argument("-i", "--install", action="store_const", dest="mode", 360 | const="install", help="install matching packages") 361 | # common options 362 | p_main.add_argument("-a", "--all", action="store_true", 363 | help="select all packages without prompting") 364 | p_main.add_argument("-A", "--arch", nargs="*", default=[local_arch, "any"], 365 | help=f"filter by architectures (default: {local_arch} and any. empty means all)") 366 | p_main.add_argument("-v", "--verbose", action="store_true", 367 | help="display more information") 368 | p_main.add_argument("--url", help=f"archive URL, default: {ARCHIVE_URL}", 369 | default=environ.get("ARCHIVE_URL", ARCHIVE_URL)) 370 | p_main.add_argument("-t", "--timeout", default=10, 371 | help="connection timeout (default: 10s)") 372 | p_main.add_argument("--version", action="version", 373 | version=f"{NAME} version {VERSION}") 374 | p_main.add_argument("--debug", action="store_true", 375 | help="debug mode") 376 | # positional args 377 | p_main.add_argument("package", 378 | help="regex to match a package name") 379 | p_main.add_argument("version", nargs="?", 380 | help="regex to match a package version") 381 | p_main.add_argument("release", nargs="?", 382 | help="regex to match a package release") 383 | return p_main.parse_args() 384 | 385 | def main(): 386 | '''Program entry point''' 387 | try: 388 | # parse command line 389 | args = parse_argv() 390 | # set global debug mode 391 | if args.debug: 392 | getLogger().setLevel(DEBUG) 393 | # init archive interface 394 | archive = Archive(args.url, args.timeout, args.update) 395 | # select target pacakges 396 | packages = archive.search(args.package, args.version, args.release, args.arch) 397 | if len(packages) == 0: 398 | print("No match found.") 399 | exit(0) 400 | if args.mode == "list": 401 | list_packages(packages, long=args.verbose) 402 | elif args.mode == "install": 403 | install_packages(packages, args.all) 404 | else: 405 | get_packages(packages, args.all) 406 | except KeyboardInterrupt: 407 | exit(Error.ERR_ABORT) 408 | except Error as exp: 409 | error(exp) 410 | exit(Error.ERR_FATAL) 411 | except Exception as exp: 412 | error(f"Unknown error. Please report it with --debug at {REPORT_URL}.") 413 | error(exp) 414 | if getLogger().getEffectiveLevel() == DEBUG: 415 | raise 416 | exit(Error.ERR_UNKNOWN) 417 | 418 | if __name__ == '__main__': 419 | main() 420 | 421 | # vim:set ts=4 sw=4 et ai: 422 | --------------------------------------------------------------------------------