#!/usr/bin/env python3 """ uisp — CLI for UISP at $UISP_URL (default https://uisp.vntx.net). Auth: export UISP_KEY=. Subcommands: list [--unauthorized] [--upgradable] [--json] [--raw] approve ... | --all [--site vntx] [--port 443] [--dry-run] upgrade ... | --all [--yes] [--force] prune [--days 1] [--yes] delete devices offline longer than N days """ from __future__ import annotations import argparse import datetime as dt import json import os import ssl import sys import time import urllib.error import urllib.parse import urllib.request from collections import Counter from typing import Any, Iterable DEFAULT_BASE = os.environ.get("UISP_URL", "https://uisp.vntx.net").rstrip("/") TOKEN = os.environ.get("UISP_KEY") or os.environ.get("UISP_TOKEN") # Ordered list — first cred that lets UISP adopt a device wins. STANDARD_CREDS = [ ("ubnt", "fngckewl"), ("ubnt", "vntx1830"), ("ubnt", "Vntx1830"), ("ubnt", "vntx1830vntx"), ("ubnt", "H8xd9tkryg"), ("ubnt", "h8xd9tkryg"), ] # discovery.status values that mean "trying again with a new password might help". RETRYABLE_DISCOVERY_STATUSES = { "authentication-failed", "authenticating", "starting", } def die(msg: str, code: int = 1) -> None: print(f"error: {msg}", file=sys.stderr) sys.exit(code) def api(method: str, path: str, body: Any = None) -> Any: if not TOKEN: die("UISP_KEY env var is not set") url = f"{DEFAULT_BASE}/nms/api/v2.1{path}" data = None if body is not None: data = json.dumps(body).encode() req = urllib.request.Request(url, data=data, method=method) req.add_header("X-Auth-Token", TOKEN) req.add_header("Accept", "application/json") if body is not None: req.add_header("Content-Type", "application/json") ctx = ssl.create_default_context() try: with urllib.request.urlopen(req, context=ctx, timeout=60) as resp: raw = resp.read() except urllib.error.HTTPError as e: raw = e.read() try: payload = json.loads(raw) msg = payload.get("message") or payload except Exception: msg = raw.decode("utf-8", "replace")[:200] die(f"{method} {url}: HTTP {e.code}: {msg}") if not raw: return None return json.loads(raw) # ---------- Helpers ---------- def disp_name(d: dict) -> str: ident = d.get("identification") or {} return ident.get("hostname") or ident.get("name") or ident.get("displayName") or ident.get("mac") or "?" def parse_version(v: str) -> tuple[int, ...]: """Loose semver parse: '6.3.24-cs' -> (6, 3, 24).""" if not v: return () v = v.lstrip("v") head = "" for ch in v: if ch in "-+ ": break head += ch parts = [] for chunk in head.split("."): n = 0 for ch in chunk: if not ch.isdigit(): break n = n * 10 + int(ch) parts.append(n) return tuple(parts) def version_lt(a: str, b: str) -> bool: return parse_version(a) < parse_version(b) def latest_firmware_for(fws: list[dict], d: dict) -> str: ident = d.get("identification") or {} plat = ident.get("platformId") model = ident.get("model") if not plat or not model: return "" best = "" for fw in fws: ident_fw = fw.get("identification") or {} if ident_fw.get("platformId") != plat: continue models = ident_fw.get("models") or [] if models and model not in models: continue if not ident_fw.get("stable", True): continue v = ident_fw.get("version") or "" if not best or version_lt(best, v): best = v return best def needs_upgrade(d: dict, fws: list[dict]) -> bool: ident = d.get("identification") or {} if ident.get("type") == "blackBox": return False model = ident.get("model") if not model or model == "UNKNOWN": return False cur = ident.get("firmwareVersion") or (d.get("firmware") or {}).get("current") or "" if not cur: return False latest = latest_firmware_for(fws, d) if not latest: return False return version_lt(cur, latest) def is_unauthorized(d: dict) -> bool: ident = d.get("identification") or {} overview = d.get("overview") or {} return not ident.get("authorized", True) or overview.get("status") == "discovered" # ---------- Commands ---------- def cmd_list(args: argparse.Namespace) -> None: if args.unauthorized: devs = api("GET", "/devices/discovered") or [] # SNMP-discovered stubs come back as model=UNKNOWN and aren't actually # adoptable. Hide by default to keep the pending-adoption view useful. if not args.include_unknown: devs = [d for d in devs if (d.get("identification") or {}).get("model") not in ("", None, "UNKNOWN")] else: devs = api("GET", "/devices?withInterfaces=false") if args.raw: json.dump(devs, sys.stdout, indent=2) print() return fws = api("GET", "/firmwares") or [] rows = [] for d in devs: if args.upgradable and not needs_upgrade(d, fws): continue rows.append(d) rows.sort(key=disp_name) if args.json: json.dump(rows, sys.stdout, indent=2) print() return headers = ["ID", "NAME", "MODEL", "SITE", "FW", "STATUS", "AUTH", "UPGRADE"] table = [] for d in rows: ident = d.get("identification") or {} ov = d.get("overview") or {} cur = ident.get("firmwareVersion") or (d.get("firmware") or {}).get("current") or "" latest = latest_firmware_for(fws, d) fw = cur or "-" if latest and latest != cur and version_lt(cur, latest): fw = f"{cur} → {latest}" site_name = "" site = ident.get("site") if isinstance(site, dict): site_name = site.get("name") or "" table.append([ ident.get("id", ""), disp_name(d), ident.get("modelName") or ident.get("model") or "", site_name, fw, ov.get("status") or "", "yes" if ident.get("authorized") else "NO", "yes" if needs_upgrade(d, fws) else "", ]) widths = [max(len(h), *(len(r[i]) for r in table)) if table else len(h) for i, h in enumerate(headers)] print(" ".join(h.ljust(w) for h, w in zip(headers, widths))) for r in table: print(" ".join(c.ljust(w) for c, w in zip(r, widths))) def lookup_site_id(name: str) -> str: sites = api("GET", "/sites") want = name.strip().lower() for s in sites: ident = s.get("identification") or {} n = ident.get("name") or s.get("name") or "" if n.lower() == want: return ident.get("id") or s.get("id") die(f"site {name!r} not found") def wait_settle(ids: list[str], timeout_sec: int = 60) -> dict[str, dict]: """Poll /devices/discovered until none of the given IDs report isProcessing=true (or timeout). Returns the latest discovery dict per ID.""" want = set(ids) deadline = time.time() + timeout_sec last: dict[str, dict] = {} while True: discovered = api("GET", "/devices/discovered") or [] last = {} still = 0 for d in discovered: ident = d.get("identification") or {} did = ident.get("id") if did not in want: continue disc = d.get("discovery") or {} last[did] = {**disc, "_device": d} if disc.get("isProcessing"): still += 1 if still == 0 or time.time() > deadline: print(f" settled (still processing: {still})") return last print(f" ...waiting on {still} device(s)") time.sleep(5) def cmd_approve(args: argparse.Namespace) -> None: discovered = api("GET", "/devices/discovered") or [] by_id = {d["identification"]["id"]: d for d in discovered} if args.all: queue = list(discovered) else: if not args.ids: die("usage: uisp approve ... | --all") queue = [] for did in args.ids: if did not in by_id: print(f"skip {did}: not in discovered list", file=sys.stderr) continue queue.append(by_id[did]) # Devices with model "UNKNOWN" are SNMP-discovered stubs UISP can't manage — # connect/ubnt always fails on them, so don't burn cycles trying. if not args.include_unknown: before = len(queue) queue = [d for d in queue if (d.get("identification") or {}).get("model") not in ("", None, "UNKNOWN")] skipped = before - len(queue) if skipped: print(f"skipping {skipped} device(s) with model=UNKNOWN (use --include-unknown to override)") if not queue: print("no devices pending adoption.") return queue_ids = [d["identification"]["id"] for d in queue] print(f"queue ({len(queue)} device(s)):") for d in queue: ident = d["identification"] print(f" {ident['id']} {disp_name(d):<32} {ident.get('modelName') or ident.get('model','')}") site_id = "" if args.site: site_id = lookup_site_id(args.site) print(f"site {args.site!r} resolved to id {site_id}") if args.dry_run: print(f"\ndry-run: would try {len(STANDARD_CREDS)} credential combo(s)" + (f" then assign to site {args.site!r}" if site_id else "")) return # Cred sweep — each pass only triggers connect on still-retryable IDs. retry_ids = list(queue_ids) for i, (user, pw) in enumerate(STANDARD_CREDS, 1): if not retry_ids: break print(f"\n[{i}/{len(STANDARD_CREDS)}] trying user={user} pw={pw} on {len(retry_ids)} device(s)...") try: api("POST", "/discovery/connect/ubnt", { "deviceIds": retry_ids, "username": user, "password": pw, "httpsPort": args.port, }) except SystemExit: print(" connect call failed; moving on", file=sys.stderr) continue statuses = wait_settle(retry_ids, timeout_sec=args.poll_timeout) # Keep only IDs that are still retryable for the next cred. next_retry = [] for did in retry_ids: st = statuses.get(did, {}).get("status", "") if st in RETRYABLE_DISCOVERY_STATUSES: next_retry.append(did) retry_ids = next_retry # Final reconciliation. fleet = api("GET", "/devices?withInterfaces=false") or [] fleet_by_id = {d["identification"]["id"]: d for d in fleet} final_disc = {d["identification"]["id"]: d for d in (api("GET", "/devices/discovered") or [])} adopted, failed = [], [] for did in queue_ids: d = fleet_by_id.get(did) if d and d["identification"].get("type") != "blackBox": adopted.append(did) else: failed.append(did) if adopted: print(f"\nadopted {len(adopted)} device(s):") for did in adopted: print(f" {did} {disp_name(fleet_by_id[did])}") if failed: # Group by status for a tidy summary. groups: dict[str, list[tuple[str, str, str]]] = {} for did in failed: d = final_disc.get(did) or by_id.get(did) or {} disc = d.get("discovery") or {} status = disc.get("status") or "unknown" err = disc.get("error") or "" groups.setdefault(status, []).append((did, disp_name(d), err)) print(f"\n{len(failed)} device(s) still pending:") for status in sorted(groups): entries = groups[status] print(f" [{status}] ({len(entries)})") for did, name, err in entries: line = f" {did} {name}" if err: line += f" — {err}" print(line) if site_id and adopted: print(f"\nassigning {len(adopted)} adopted device(s) to site {args.site!r}...") api("POST", "/devices/authorize", {"siteId": site_id, "deviceIds": adopted}) print(" done.") def parse_uisp_time(s: str | None) -> dt.datetime | None: """UISP returns ISO 8601 like '2026-04-15T00:08:42.587Z'. Returns aware UTC dt.""" if not s: return None s = s.replace("Z", "+00:00") try: return dt.datetime.fromisoformat(s) except ValueError: return None def cmd_prune(args: argparse.Namespace) -> None: devs = api("GET", "/devices?withInterfaces=false") or [] now = dt.datetime.now(dt.timezone.utc) cutoff = now - dt.timedelta(days=args.days) stale = [] for d in devs: ident = d.get("identification") or {} ov = d.get("overview") or {} last_seen = parse_uisp_time(ov.get("lastSeen")) if last_seen is not None and last_seen >= cutoff: continue if last_seen is None and args.skip_never_seen: continue # Skip backbone routers / switches by default — losing one of these from # UISP just because it's not reporting in would be an own-goal. if not args.include_infra and ident.get("type") in {"erouter", "eswitch", "olt"}: continue if not args.include_infra and ident.get("role") in {"router", "switch", "gateway"}: continue stale.append((d, last_seen)) if not stale: print(f"no devices offline longer than {args.days} day(s).") return stale.sort(key=lambda t: t[1] or dt.datetime.min.replace(tzinfo=dt.timezone.utc)) # Summary buckets so the user can sanity-check before approving. summary: Counter = Counter() type_summary: Counter = Counter() for d, last_seen in stale: if last_seen is None: bucket = "never seen" else: age_days = (now - last_seen).days if age_days > 30: bucket = ">30d offline" elif age_days > 7: bucket = "7-30d offline" else: bucket = "1-7d offline" summary[bucket] += 1 type_summary[(bucket, (d.get("identification") or {}).get("type") or "?")] += 1 print(f"{len(stale)} device(s) eligible for prune (offline > {args.days}d, including never-seen={'no' if args.skip_never_seen else 'yes'}):") for bucket, n in summary.most_common(): print(f" {n:>4} {bucket}") print() print("by bucket × type:") for (bucket, t), n in sorted(type_summary.items()): print(f" {n:>4} {bucket:<14} type={t}") print() if args.verbose: print("detail:") for d, last_seen in stale: ident = d["identification"] site_name = "" if isinstance(ident.get("site"), dict): site_name = ident["site"].get("name") or "" age = "never seen" if last_seen is None else f"{(now - last_seen).days}d" print(f" {ident['id']} {disp_name(d):<30} " f"{ident.get('modelName') or ident.get('model',''):<22} " f"site={site_name:<14} offline={age}") print() if not args.yes: print(f"dry-run: pass --yes to delete these {len(stale)} device(s) (use --verbose to see each one).") return ids = [d["identification"]["id"] for d, _ in stale] api("POST", "/devices/bulkdelete", {"ids": ids}) print(f"deleted {len(ids)} device(s).") # Whitelist of fields UISP's PUT /nms/settings will accept. Extracted from the # UI bundle (`hZr` array). Anything else triggers a 400 like "X is not allowed". SETTINGS_PUT_ALLOWED = { "allowAutoUpdateUbntFirmwares", "allowBetaFirmwares", "allowLoggingToLogentries", "allowLoggingToSentry", "allowNewFirmware", "autoUpdatePlatforms", "country", "dateFormat", "defaultGracePeriod", "defaultQosPropagation", "devicePingAddress", "devicePingAddressMode", "devicePingIntervalNormal", "devicePingIntervalOutage", "deviceTransmissionFrequencies", "deviceTransmissionProfile", "deviceUpdateNotification", "discoveryAllowLocalScan", "discoveryAllowRemoteScan", "discoveryAllowUnsecuredChannels", "discoveryAutoConfiguration", "discoveryBlacklist", "discoveryHideBlackBox", "discoveryNotification", "discoverySnmpCommunity", "googleMapsApiKey", "homePage", "hostname", "isOnuDisabledOnSubscriberSuspend", "maintenanceWindowFriday", "maintenanceWindowFromTime", "maintenanceWindowMonday", "maintenanceWindowSaturday", "maintenanceWindowSunday", "maintenanceWindowThursday", "maintenanceWindowToTime", "maintenanceWindowTuesday", "maintenanceWindowWednesday", "mapsProvider", "migrationForceModeEnabled", "migrationWithBackupEnabled", "migrationUispKey", "migrationHostname", "migrationModeEnabled", "migrationPort", "outageMailablePeriod", "parallelUpgradeLimit", "restartGracePeriod", "tableDensity", "timeFormat", "timezone", "trafficShapingAdjustment", "upgradeGracePeriod", "useLetsEncrypt", } def put_nms_settings(updates: dict) -> None: """GET current settings, merge updates, filter to PUT-allowed fields, PUT.""" current = api("GET", "/nms/settings") or {} merged = {**current, **updates} body = {k: v for k, v in merged.items() if k in SETTINGS_PUT_ALLOWED} api("PUT", "/nms/settings", body) def cmd_ignore(args: argparse.Namespace) -> None: """Add IPs of model=UNKNOWN devices to UISP's discoveryBlacklist so they stop reappearing, then delete the stub records.""" # Pull from both /devices/discovered (pending) and /devices (adopted but # blackBox) since the SNMP-stub population shows up in both lists. discovered = api("GET", "/devices/discovered") or [] fleet = api("GET", "/devices?withInterfaces=false") or [] pool = [] for d in discovered + fleet: ident = d.get("identification") or {} if ident.get("model") in ("", None, "UNKNOWN") or ident.get("type") == "blackBox": pool.append(d) # Dedupe by id (a device can appear in both lists). seen_ids: set[str] = set() targets = [] for d in pool: did = (d.get("identification") or {}).get("id") if did and did not in seen_ids: seen_ids.add(did) targets.append(d) ip_set: dict[str, dict] = {} # ip -> first device that has it no_ip = [] for d in targets: ip = d.get("ipAddress") or "" ip = ip.split("/")[0].strip() if ip: ip_set.setdefault(ip, d) else: no_ip.append(d) if not ip_set and not no_ip: print("no UNKNOWN/blackBox devices found.") return settings = api("GET", "/nms/settings") current_bl = settings.get("discoveryBlacklist") or [] if isinstance(current_bl, str): current_bl = [x.strip() for x in current_bl.split(",") if x.strip()] current_set = set(current_bl) new_ips = sorted(ip for ip in ip_set if ip not in current_set) print(f"found {len(targets)} UNKNOWN/blackBox device(s) " f"({len(ip_set)} unique IP(s), {len(no_ip)} without IP)") print(f"already blacklisted: {len(current_set)}") print(f"new IPs to blacklist: {len(new_ips)}") if new_ips and args.verbose: for ip in new_ips: d = ip_set[ip] ident = d["identification"] print(f" {ip:<16} {ident.get('hostname') or ident.get('name') or '?'}") if args.dry_run: print(f"\ndry-run: would add {len(new_ips)} IP(s) to discoveryBlacklist " f"and delete {len(targets)} stub record(s).") return if new_ips: merged = sorted(current_set | set(new_ips)) put_nms_settings({"discoveryBlacklist": merged}) print(f"blacklist updated: {len(current_set)} → {len(merged)} entries") if targets: ids = [d["identification"]["id"] for d in targets] # bulkdelete handles batches comfortably; UISP returns 200 with no body. api("POST", "/devices/bulkdelete", {"ids": ids}) print(f"deleted {len(ids)} stub record(s).") def cmd_upgrade(args: argparse.Namespace) -> None: devs = api("GET", "/devices?withInterfaces=false") or [] fws = api("GET", "/firmwares") or [] by_id = {d["identification"]["id"]: d for d in devs} targets = [] if args.all: for d in devs: if needs_upgrade(d, fws): targets.append(d) else: if not args.ids: die("usage: uisp upgrade ... | --all") for did in args.ids: d = by_id.get(did) if not d: print(f"skip {did}: not found", file=sys.stderr) continue if not needs_upgrade(d, fws) and not args.force: ident = d["identification"] cur = ident.get("firmwareVersion") or "?" latest = latest_firmware_for(fws, d) or "?" print(f"skip {did} ({disp_name(d)}): already on {cur} (latest {latest}) — pass --force to override", file=sys.stderr) continue targets.append(d) if not targets: print("no devices need upgrade.") return print(f"upgrade plan ({len(targets)} device(s)):") for d in targets: ident = d["identification"] cur = ident.get("firmwareVersion") or "?" latest = latest_firmware_for(fws, d) or "?" print(f" {ident['id']} {disp_name(d):<30} {cur} → {latest}") if not args.yes: print(f"\ndry-run: pass --yes to actually trigger upgrades.") return batch_size = max(1, args.batch_size) n_batches = (len(targets) + batch_size - 1) // batch_size failed_total = 0 for batch_idx in range(n_batches): batch = targets[batch_idx * batch_size:(batch_idx + 1) * batch_size] print(f"\n=== batch {batch_idx + 1}/{n_batches}: {len(batch)} device(s) ===") # Build the upgrades payload and POST one task per device. UISP's # actual upgrade endpoint is POST /tasks with # {upgrades: [{deviceId, firmwareVersion}], upgradeInMaintenanceWindow}. # /devices/{id}/update only refreshes device data — it does NOT # trigger a firmware upgrade. triggered: list[tuple[str, str, str]] = [] # (id, name, target_version) upgrades = [] for d in batch: did = d["identification"]["id"] target_v = latest_firmware_for(fws, d) if not target_v: print(f" skip {did} {disp_name(d)}: no catalog firmware match", file=sys.stderr) failed_total += 1 continue upgrades.append({ "deviceId": did, "firmwareVersion": target_v, }) triggered.append((did, disp_name(d), target_v)) if upgrades: try: api("POST", "/tasks", { "upgrades": upgrades, "upgradeInMaintenanceWindow": False, }) for did, name, tv in triggered: print(f" trigger OK {did} {name:<30} → {tv}") except SystemExit: print(f" batch trigger FAILED for {len(upgrades)} device(s)", file=sys.stderr) failed_total += len(upgrades) triggered = [] if not args.wait: # No completion wait — just space batches out so we don't blast # the whole fleet simultaneously. if batch_idx + 1 < n_batches: print(f" sleeping {args.batch_delay}s before next batch...") time.sleep(args.batch_delay) continue # Wait for every device in this batch to land on its target version. deadline = time.time() + args.wait_timeout pending = {did: (name, tv) for did, name, tv in triggered} while pending and time.time() < deadline: time.sleep(20) current = api("GET", "/devices?withInterfaces=false") or [] cur_by_id = {x["identification"]["id"]: x for x in current} for did in list(pending.keys()): name, tv = pending[did] d = cur_by_id.get(did) if not d: continue cur = d["identification"].get("firmwareVersion") or "" if cur and tv != "?" and not version_lt(cur, tv): print(f" done {did} {name:<30} on {cur}") del pending[did] if pending: print(f" ...waiting on {len(pending)} device(s) " f"({int(deadline - time.time())}s left)") if pending: print(f" batch timeout: {len(pending)} device(s) didn't finish in {args.wait_timeout}s; " f"continuing anyway") for did, (name, tv) in pending.items(): print(f" still pending {did} {name} → {tv}") failed_total += 1 if failed_total: print(f"\n{failed_total} device(s) had problems (failed trigger or didn't complete in time).") else: print(f"\nall {len(targets)} device(s) processed.") # ---------- argparse wiring ---------- def main() -> None: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = p.add_subparsers(dest="cmd", required=True) pl = sub.add_parser("list", help="list devices") pl.add_argument("--unauthorized", action="store_true", help="show only pending-adoption devices") pl.add_argument("--upgradable", action="store_true", help="show only devices with a firmware update") pl.add_argument("--json", action="store_true", help="emit parsed JSON") pl.add_argument("--raw", action="store_true", help="dump raw API JSON (debug)") pl.add_argument("--include-unknown", action="store_true", help="include model=UNKNOWN devices in --unauthorized output (hidden by default)") pl.set_defaults(func=cmd_list) pa = sub.add_parser("approve", help="adopt pending-adoption devices") pa.add_argument("ids", nargs="*", help="device IDs to adopt") pa.add_argument("--all", action="store_true", help="adopt every pending-adoption device") pa.add_argument("--site", default="vntx", help="site name to assign adopted devices to (empty = skip)") pa.add_argument("--port", type=int, default=443, help="HTTPS port UISP uses to reach the device") pa.add_argument("--poll-timeout", type=int, default=60, help="seconds to wait for each cred attempt to settle") pa.add_argument("--dry-run", action="store_true") pa.add_argument("--include-unknown", action="store_true", help="also try to adopt devices with model=UNKNOWN (SNMP stubs; almost never adoptable)") pa.set_defaults(func=cmd_approve) pi = sub.add_parser("ignore", help="blacklist IPs of UNKNOWN/blackBox devices and delete the stubs so they stop coming back") pi.add_argument("--dry-run", action="store_true") pi.add_argument("--verbose", "-v", action="store_true", help="list every IP being added") pi.set_defaults(func=cmd_ignore) pp = sub.add_parser("prune", help="delete devices offline longer than N days") pp.add_argument("--days", type=float, default=1.0, help="offline threshold in days (default: 1)") pp.add_argument("--yes", action="store_true", help="actually delete (default: dry-run)") pp.add_argument("--include-infra", action="store_true", help="also prune routers/switches/OLTs (default: skipped)") pp.add_argument("--skip-never-seen", action="store_true", help="exclude devices with no lastSeen timestamp (SNMP stubs that never adopted)") pp.add_argument("--verbose", "-v", action="store_true", help="list every device, not just summary") pp.set_defaults(func=cmd_prune) pu = sub.add_parser("upgrade", help="upgrade firmware") pu.add_argument("ids", nargs="*", help="device IDs to upgrade") pu.add_argument("--all", action="store_true", help="upgrade every device with a newer firmware available") pu.add_argument("--yes", action="store_true", help="actually trigger upgrades (default: dry-run)") pu.add_argument("--force", action="store_true", help="upgrade even if already on latest") pu.add_argument("--batch-size", type=int, default=5, help="how many devices to upgrade in parallel per batch (default: 5)") pu.add_argument("--wait", action="store_true", help="wait for each batch to land on the new firmware before starting the next") pu.add_argument("--wait-timeout", type=int, default=900, help="seconds to wait per batch when --wait is set (default: 900)") pu.add_argument("--batch-delay", type=int, default=30, help="seconds to sleep between batches when --wait is NOT set (default: 30)") pu.set_defaults(func=cmd_upgrade) args = p.parse_args() args.func(args) if __name__ == "__main__": main()