"""Cliget - install cli tools in you user profile Usage: cliget [-v] [-c URL] search PAT cliget [-v] [-c URL] info TOOL cliget [-v] [-c URL] list cliget [-v] [-c URL] versions TOOLS ... cliget [-v] [-c URL] allversions TOOL cliget [-v] [-c URL] install TOOLS ... cliget [-v] [-c URL] update [--all] [TOOLS ...] search search catalog for tools with the given pattern info show details of an entry in the catalog list list all installed tools versions TOOLS list current and latest versions of some tools allversions TOOL list all versions of a tool install TOOLS install some tools update list all updatable tools update TOOLS update tools update --all update all updatable tools Options: -h, --help -c, --catalog URL -v, --verbose """ import logging import sys, os, shutil from docopt import docopt from yaml import load, SafeLoader from semver import VersionInfo import re from subprocess import run, CalledProcessError, TimeoutExpired from fuzzywuzzy import fuzz import requests def trace(*mess): if "TRACE" in os.environ: print("TRACE", mess) def info(*mess): print(*mess) def warn(*mess): print("WARNING:", *mess) class DotDict(dict): def __getattr__(self, name): return self[name] if name in self else None def _load_catalog(options) -> dict: catalog = options.get( "__catalog", "https://codeberg.org/setop/cliget/raw/branch/main/catalog.yaml" ) if catalog.startswith("http"): # IMPROVE cache catalog for some time r = requests.get(catalog) o = load(r.content, SafeLoader) else: o = load(open(catalog), SafeLoader) trace(o) return {k: DotDict(v) for k, v in o.items()} def _find_semver(s: str) -> VersionInfo: ver = VersionInfo(0, 0, 0) try: ver = VersionInfo.parse(s) except ValueError: try: ver = VersionInfo(*list(i.group(0) for i in re.finditer("\d+", s))[:3]) except Exception as e: trace("parse error", e) return ver def _local_version(cmd): ver = VersionInfo(0) try: first_line = run( [cmd, "--version"], input="", text=True, capture_output=True, check=True, timeout=0.1, ).stdout.split("\n")[0] trace(cmd, "=>", first_line) ver = _find_semver(first_line) except Exception as e: trace("run error", e) return ver def _internal_list(options) -> tuple[str, VersionInfo]: """list installed tools and their version""" ctl = _load_catalog(options) for cli, props in ctl.items(): # search in path try: vers = _local_version(cli) trace(cli, vers) yield cli, props, vers except CalledProcessError: trace(cli, "call error") except TimeoutExpired: trace(cli, "timeout") except FileNotFoundError: trace(cli, "not found") def dolist(options): for cli, _, ver in _internal_list(options): print(cli, ver) def doinfo(options): tool = options.TOOL ctl = _load_catalog(options) if tool in ctl: print(tool) for k, v in ctl[tool].items(): print(f" {k}: {v}") else: warn(f"{tool} not in catalog") def dosearch(options): pat = options.PAT ctl = _load_catalog(options) L = [] for cli, props in ctl.items(): trace(cli, props) rtitle = fuzz.ratio(cli, pat) rname = fuzz.ratio(props.name, pat) if 'name' in props else 0 rdesc = fuzz.partial_token_set_ratio(props.desc, pat) score = rdesc + rname score = rtitle + score if rtitle > 60 else score L.append((cli, props.desc[:50], score)) L = sorted(L, key=lambda x: -x[-1]) L = [[ "cli", "desc", "rel" ]] + L[:10] from terminaltables import SingleTable table = SingleTable(L) table.inner_row_border=False print(table.table) def dolistupdate(options): print("look for updatables") for cli, props, ver in _internal_list(options): # get last version online if props.github: pass pass def _gh_versions(repo: str) -> [VersionInfo | None]: [owner, repo] = repo.split("/") url = f"https://api.github.com/repos/{owner}/{repo}/releases" response = requests.get(url) return [_find_semver(o.get("tag_name")) for o in response.json()] def _gh_version(repo: str) -> [VersionInfo | None]: [owner, repo] = repo.split("/") url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" response = requests.get(url) return _find_semver(response.json().get("tag_name")) def doversions(options): tools = options.TOOLS ctl = _load_catalog(options) for cli in tools: if cli in ctl: props = ctl[cli] rver = _gh_version(props.github) if props.github else VersionInfo(0) lver = _local_version(cli) trace(lver) trace(rver) print(f"{cli} | {lver} | {rver}") else: warn(f"{tool} not in catalog") def doallversions(options): tool = options.TOOL ctl = _load_catalog(options) if tool in ctl: props = ctl[tool] if props.github: vers = _gh_versions(props.github) trace(vers) print("\n".join(vers)) else: warn(f"{tool} not in catalog") def doinstall(options): tools = options.TOOLS ctl = _load_catalog(options) for tool in tools: if tool in ctl: props = ctl[tool] if props.github: rver = _gh_version(props.github) lver = _local_version(tool) trace(lver, rver) if rver > lver: _perform_gh_install(tool, props.github) else: info(f"{tool} is already up do date ({lver})") else: warn(f'{tool} has no known install strategy') else: warn(f"{tool} not in catalog") def _match_arch_machine(name: str) -> bool: sysname = os.uname().sysname.lower() # os machine = os.uname().machine.lower() # arch return name.lower().find(sysname) > 0 and name.lower().find(machine) > 0 def _perform_gh_install(cli, repo, version=None): # get asset list url = f"https://api.github.com/repos/{repo}/releases/latest" r = requests.get(url) assets = r.json()["assets"] trace(assets) # select right asset asset = DotDict(next(filter(lambda x: _match_arch_machine(x["name"]), assets))) trace(asset.name, asset.url) # mkdirs p = os.path home = os.environ["HOME"] assetshome = p.join(home, ".cache/cliget/assets") os.makedirs(assetshome, exist_ok=True) location = p.join(assetshome, asset.name) trace(f"will dl {location}") # dl asset if not already there if not p.exists(location): dlurl = requests.get(asset.url).json()["browser_download_url"] r = requests.get(dlurl, allow_redirects=True, stream=True) with open(location, "wb") as fd: shutil.copyfileobj(r.raw, fd) trace("downloaded") # unpack asset if not asset.name.endswith(".tar.gz"): raise ValueError("package type not handled") progloc = p.join(home, ".local/programs", cli) trace("process tgz") os.makedirs(progloc, exist_ok=True) run(["tar", "xfz", location, "-C", progloc]) # symlink # TODO remove existing symlink os.symlink(p.join('../programs', cli, cli), p.join(home, ".local/bin", cli)) if __name__ == "__main__": if "DEBUG" in os.environ: logging.basicConfig(level=logging.DEBUG) logging.debug("debug is on") else: logging.info("not in debug") options = docopt(__doc__, version="Cliget 0.1.0") options = DotDict( {k.replace("-", "_"): v for (k, v) in options.items() if v is not None} ) trace(options) if options.info: doinfo(options) elif options.list: dolist(options) elif options.search: dosearch(options) elif options.versions: doversions(options) elif options.allversions: doallversions(options) elif options.install or options.update: if not options.__all and len(options.TOOLS) == 0: dolistupdate(options) elif len(options.TOOLS) > 0: doinstall(options) else: print("update all not implemented")