usm-spm-apt 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. #!/usr/bin/env python3
  2. """USM system-package-manager helper for APT-based systems.
  3. Implements the USM SPM contract (see usm/README.md, "System package manager
  4. integration") for Debian-style systems:
  5. usm-spm-apt query <usm-ref>... -> contract JSON on STDOUT
  6. usm-spm-apt install <name>... -> contract JSONL events on STDOUT
  7. File queries are answered from apt-file's Contents index ONLY. This helper
  8. therefore requires apt-file to be installed and its index updated
  9. (`apt-file update`; ~100 MB of Contents per enabled architecture -- the
  10. container bootstrap used by `usm deploy --spm apt` performs both). There
  11. are no package-name heuristics: any ref whose translated path has no owner
  12. in the index lands in not-found. The two sanctioned exceptions are curated
  13. tables: SONAME_PACKAGES (sonames whose Debian package lags the upstream
  14. soname the manifests reference) and PACKAGE_REMAPS (owners remapped to the
  15. package that makes the toolchain usable, e.g. valac-bin to the valac
  16. meta-package carrying the split-out vapis). One
  17. deviation from the DNF helper, which
  18. unions repository filelists with the local rpmdb: files that exist only on
  19. the local system (packages installed from .debs outside the configured
  20. repositories) are invisible to apt-file, so such refs land in not-found.
  21. The DPKG database is consulted for package NAMES only, to compute
  22. installed-dependency-count.
  23. The query translation mirrors spm/dnf with Debian multiarch paths,
  24. preferring x86_64 and following the machine architecture otherwise:
  25. bin/sbin/libexec -> /usr/bin, /usr/sbin, /usr/bin (Debian ships helper
  26. executables under /usr/bin; no /usr/libexec tree)
  27. lib -> /usr/lib/<triple>/<soname> and /lib/<triple>/<soname>
  28. (usr-merge: both path forms appear across suites)
  29. pc -> /usr/lib/<triple>/pkgconfig/<n> and /usr/share/pkgconfig/<n>
  30. inc -> /usr/include/<P> prefix
  31. typelib -> /usr/lib/<triple>/girepository-1.0/<n>
  32. vapi -> /usr/share/vala/vapi/<n> and /usr/share/vala-*/vapi/<n>
  33. gir/res/cfg/man/info/locale/rootpath/tag -> as in the DNF table
  34. apt-file is invoked as `apt-file search --fixed-string` (exact paths) and
  35. `apt-file search --substring-match` (anchored prefixes: inc: and the
  36. versioned vapi directory), with all patterns of one mode batched via
  37. --from-file (falling back to one invocation per pattern on apt-file builds
  38. without --from-file). apt-file 3.3 reads --fixed-string as a full-path
  39. exact match and exits 1 without diagnostics when a pattern has no owner,
  40. which this helper treats as not-found rather than failure; every result is
  41. additionally re-validated against an exact or anchored-prefix path
  42. predicate before being credited to a ref, so no over-matches leak into the
  43. contract output.
  44. dependency-count models "a solo install on this machine" exactly like the
  45. DNF helper's repository-only sack: python3-apt resolves the package with
  46. DPkg status pointed at /dev/null (nothing installed), so every dependency
  47. resolves as an install; the overlap with the real DPKG status is reported
  48. as installed-dependency-count. If the python3-apt cache cannot be built or
  49. a package cannot be marked, counts degrade to the DNF helper's fallback
  50. (1, installed ? 1 : 0) with a note on STDERR.
  51. Install runs one `apt-get install -y` transaction with
  52. DEBIAN_FRONTEND=noninteractive and never prompts. An `apt-get --simulate`
  53. pass first provides the transaction size and order for the begin event;
  54. real-run "Get:", "Unpacking" and "Setting up" lines are then mapped to
  55. (coalesced) package progress events and package-complete events. There is
  56. no --test mode: apt cannot run the dpkg transaction phases that the DNF
  57. --test mode exercises. Exit codes match the contract table, with 5 meaning
  58. a dpkg transaction failure (apt's analogue of the rpm code).
  59. """
  60. import argparse
  61. import json
  62. import os
  63. import platform
  64. import re
  65. import shutil
  66. import subprocess
  67. import sys
  68. import tempfile
  69. import apt.progress.base
  70. import apt_pkg
  71. MULTIARCH_TRIPLES = {
  72. "x86_64": "x86_64-linux-gnu",
  73. "aarch64": "aarch64-linux-gnu",
  74. "armv7l": "arm-linux-gnueabihf",
  75. "i386": "i386-linux-gnu",
  76. "i686": "i386-linux-gnu",
  77. "ppc64le": "powerpc64le-linux-gnu",
  78. "s390x": "s390x-linux-gnu",
  79. }
  80. TRIPLE = MULTIARCH_TRIPLES.get(platform.machine(), "x86_64-linux-gnu")
  81. # Sonames whose Debian package lags the soname the Web-Stack manifests
  82. # reference: the Contents index has no owner for the exact path, but the
  83. # library is present under an older soname (libsodium23 ships
  84. # libsodium.so.23 while the manifests reference the upstream .26). Curated
  85. # like the emerge helper's atom exception table; entries are validated
  86. # against the apt cache so a stale mapping degrades to not-found instead
  87. # of resolving a ghost package.
  88. SONAME_PACKAGES = {
  89. "libsodium.so.26": "libsodium23",
  90. }
  91. # Owners the Contents index reports for a file, remapped to the package
  92. # that actually makes the toolchain usable: Debian splits valac's vapis
  93. # into valac-<version>-vapi, which valac-bin does not depend on, and keeps
  94. # g-ir-compiler's real binary in gobject-introspection-bin-linux, which
  95. # gobject-introspection-bin (owner of the dangling /usr/bin symlink) does
  96. # not depend on either; the valac and gobject-introspection meta-packages
  97. # pull each split together. Answering with the meta-package keeps one
  98. # install transaction sufficient.
  99. PACKAGE_REMAPS = {
  100. "valac-bin": "valac",
  101. "gobject-introspection-bin": "gobject-introspection",
  102. }
  103. EXIT_OK = 0
  104. EXIT_FAILURE = 1
  105. EXIT_RESOLVE = 3
  106. EXIT_DOWNLOAD = 4
  107. EXIT_TRANSACTION = 5
  108. GET_LINE = re.compile(r"^Get:\d+\s")
  109. UNPACK_LINE = re.compile(r"^Unpacking\s+(\S+)")
  110. SETUP_LINE = re.compile(r"^Setting up\s+(\S+)")
  111. INST_LINE = re.compile(r"^Inst\s+(\S+)")
  112. ERROR_LINE = re.compile(r"^E:\s+")
  113. class HelperError(Exception):
  114. """Fatal helper failure; message is emitted as a contract error event."""
  115. def __init__(self, message, exit_code=EXIT_FAILURE):
  116. super(HelperError, self).__init__(message)
  117. self.exit_code = exit_code
  118. def emit_event(event):
  119. """Write one contract JSONL event to STDOUT and flush it."""
  120. sys.stdout.write(json.dumps(event) + "\n")
  121. sys.stdout.flush()
  122. def fail(message, exit_code=EXIT_FAILURE):
  123. """Emit the terminal error event and exit non-zero."""
  124. emit_event({"type": "error", "message": message})
  125. sys.exit(exit_code)
  126. def fail_plain(message, exit_code=EXIT_FAILURE):
  127. """Report a query failure on STDERR only, keeping STDOUT contract-clean."""
  128. sys.stderr.write("usm-spm-apt: %s\n" % message)
  129. sys.exit(exit_code)
  130. def plain_name(token):
  131. """Strip a multiarch :arch qualifier from an apt/dpkg package token."""
  132. return token.split(":", 1)[0]
  133. def exact(path):
  134. """Descriptor for one exact indexed path (apt-file --fixed-string)."""
  135. return ("exact", path, lambda candidate, path=path: candidate == path)
  136. def prefixed(path):
  137. """Descriptor matching indexed paths under path (--substring-match)."""
  138. return ("substring", path,
  139. lambda candidate, path=path: candidate.startswith(path))
  140. def vapi_versioned(resource):
  141. """Descriptor for /usr/share/vala-*/vapi/<resource>."""
  142. return (
  143. "substring",
  144. "/usr/share/vala-",
  145. lambda candidate, resource=resource: (
  146. candidate.startswith("/usr/share/vala-")
  147. and candidate.endswith("/vapi/" + resource)
  148. ),
  149. )
  150. def translate_ref(ref):
  151. """Map a USM resource ref to apt-file (mode, pattern, predicate) triples.
  152. Mode is "exact" for --fixed-string full-path matches and "substring"
  153. for --substring-match anchored prefixes. Returns None for refs whose
  154. type has no file translation.
  155. """
  156. prefix, sep, resource = ref.partition(":")
  157. if not sep or not resource or any(ord(c) < 32 for c in ref):
  158. return None
  159. file_roots = {
  160. "bin": "/usr/bin",
  161. "sbin": "/usr/sbin",
  162. "libexec": "/usr/bin",
  163. "gir": "/usr/share/gir-1.0",
  164. "res": "/usr/share",
  165. "cfg": "/etc",
  166. "man": "/usr/share/man",
  167. "info": "/usr/share/info",
  168. "locale": "/usr/share/locale",
  169. }
  170. if prefix in file_roots:
  171. return [exact(file_roots[prefix] + "/" + resource)]
  172. if prefix == "typelib":
  173. return [exact("/usr/lib/%s/girepository-1.0/%s" % (TRIPLE, resource))]
  174. if prefix == "vapi":
  175. return [
  176. exact("/usr/share/vala/vapi/" + resource),
  177. vapi_versioned(resource),
  178. ]
  179. if prefix == "lib":
  180. return [
  181. exact("/usr/lib/%s/%s" % (TRIPLE, resource)),
  182. exact("/lib/%s/%s" % (TRIPLE, resource)),
  183. ]
  184. if prefix == "pc":
  185. return [
  186. exact("/usr/lib/%s/pkgconfig/%s" % (TRIPLE, resource)),
  187. exact("/usr/share/pkgconfig/" + resource),
  188. ]
  189. if prefix == "inc":
  190. return [prefixed("/usr/include/" + resource)]
  191. if prefix == "rootpath":
  192. return [exact("/" + resource.lstrip("/"))]
  193. if prefix == "tag":
  194. return [exact("/usr/share/usm-tags/" + resource.replace(".", "/") + ".tag")]
  195. return None
  196. def first_error_line(text):
  197. for line in text.splitlines():
  198. if ERROR_LINE.match(line):
  199. return line.strip()
  200. return None
  201. def parse_owner_lines(text):
  202. """Extract (package, path) pairs from apt-file's "package: path" lines.
  203. apt-file's progress chatter and anything that does not look like an
  204. absolute path after "package: " is ignored.
  205. """
  206. owners = []
  207. for line in text.splitlines():
  208. package, sep, path = line.strip().partition(": ")
  209. if not sep or not path.startswith("/") or " " in package:
  210. continue
  211. owners.append((plain_name(package), path))
  212. return owners
  213. def run_apt_file(arguments):
  214. if shutil.which("apt-file") is None:
  215. raise HelperError(
  216. "apt-file is not installed; the SPM bootstrap must install it "
  217. "and run apt-file update", EXIT_RESOLVE)
  218. return subprocess.run(
  219. ["apt-file", "search"] + arguments,
  220. stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  221. universal_newlines=True,
  222. )
  223. def option_unsupported(stderr):
  224. lowered = stderr.lower()
  225. return any(marker in lowered for marker in
  226. ("unknown option", "unrecognized", "usage:", "usage error"))
  227. def apt_file_search(mode, patterns):
  228. """Search the apt-file Contents index for every pattern in one mode."""
  229. results = []
  230. if not patterns:
  231. return results
  232. flags = ["--fixed-string"] if mode == "exact" else ["--substring-match"]
  233. handle = tempfile.NamedTemporaryFile(
  234. "w", suffix=".usm-spm-apt-patterns", delete=False)
  235. try:
  236. handle.write("\n".join(patterns) + "\n")
  237. handle.close()
  238. process = run_apt_file(flags + ["--from-file", handle.name])
  239. if process.returncode == 0:
  240. return parse_owner_lines(process.stdout)
  241. if option_unsupported(process.stderr):
  242. for pattern in patterns:
  243. process = run_apt_file(flags + [pattern])
  244. if process.returncode != 0 and first_error_line(
  245. process.stderr + process.stdout) is not None:
  246. raise HelperError(
  247. first_error_line(process.stderr + process.stdout),
  248. EXIT_RESOLVE)
  249. results.extend(parse_owner_lines(process.stdout))
  250. return results
  251. error = first_error_line(process.stderr + process.stdout)
  252. if error is not None:
  253. raise HelperError(error, EXIT_RESOLVE)
  254. return results
  255. finally:
  256. os.unlink(handle.name)
  257. def dpkg_installed_names():
  258. """Names of every installed package, from the dpkg status database."""
  259. try:
  260. process = subprocess.run(
  261. ["dpkg-query", "--show", "--showformat", "${Package}\n"],
  262. stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  263. universal_newlines=True)
  264. except OSError:
  265. return set()
  266. if process.returncode != 0:
  267. return set()
  268. return set(process.stdout.split())
  269. def build_solo_cache():
  270. """An apt cache whose dpkg status is empty (nothing installed).
  271. The DNF helper builds its solo-install estimates against a repository-
  272. only sack; pointing Dir::State::status at /dev/null is apt's analogue,
  273. so every dependency of a marked package resolves as an install.
  274. """
  275. apt_pkg.init()
  276. apt_pkg.config.set("Dir::State::status", os.devnull)
  277. return apt_pkg.Cache(apt.progress.base.OpProgress())
  278. def solo_closure(cache, installed_names, name, closure_cache):
  279. """(dependency-count, installed-dependency-count) for a solo install."""
  280. if name in closure_cache:
  281. return closure_cache[name]
  282. counts = (1, 1 if name in installed_names else 0)
  283. if cache is None:
  284. sys.stderr.write(
  285. "usm-spm-apt: no python3-apt cache available, estimating "
  286. "dependency counts of %s as installed-only\n" % name)
  287. closure_cache[name] = counts
  288. return counts
  289. try:
  290. package = cache[name]
  291. except KeyError:
  292. package = None
  293. if package is None:
  294. sys.stderr.write(
  295. "usm-spm-apt: %s not found in enabled repositories, "
  296. "estimating dependency counts as installed-only\n" % name)
  297. closure_cache[name] = counts
  298. return counts
  299. depcache = apt_pkg.DepCache(cache)
  300. depcache.init()
  301. if depcache.get_candidate_ver(package) is None:
  302. sys.stderr.write(
  303. "usm-spm-apt: %s has no installation candidate, "
  304. "estimating dependency counts as installed-only\n" % name)
  305. closure_cache[name] = counts
  306. return counts
  307. try:
  308. depcache.mark_install(package, True, True)
  309. closure = [p.name for p in cache.packages if depcache.marked_install(p)]
  310. counts = (
  311. len(closure),
  312. sum(1 for member in closure if member in installed_names),
  313. )
  314. except SystemError as e:
  315. sys.stderr.write(
  316. "usm-spm-apt: could not resolve solo install of %s: %s\n"
  317. % (name, e))
  318. closure_cache[name] = counts
  319. return counts
  320. def soname_exception_package(soname):
  321. """The cached package for a {@link SONAME_PACKAGES} soname, or None.
  322. The mapping is only trusted when the apt cache actually offers the
  323. package, so a stale table entry degrades to not-found.
  324. """
  325. name = SONAME_PACKAGES.get(soname)
  326. if name is None:
  327. return None
  328. try:
  329. cache = apt.Cache()
  330. if name in cache and cache[name].candidate is not None:
  331. return name
  332. except Exception:
  333. pass
  334. return None
  335. def cached_package_name(name):
  336. """The remapped {@link PACKAGE_REMAPS} target for an owner, or None.
  337. The remap is only trusted when the apt cache actually offers the
  338. target, so a stale table entry keeps the index-reported owner.
  339. """
  340. target = PACKAGE_REMAPS.get(name)
  341. if target is None:
  342. return None
  343. try:
  344. cache = apt.Cache()
  345. if target in cache and cache[target].candidate is not None:
  346. return target
  347. except Exception:
  348. pass
  349. return None
  350. def run_query(args):
  351. refs = list(dict.fromkeys(args.refs))
  352. translated = {}
  353. patterns = {"exact": set(), "substring": set()}
  354. for ref in refs:
  355. descriptors = translate_ref(ref)
  356. if descriptors is not None:
  357. translated[ref] = descriptors
  358. for mode, pattern, _ in descriptors:
  359. patterns[mode].add(pattern)
  360. index = []
  361. for mode in ("exact", "substring"):
  362. index.extend(apt_file_search(mode, sorted(patterns[mode])))
  363. not_found = []
  364. candidates = {}
  365. for ref in refs:
  366. descriptors = translated.get(ref)
  367. if descriptors is None:
  368. sys.stderr.write(
  369. "usm-spm-apt: resource type of \"%s\" has no "
  370. "system-package-manager translation\n" % ref)
  371. not_found.append(ref)
  372. continue
  373. matched = set()
  374. for mode, pattern, predicate in descriptors:
  375. for name, path in index:
  376. if predicate(path):
  377. matched.add(name)
  378. remapped = set()
  379. for name in sorted(matched):
  380. remapped.add(cached_package_name(name) or name)
  381. matched = remapped
  382. if not matched and ref.startswith("lib:"):
  383. mapped = soname_exception_package(ref[len("lib:"):])
  384. if mapped is not None:
  385. matched.add(mapped)
  386. if not matched:
  387. not_found.append(ref)
  388. continue
  389. for name in matched:
  390. entry = candidates.get(name)
  391. if entry is None:
  392. entry = {"resources": []}
  393. candidates[name] = entry
  394. entry["resources"].append(ref)
  395. installed_names = dpkg_installed_names()
  396. solo_cache = build_solo_cache() if candidates else None
  397. closure_cache = {}
  398. packages = []
  399. for name in sorted(candidates):
  400. dependency_count, installed_dependency_count = solo_closure(
  401. solo_cache, installed_names, name, closure_cache)
  402. packages.append({
  403. "name": name,
  404. "resources": candidates[name]["resources"],
  405. "dependency-count": dependency_count,
  406. "installed-dependency-count": installed_dependency_count,
  407. })
  408. sys.stdout.write(json.dumps({
  409. "not-found": not_found,
  410. "packages": packages,
  411. }) + "\n")
  412. sys.stdout.flush()
  413. return EXIT_OK
  414. def apt_environment():
  415. environment = dict(os.environ)
  416. environment["DEBIAN_FRONTEND"] = "noninteractive"
  417. environment["LC_ALL"] = "C"
  418. return environment
  419. def classify_apt_failure(output):
  420. lowered = output.lower()
  421. if "failed to fetch" in lowered or "unable to fetch" in lowered:
  422. return EXIT_DOWNLOAD
  423. if ("unable to locate" in lowered or "unmet dependencies" in lowered
  424. or "no installation candidate" in lowered):
  425. return EXIT_RESOLVE
  426. return EXIT_TRANSACTION
  427. def collect_error_messages(output, limit=5):
  428. messages = [line.strip() for line in output.splitlines()
  429. if ERROR_LINE.match(line)]
  430. return "; ".join(messages[-limit:])
  431. def simulate_install(names, environment):
  432. """Ordered package list of the would-be transaction, from -s output."""
  433. process = subprocess.run(
  434. ["apt-get", "--simulate", "install"] + names,
  435. stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  436. universal_newlines=True, env=environment)
  437. if process.returncode != 0:
  438. combined = process.stderr + process.stdout
  439. raise HelperError(
  440. collect_error_messages(combined)
  441. or "apt-get simulation failed for %s" % " ".join(names),
  442. classify_apt_failure(combined))
  443. order = []
  444. for line in process.stdout.splitlines():
  445. match = INST_LINE.match(line)
  446. if match:
  447. name = plain_name(match.group(1))
  448. if name not in order:
  449. order.append(name)
  450. return order
  451. def cmd_query(args):
  452. try:
  453. return run_query(args)
  454. except SystemExit:
  455. raise
  456. except HelperError as e:
  457. fail_plain(str(e), e.exit_code)
  458. except Exception as e:
  459. fail_plain("query failed: %s" % e, EXIT_RESOLVE)
  460. class InstallProgress:
  461. """Maps apt-get output lines to contract package/complete events."""
  462. def __init__(self, order):
  463. self.position = {name: index for index, name in enumerate(order, 1)}
  464. self.total = len(order)
  465. self.completed = 0
  466. self.last = None
  467. def emit_package(self, name, progress):
  468. if name not in self.position:
  469. return
  470. event = ("package", name, self.position[name], self.total,
  471. round(progress, 4))
  472. if event == self.last:
  473. return
  474. self.last = event
  475. emit_event({
  476. "type": "package",
  477. "name": name,
  478. "current": self.position[name],
  479. "total": self.total,
  480. "progress": round(progress, 4),
  481. })
  482. def feed(self, line):
  483. match = GET_LINE.match(line)
  484. if match:
  485. for token in line.split()[1:]:
  486. candidate = plain_name(token)
  487. if candidate in self.position:
  488. self.emit_package(candidate, 0.0)
  489. break
  490. return
  491. match = UNPACK_LINE.match(line)
  492. if match:
  493. self.emit_package(plain_name(match.group(1)), 0.5)
  494. return
  495. match = SETUP_LINE.match(line)
  496. if match:
  497. name = plain_name(match.group(1))
  498. if name in self.position:
  499. self.emit_package(name, 1.0)
  500. emit_event({"type": "package-complete", "name": name})
  501. self.completed += 1
  502. def cmd_install(args):
  503. environment = apt_environment()
  504. try:
  505. order = simulate_install(args.names, environment)
  506. except HelperError as e:
  507. fail(str(e), e.exit_code)
  508. emit_event({"type": "begin", "total": len(order)})
  509. progress = InstallProgress(order)
  510. process = subprocess.Popen(
  511. ["apt-get", "install", "-y"] + args.names,
  512. stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
  513. universal_newlines=True, env=environment)
  514. output = []
  515. for line in process.stdout:
  516. output.append(line)
  517. progress.feed(line.rstrip("\r\n"))
  518. process.stdout.close()
  519. returncode = process.wait()
  520. transcript = "".join(output)
  521. if returncode != 0:
  522. fail(collect_error_messages(transcript)
  523. or "apt-get install failed with exit code %d" % returncode,
  524. classify_apt_failure(transcript))
  525. emit_event({
  526. "type": "complete",
  527. "status": "ok",
  528. "installed": progress.completed,
  529. })
  530. return EXIT_OK
  531. def main():
  532. parser = argparse.ArgumentParser(
  533. prog="usm-spm-apt",
  534. description="USM system-package-manager helper for APT")
  535. subparsers = parser.add_subparsers(dest="command", required=True)
  536. query_parser = subparsers.add_parser(
  537. "query", help="resolve USM resource refs to system packages")
  538. query_parser.add_argument(
  539. "refs", nargs="+", metavar="USM-REF",
  540. help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
  541. query_parser.set_defaults(handler=cmd_query)
  542. install_parser = subparsers.add_parser(
  543. "install", help="install system packages, streaming progress events")
  544. install_parser.add_argument(
  545. "names", nargs="+", metavar="NAME",
  546. help="native system package name")
  547. install_parser.set_defaults(handler=cmd_install)
  548. args = parser.parse_args()
  549. try:
  550. return args.handler(args)
  551. except HelperError as e:
  552. fail(str(e), e.exit_code)
  553. except SystemExit:
  554. raise
  555. except Exception as e:
  556. fail("unexpected failure: %s" % e, EXIT_FAILURE)
  557. if __name__ == "__main__":
  558. sys.exit(main())