cliget/cliget.py

355 lines
10 KiB
Python
Raw Normal View History

"""Cliget - install cli tools in you user profile
Usage:
cliget [-v] [-c URL] search PAT
2023-02-26 22:25:26 +00:00
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
2023-02-26 22:25:26 +00:00
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
2023-03-10 23:15:06 +00:00
from collections import namedtuple
2023-02-26 22:25:26 +00:00
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
2023-02-27 00:53:29 +00:00
2023-03-10 23:15:06 +00:00
class DotDict(dict):
def __getattr__(self, name):
return self[name] if name in self else None
2023-03-26 22:02:56 +00:00
Tool = namedtuple("Tool", "cli,props,lver,rver")
Tool.__annotations__ = {
"cli": str,
"props": DotDict,
"lver": VersionInfo,
"rver": VersionInfo,
}
import requests
from requests_cache import CachedSession
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
retry_strategy = Retry(
total=5,
status_forcelist=[403, 429, 500, 502, 503, 504],
# method_whitelist=["HEAD", "GET", "OPTIONS"],
method_whitelist=False,
backoff_factor=4,
)
adapter = HTTPAdapter(max_retries=retry_strategy)
http = CachedSession(
"cliget/http_cache",
use_cache_dir=True, # Save files in the default user cache dir
# cache_control=True, # Use Cache-Control response headers for expiration, if available
expire_after=3600, # Otherwise expire responses after one day
allowable_codes=[
200,
400,
404,
], # Cache 400 responses as a solemn reminder of your failures
# allowable_methods=['GET', 'POST'], # Cache whatever HTTP methods you want
# ignored_parameters=['api_key'], # Don't match this request param, and redact if from the cache
# match_headers=['Accept-Language'], # Cache a different response per language
stale_if_error=True, # In case of request errors, use stale cache data if possible
)
http.mount("https://", adapter)
http.mount("http://", adapter)
2023-03-10 23:15:06 +00:00
def trace(*mess):
2023-02-27 00:53:29 +00:00
if "TRACE" in os.environ:
2023-03-26 22:02:56 +00:00
print("TRACE", *mess)
2023-02-27 00:53:29 +00:00
def info(*mess):
print(*mess)
def warn(*mess):
2023-02-27 00:53:29 +00:00
print("WARNING:", *mess)
2023-02-27 00:53:29 +00:00
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:
2023-03-10 23:15:06 +00:00
trace(s)
2023-02-27 00:53:29 +00:00
ver = VersionInfo(0, 0, 0)
try:
2023-02-27 00:53:29 +00:00
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):
2023-02-27 00:53:29 +00:00
ver = VersionInfo(0)
try:
2023-02-27 00:53:29 +00:00
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):
2023-02-27 00:53:29 +00:00
for cli, _, ver in _internal_list(options):
print(cli, ver)
2023-02-26 22:25:26 +00:00
def doinfo(options):
2023-02-27 00:53:29 +00:00
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")
2023-02-26 22:25:26 +00:00
def dosearch(options):
2023-02-27 00:53:29 +00:00
pat = options.PAT
ctl = _load_catalog(options)
L = []
for cli, props in ctl.items():
trace(cli, props)
rtitle = fuzz.ratio(cli, pat)
2023-03-26 22:02:56 +00:00
rname = fuzz.ratio(props.name, pat) if "name" in props else 0
2023-02-27 00:53:29 +00:00
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])
2023-03-26 22:02:56 +00:00
L = [["cli", "desc", "rel"]] + L[:10]
2023-02-27 00:53:29 +00:00
from terminaltables import SingleTable
2023-03-26 22:02:56 +00:00
2023-02-27 00:53:29 +00:00
table = SingleTable(L)
2023-03-26 22:02:56 +00:00
table.inner_row_border = False
2023-02-27 00:53:29 +00:00
print(table.table)
def dolistupdate(options):
2023-02-27 00:53:29 +00:00
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"
2023-03-10 23:15:06 +00:00
# GH API raise 403 when too many requests are sent
# TODO implement retry with threshold
2023-03-26 22:02:56 +00:00
response = http.get(url)
2023-03-10 23:15:06 +00:00
trace(response)
2023-02-27 00:53:29 +00:00
return [_find_semver(o.get("tag_name")) for o in response.json()]
2023-03-26 22:02:56 +00:00
def _gh_version(repo: str) -> VersionInfo:
2023-02-27 00:53:29 +00:00
[owner, repo] = repo.split("/")
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
2023-03-10 23:15:06 +00:00
trace(url)
2023-03-26 22:02:56 +00:00
response = http.get(url)
res_body = response.json()
trace(response, type(res_body), res_body)
if not response.ok:
return VersionInfo(0)
return _find_semver(res_body.get("tag_name"))
2023-02-27 00:53:29 +00:00
def doversions(options):
2023-02-27 00:53:29 +00:00
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")
2023-02-26 22:25:26 +00:00
2023-02-27 00:53:29 +00:00
def doallversions(options):
tool = options.TOOL
ctl = _load_catalog(options)
if tool in ctl:
2023-02-27 00:53:29 +00:00
props = ctl[tool]
if props.github:
vers = _gh_versions(props.github)
trace(vers)
print("\n".join(vers))
else:
2023-02-27 00:53:29 +00:00
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:
2023-03-26 22:02:56 +00:00
warn(f"{tool} has no known install strategy")
2023-02-27 00:53:29 +00:00
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
2023-03-10 23:15:06 +00:00
# we don't consider libc - glic or musl - as musl is usually statically embed
lname = name.lower()
2023-03-26 22:02:56 +00:00
return lname.find(sysname) > 0 and (
lname.find(machine) > 0 or (machine == "x86_64" and lname.find("amd64") > 0)
) # x86_64 and "amd64" are synonym
2023-02-27 00:53:29 +00:00
2023-03-10 23:15:06 +00:00
def _get_gh_matching_release(repo):
# get asset list from last release
2023-02-27 00:53:29 +00:00
url = f"https://api.github.com/repos/{repo}/releases/latest"
2023-03-26 22:02:56 +00:00
r = http.get(url)
2023-02-27 00:53:29 +00:00
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)
2023-03-10 23:15:06 +00:00
return asset
2023-03-26 22:02:56 +00:00
2023-03-10 23:15:06 +00:00
def _perform_gh_install(cli, repo, version=None):
asset = _get_gh_matching_release(repo)
2023-02-27 00:53:29 +00:00
# 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):
2023-03-26 22:02:56 +00:00
dlurl = http.get(asset.url).json()["browser_download_url"]
r = http.get(dlurl, allow_redirects=True, stream=True)
2023-02-27 00:53:29 +00:00
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])
2023-03-10 23:15:06 +00:00
# TODO look for exe : ./cli, ./<tar root folder>/cli, exe propertie
2023-02-27 00:53:29 +00:00
# symlink
# TODO remove existing symlink
2023-03-26 22:02:56 +00:00
os.symlink(p.join("../programs", cli, cli), p.join(home, ".local/bin", cli))
2023-02-27 00:53:29 +00:00
if __name__ == "__main__":
if "DEBUG" in os.environ:
logging.basicConfig(level=logging.DEBUG)
logging.debug("debug is on")
else:
2023-02-27 00:53:29 +00:00
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")