From 5406afa497148fc9b1788ec01b0c5a1876b88dbd Mon Sep 17 00:00:00 2001 From: Lephenixnoir Date: Sat, 2 Jan 2021 13:30:24 +0100 Subject: [PATCH] most things, minus dependencies, updates, and auto-install --- .gitignore | 1 + Makefile | 16 +++ giteapc.py | 149 +++++++++++++++++++++ giteapc/__init__.py | 0 giteapc/config.py | 9 ++ giteapc/gitea.py | 61 +++++++++ giteapc/repo.py | 316 ++++++++++++++++++++++++++++++++++++++++++++ giteapc/repos.py | 136 +++++++++++++++++++ giteapc/util.py | 156 ++++++++++++++++++++++ install.sh | 56 ++++++++ 10 files changed, 900 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 giteapc.py create mode 100644 giteapc/__init__.py create mode 100644 giteapc/config.py create mode 100644 giteapc/gitea.py create mode 100644 giteapc/repo.py create mode 100644 giteapc/repos.py create mode 100644 giteapc/util.py create mode 100755 install.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f9596a2 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +PREFIX ?= $(HOME)/.local +VERSION = 1.0 + +install: $(bin) + install -d $(PREFIX)/bin + sed -e 's*%PREFIX%*$(PREFIX)*; s*%VERSION%*$(VERSION)*' giteapc.py > $(PREFIX)/bin/giteapc + chmod +x $(PREFIX)/bin/giteapc + install -d $(PREFIX)/lib/giteapc/giteapc + install giteapc/*.py $(PREFIX)/lib/giteapc/giteapc + +uninstall: + rm -f $(PREFIX)/bin/giteapc + rm -rf $(PREFIX)/lib/giteapc + @ echo "note: repositories cloned by GiteaPC have not been removed" + +.PHONY: install uninstall diff --git a/giteapc.py b/giteapc.py new file mode 100644 index 0000000..122a5ab --- /dev/null +++ b/giteapc.py @@ -0,0 +1,149 @@ +#! /usr/bin/env python3 + +""" +GiteaPC is an automated installer/updated for repositories of the Planète Casio +Gitea forge (https://gitea.planet-casio.com/). It is mainly used to set up +installs of the add-in development fxSDK. +""" + +# Install prefix (inserted at compile-time) +PREFIX = "%PREFIX%" +# Program version (inserted at compile-time) +VERSION = "%VERSION%" + +import sys +sys.path.append(PREFIX + "/lib/giteapc") + +import giteapc.repo +from giteapc.util import * +from giteapc.config import REPO_FOLDER, PREFIX_FOLDER +import getopt +import sys +import os + +# TODO: +# * @ or ~ shortcut for remote repositories to avoid the -r option? +# * build, install: repository updates +# * NetworkError for cURL +# * Handle dependencies +# * Test update logic + +usage_string = """ +usage: {R}giteapc{_} [{R}list{_}|{R}fetch{_}|{R}show{_}|{R}build{_}|{R}install{_}|{R}uninstall{_}] [{g}{i}ARGS...{_}] + +GiteaPC is a tool to automatically clone, install and update repositories from +the Planète Casio Gitea forge. In the following commands, each {g}{i}REPOSITORY{_} +is either a full name like "Lephenixnoir/sh-elf-gcc", or a short name like +"sh-elf-gcc" when there is no ambiguity. + +{R}giteapc list{_} [{R}-r{_}] [{g}{i}PATTERN{_}] + Lists all repositories on this computer. With -r, lists repositories on the + forge. A wildcard pattern can be specified to filter the results. +{R}giteapc fetch{_} [{R}--https{_}|{R}--ssh{_}] [{R}-u{_}] [{R}-f{_}] [{g}{i}REPOSITORY{_}[{R}@{_}{g}{i}VERSION{_}]{g}{i}...{_}] + Clones or fetches a repository and its dependencies. If no repository is + specified, fetches all the local repositories. HTTPS or SSH can be selected + when cloning (HTTPS by default). With -u, pulls after fetching. +{R}giteapc show{_} [{R}-r{_}] [{R}-p{_}] {g}{i}REPOSITORY...{_} + Shows the branches and tags (versions) for the specified local repositories. + With -r, show information for remote repositories on the forge. + With -p, just print the path of local repositories (useful in scripts). +{R}giteapc build{_} [{R}-i{_}] [{R}-u{_}] [{R}--skip-configure{_}] {g}{i}REPOSITORY{_}[{R}@{_}{g}{i}VERSION{_}][{R}:{_}{g}{i}CONFIG{_}]{g}{i}...{_} + Configures and builds a local repository. A specific configuration can be + requested. With -i, also installs if build is successful. --skip-configure + builds without configuring (useful for rebuilds). With -u, pulls the current + branch before building (update mode). +{R}giteapc install{_} [{R}--https{_}|{R}--ssh{_}] [{R}-u{_}] {g}{i}REPOSITORY{_}[{R}@{_}{g}{i}VERSION{_}][{R}:{_}{g}{i}CONFIG{_}]{g}{i}...{_} + Shortcut to clone (or fetch), build, and install a repository. +{R}giteapc uninstall{_} [{R}-k{_}] {g}{i}REPOSITORY...{_} + Uninstalls the build products of the specified repositories and removes the + source files. With -k, keeps the source files. + +{W}Important folders{_} + +{R}$GITEAPC_HOME{_} or {R}$XDG_DATA_HOME/giteapc{_} or {R}$HOME/.local/share/giteapc{_} +{W}->{_} {b}{}{_} + Storage for GiteaPC repositories. +{R}$GITEAPC_PREFIX{_} or {R}$HOME/.local{_} +{W}->{_} {b}{}{_} + Default install prefix. +""".format(REPO_FOLDER, PREFIX_FOLDER, **colors()).strip() + +def usage(exitcode=None): + print(usage_string, file=sys.stderr) + if exitcode is not None: + sys.exit(exitcode) + +commands = { + "list": { + "function": giteapc.repo.list, + "args": "remote:-r,--remote", + }, + "fetch": { + "function": giteapc.repo.fetch, + "args": "use_ssh:--ssh use_https:--https force:-f,--force "+\ + "update:-u,--update", + }, + "show": { + "function": giteapc.repo.show, + "args": "remote:-r,--remote path:-p,--path-only", + }, + "build": { + "function": giteapc.repo.build, + "args": "install:-i,--install skip_configure:--skip-configure "+\ + "update:-u,--update", + }, + "install": { + "function": giteapc.repo.install, + "args": "use_ssh:--ssh use_https:--https update:-u,--update", + }, + "uninstall": { + "function": giteapc.repo.uninstall, + "args": "keep:-k,--keep", + }, +} + +def main(argv, commands): + # Help, version, and invocations without a proper command name + if "-h" in argv or "--help" in argv: + usage(0) + if "-v" in argv or "--version" in argv: + print(f"GiteaPC {VERSION}") + return 0 + if argv == [] or argv[0] not in commands: + usage(1) + + args = commands[argv[0]].get("args","").split() + args = [x.split(":",1) for x in args] + args = [(name,forms.split(",")) for name, forms in args] + + single = "" + double = [] + for name, forms in args: + for f in forms: + if f.startswith("--"): + double.append(f[2:]) + elif f[0] == "-" and len(f) == 2: + single += f[1] + else: + raise Exception("invalid argument format o(x_x)o") + + # Parse arguments + try: + opts, data = getopt.gnu_getopt(argv[1:], single, double) + opts = { name: True if val == "" else val for (name,val) in opts } + except getopt.GetoptError as e: + return fatal(e) + + options = {} + for (o,val) in opts.items(): + for (name, forms) in args: + if o in forms: + options[name] = val + + try: + return commands[argv[0]]["function"](*data, **options) + except Error as e: + return fatal(e) + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:], commands)) diff --git a/giteapc/__init__.py b/giteapc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/giteapc/config.py b/giteapc/config.py new file mode 100644 index 0000000..6318b7a --- /dev/null +++ b/giteapc/config.py @@ -0,0 +1,9 @@ +import os + +# URL to the Gitea forge supplying the resources +GITEA_URL = "https://gitea.planet-casio.com" +# Data folder to store repositores +XDG_DATA_HOME = os.getenv("XDG_DATA_HOME", os.getenv("HOME")+"/.local/share") +REPO_FOLDER = os.getenv("GITEAPC_HOME") or XDG_DATA_HOME + "/giteapc" +# Prefix folder to install files to +PREFIX_FOLDER = os.getenv("GITEAPC_PREFIX") or os.getenv("HOME") + "/.local" diff --git a/giteapc/gitea.py b/giteapc/gitea.py new file mode 100644 index 0000000..67bb765 --- /dev/null +++ b/giteapc/gitea.py @@ -0,0 +1,61 @@ +from giteapc.util import * +import giteapc.repos +import giteapc.config +import subprocess +import json +import sys + +# Provide a mechanism to submit HTTP GET requests to the forge. Try to use the +# requests module if it's available, otherwise default to cURL. If none is +# available, disable network features but keep going. +_http_get = None + +try: + import requests + _http_get = requests_get +except ImportError: + pass + +if _http_get is None and has_curl(): + _http_get = curl_get + +if _http_get is None: + warn("found neither requests nor curl, network will be disabled") + +# Send a GET request to the specified API endpoint +def _get(url, params=None): + if _http_get is None: + raise Error("cannot access forge (network is disabled)") + return _http_get(giteapc.config.GITEA_URL + "/api/v1" + url, params) + +# Search for repositories +def repo_search(keyword, **kwargs): + r = _get("/repos/search", { "q": keyword, **kwargs }) + results = json.loads(r)['data'] + results = [ giteapc.repos.RemoteRepo(r) for r in results ] + return sorted(results, key=lambda r: r.fullname) + +# List all remote repositories (with topic "giteapc") +def all_remote_repos(): + return repo_search("giteapc", topic=True) + +# Repository info +def repo_get(fullname): + try: + r = _get(f"/repos/{fullname}") + return giteapc.repos.RemoteRepo(json.loads(r)) + except NetworkError as e: + if e.status == 404: + return None + else: + raise e + +def repo_branches(r): + r = _get(r.url + "/branches") + return json.loads(r) +def repo_tags(r): + r = _get(r.url + "/tags") + return json.loads(r) +def repo_topics(r): + r = _get(r.url + "/topics") + return json.loads(r)["topics"] diff --git a/giteapc/repo.py b/giteapc/repo.py new file mode 100644 index 0000000..56c5e6c --- /dev/null +++ b/giteapc/repo.py @@ -0,0 +1,316 @@ +import giteapc.gitea as gitea +from giteapc.repos import LocalRepo, RemoteRepo +from giteapc.util import * +from giteapc.config import REPO_FOLDER, PREFIX_FOLDER +import fnmatch +import shutil +import os +import re + +# +# Determine full repo names from short versions +# + +def local_match(name): + """Find all local repositories with the specified name.""" + return [ r for r in LocalRepo.all() if r.name == name ] + +def remote_match(name): + """Find all remote repositories matching the given name.""" + return [ r for r in gitea.all_remote_repos() if r.name == name ] + +def resolve(name, local_only=False, remote_only=False): + assert not (local_only and remote_only) + + # Resolve full names directly + if "/" in name: + if not remote_only and LocalRepo.exists(name): + return LocalRepo(name) + if local_only: + raise ResolveMissingException(name, local_only, remote_only) + + r = gitea.repo_get(name) + if r is None: + raise ResolveMissingException(name, local_only, remote_only) + return r + + # Match local names without owners + if not remote_only: + r = local_match(name) + if len(r) == 0 and local_only: + raise ResolveMissingException(name, local_only, remote_only) + elif len(r) == 1: + return r[0] + elif len(r) > 1: + raise ResolveAmbiguousException(name, r, "local") + + # Match remote names without owners + if not local_only: + r = remote_match(name) + if len(r) == 0: + raise ResolveMissingException(name, local_only, remote_only) + elif len(r) == 1: + return r[0] + else: + raise ResolveAmbiguousException(name, r, "remote") + +# +# Utilities +# + +def print_repo(r, branches=None, tags=None, has_giteapc=True): + color = "ARGYBMW"[sum(map(ord,r.owner[:5])) % 7] + + print(colors()[color] + + "{}{_}/{W}{}{_}".format(r.owner, r.name, **colors()), end="") + print(" (remote)" if r.remote else " (local)", end="") + if r.remote and r.parent: + print(" {A}[{}]{_}".format(r.parent.fullname, **colors()), end="") + if r.remote and has_giteapc == False: + print(" {R}(NOT SUPPORTED){_}".format(**colors()), end="") + print("") + + if r.remote: + print(("\n" + r.description).replace("\n", "\n ")[1:]) + else: + print(" {W}Path:{_}".format(**colors()), r.folder, end="") + if os.path.islink(r.folder): + print(" ->", os.readlink(r.folder)) + else: + print("") + branches = r.branches() + tags = r.tags() + + if branches: + print(" {W}Branches:{_}".format(**colors()), end="") + for b in branches: + if "local" in b and b["local"] == False: + print(" {A}{}{_}".format(b["name"], **colors()), end="") + else: + print(" " + b["name"], end="") + print("") + + if tags: + print(" {W}Tags:{_}".format(**colors()), + " ".join(t["name"] for t in reversed(tags))) + +def pretty_repo(r): + color = "ARGYBMW"[sum(map(ord,r.owner[:5])) % 7] + return colors()[color] + "{}{_}/{W}{}{_}".format(r.owner,r.name,**colors()) + +def split_config(name): + """Splits REPOSITORY[@VERSION][:CONFIGURATION] into components.""" + RE_CONFIG = re.compile(r'^([^@:]+)(?:@([^@:]+))?(?:[:]([^@:]+))?') + m = re.match(RE_CONFIG, name) + if m is None: + return None + + repo, version, config = m[1], m[2] or "", m[3] or "" + return repo, version, config + +# +# repo list command +# + +def list(*args, remote=False): + if len(args) > 1: + return fatal("repo list: too many arguments") + + if remote: + # Since there aren't many repositories under [giteapc], just list them + # and then filter by hand (this avoids some requests and makes search + # results more consistent with local repositories) + repos = gitea.all_remote_repos() + else: + repos = LocalRepo.all() + + # - Ignore case in pattern + # - Add '*' at start and end to match substrings only + pattern = args[0].lower() if args else "*" + if not pattern.startswith("*"): + pattern = "*" + pattern + if not pattern.endswith("*"): + pattern = pattern + "*" + + # Filter + repos = [ r for r in repos + if fnmatch.fnmatch(r.fullname.lower(), pattern) + or r.remote and fnmatch.fnmatch(r.description.lower(), pattern) ] + + # Print + if repos == []: + if args: + print(f"no repository matching '{args[0]}'") + else: + print(f"no repository") + return 1 + else: + for r in repos: + print_repo(r) + return 0 + +# +# repo fetch command +# + +def fetch(*args, use_ssh=False, use_https=False, force=False, update=False): + # Use HTTPS by default + if use_ssh and use_https: + return fatal("repo fetch: --ssh and --https are mutually exclusive") + protocol = "ssh" if use_ssh else "https" + + # With no arguments, fetch all local repositories + if args == (): + for r in LocalRepo.all(): + msg(f"Fetching {pretty_repo(r)}...") + r.fetch() + if update: + r.pull() + return 0 + + for spec in args: + name, version, config = split_config(spec) + r = resolve(name) + + # If this is a local repository, just git fetch + if not r.remote: + if version: + msg("Checking out {W}{}{_}".format(version, **colors())) + r.checkout(version) + + msg(f"Fetching {pretty_repo(r)}...") + r.fetch() + if update: + r.pull() + continue + + msg(f"Cloning {pretty_repo(r)}...") + + # For remote repositories, make sure the repository supports GiteaPC + has_tag = "giteapc" in gitea.repo_topics(r) + + if has_tag or force: + LocalRepo.clone(r, protocol) + if not has_tag and force: + warn(f"{r.fullname} doesn't have the [giteapc] tag") + if not has_tag and not force: + fatal(f"{r.fullname} doesn't have the [giteapc] tag, use -f to force") + +# +# repo show command +# + +def show(*args, remote=False, path=False): + if remote and path: + raise Error("repo show: -r and -p are exclusive") + + if not remote: + for name in args: + r = resolve(name, local_only=True) + if path: + print(LocalRepo.path(r.fullname)) + else: + print_repo(r) + return 0 + + repos = [] + for name in args: + r = resolve(name, remote_only=True) + branches = gitea.repo_branches(r) + tags = gitea.repo_tags(r) + topics = gitea.repo_topics(r) + repos.append((r, branches, tags, "giteapc" in topics)) + + for (r, branches, tags, has_giteapc) in repos: + print_repo(r, branches, tags, has_giteapc=has_giteapc) + +# +# repo build command +# + +def build(*args, install=False, skip_configure=False, update=False): + if len(args) < 1: + return fatal("repo build: specify at least one repository") + + specs = [] + for spec in args: + repo, version, config = split_config(spec) + repo = resolve(repo, local_only=True) + specs.append((repo, version, config)) + + msg("Will build:", ", ".join(pretty_repo(spec[0]) for spec in specs)) + + for (r, version, config) in specs: + pretty = pretty_repo(r) + config_string = f" for {config}" if config else "" + + if version != "": + msg("{}: Checking out {W}{}{_}".format(pretty, version, **colors())) + r.checkout(version) + if update: + r.pull() + + # Check that the project has a Makefile + if not os.path.exists(r.makefile): + raise Error(f"{r.fullname} has no giteapc.make") + + env = os.environ.copy() + if config: + env["GITEAPC_CONFIG"] = config + env["GITEAPC_PREFIX"] = PREFIX_FOLDER + + if not skip_configure: + msg(f"{pretty}: Configuring{config_string}") + r.make("configure", env) + + msg(f"{pretty}: Building") + r.make("build", env) + + if install: + msg(f"{pretty}: Installing") + r.make("install", env) + + msg(f"{pretty}: Done! :D") + + +# +# repo install command +# + +def install(*args, use_https=False, use_ssh=False, update=False): + if args == (): + return 0 + + # First download every repository, and only then build + fetch(*args, use_https=use_https, use_ssh=use_ssh) + build(*args, install=True, update=update) + +# +# repo uninstall command +# + +def uninstall(*args, keep=False): + if len(args) < 1: + return fatal("repo uninstall: specify at least one repository") + + for name in args: + r = resolve(name, local_only=True) + msg(f"{pretty_repo(r)}: Uninstalling") + + env = os.environ.copy() + env["GITEAPC_PREFIX"] = PREFIX_FOLDER + r.make("uninstall", env) + + if not keep: + msg("{}: {R}Removing files{_}".format(pretty_repo(r), **colors())) + + if os.path.isdir(r.folder): + shutil.rmtree(r.folder) + elif os.path.islink(r.folder): + os.remove(r.folder) + else: + raise Error(f"cannot handle {r.folder} (not a folder/symlink)") + + parent = os.path.dirname(r.folder) + if not os.listdir(parent): + os.rmdir(parent) diff --git a/giteapc/repos.py b/giteapc/repos.py new file mode 100644 index 0000000..02cc1d3 --- /dev/null +++ b/giteapc/repos.py @@ -0,0 +1,136 @@ +from giteapc.config import REPO_FOLDER +from giteapc.util import * +import subprocess +from subprocess import PIPE +import os.path +import shutil +import glob + +class RemoteRepo: + # Create a remote repo from the JSON object returned by Gitea + def __init__(self, j): + self.j = j + self.remote = True + + @property + def name(self): + return self.j["name"] + + @property + def owner(self): + return self.j["owner"]["username"] + + @property + def fullname(self): + return self.j["full_name"] + + @property + def description(self): + return self.j["description"] + + @property + def parent(self): + p = self.j["parent"] + return RemoteRepo(p) if p is not None else None + + @property + def url(self): + return f"/repos/{self.owner}/{self.name}" + + def clone_url(self, protocol): + if protocol == "ssh": + return self.j["ssh_url"] + else: + return self.j["clone_url"] + + +class LocalRepo: + # Create a remote repo from the full name or path + def __init__(self, fullname): + if fullname.startswith(REPO_FOLDER + "/"): + fullname = fullname[len(REPO_FOLDER)+1:] + + assert fullname.count("/") == 1 + + self.fullname = fullname + self.owner, self.name = fullname.split("/") + self.folder = REPO_FOLDER + "/" + fullname + self.remote = False + self.makefile = self.folder + "/giteapc.make" + + @staticmethod + def path(fullname): + return REPO_FOLDER + "/" + fullname + + @staticmethod + def exists(fullname): + return os.path.exists(LocalRepo.path(fullname)) + + @staticmethod + def all(): + return [ LocalRepo(path) for path in glob.glob(REPO_FOLDER + f"/*/*") ] + + @staticmethod + def clone(r, method="https"): + src = r.clone_url(method) + dst = LocalRepo.path(r.fullname) + mkdir_p(dst) + cmd = ["git", "clone", src, dst] + + try: + run(cmd, stdout=PIPE, stderr=PIPE) + return LocalRepo(r.fullname) + except ProcessError as e: + # On error, delete the failed clone + shutil.rmtree(dst) + raise e + + # Git commands + + def _git(self, command, *args, **kwargs): + return run(["git", "-C", self.folder] + command, + *args, **kwargs) + + def is_on_branch(self): + try: + self._git(["symbolic-ref", "-q", "HEAD"]) + return True + except ProcessError as e: + if e.returncode == 1: + return False + raise e + + def fetch(self): + self._git(["fetch"]) + + def pull(self): + if self.is_on_branch(): + self._git(["pull"]) + + def checkout(self, version): + self._git(["checkout", version], stdout=PIPE, stderr=PIPE) + + def branches(self): + proc = self._git(["branch"], stdout=subprocess.PIPE) + local = proc.stdout.decode("utf-8").split("\n") + local = [ b[2:] for b in local if b ] + + proc = self._git(["branch", "-r"], stdout=subprocess.PIPE) + remote = proc.stdout.decode("utf-8").split("\n") + remote = [ b[9:] for b in remote + if b and b.startswith(" origin/") and "/HEAD " not in b ] + remote = [ b for b in remote if b not in local ] + + return [ {"name": b, "local": True } for b in local ] + \ + [ {"name": b, "local": False} for b in remote ] + + def tags(self): + proc = self._git(["tag", "--list"], stdout=subprocess.PIPE) + tags = proc.stdout.decode("utf-8").split("\n") + return [ {"name": t} for t in tags if t ] + + # Make commands + + def make(self, target, env=None): + with ChangeDirectory(self.folder): + return run(["make", "-f", "giteapc.make", target], env=env) diff --git a/giteapc/util.py b/giteapc/util.py new file mode 100644 index 0000000..9d34552 --- /dev/null +++ b/giteapc/util.py @@ -0,0 +1,156 @@ +import sys +import subprocess +import os.path +import shlex + +# Error management; the base Error class is provided for errors to be caught at +# top-level and displayed as a GiteaPC error message. + +class Error(Exception): + pass + +class NetworkError(Error): + def __init__(self, status): + self.status = status + +class ProcessError(Error): + def __init__(self, process): + self.process = process + self.returncode = process.returncode + + def __str__(self): + p = self.process + e = f"error {p.returncode} in command: " + c = " ".join(shlex.quote(arg) for arg in p.args) + e += "{W}{}{_}\n".format(c, **colors()) + + out = p.stdout.decode('utf-8').strip() if p.stdout else "" + err = p.stderr.decode('utf-8').strip() if p.stderr else "" + + # Show standard output and standard error (omit names if only one has + # content to show) + if len(out) > 0 and len(err) > 0: + e += "{W}Standard output:{_}\n".format(**colors()) + if len(out) > 0: + e += out + "\n" + if len(out) > 0 and len(err) > 0: + e += "{W}Standard error:{_}\n".format(**colors()) + if len(err) > 0: + e += err + "\n" + + return e.strip() + +class ResolveMissingException(Error): + def __init__(self, name, local_only, remote_only): + self.name = name + self.local_only = local_only + self.remote_only = remote_only + + def __str__(self): + if self.local_only: + spec = "local repository" + elif self.remote_only: + spec = "remote repository" + else: + spec = "local or remote repository" + + return f"no such {spec}: '{self.name}'" + +class ResolveAmbiguousException(Error): + def __init__(self, name, matches, kind): + self.name = name + self.matches = [r.fullname for r in matches] + self.kind = kind + + def __str__(self): + return f"multiple {self.kind} repositories match '{self.name}': " + \ + ", ".join(self.matches) + +def warn(*args): + print("{Y}warning:{_}".format(**colors()), *args, file=sys.stderr) +def fatal(*args): + print("{R}error:{_}".format(**colors()), *args, file=sys.stderr) + return 1 + +# Color output + +def colors(): + colors = { + # Gray, Red, Green, Yello, Blue, Magenta, Cyan, White + "A": "30;1", "R": "31;1", "G": "32;1", "Y": "33;1", + "B": "34;1", "M": "35;1", "C": "36;1", "W": "37;1", + # Same but without bold + "a": "30", "r": "31", "g": "32", "y": "33", + "b": "34", "m": "35", "c": "36", "w": "37", + # Italic + "i": "3", + # Clear formatting + "_": "0", + } + # Disable colors if stdout is not a TTY + if sys.stdout.isatty(): + return { name: f"\x1b[{code}m" for name, code in colors.items() } + else: + return { name: "" for name in colors } + +def msg(*args, **kwargs): + print("{c}{_}".format(**colors()), *args, **kwargs) + +# Change directory and guarantee changing back even if an exception occurs + +class ChangeDirectory: + def __init__(self, destination): + self.destination = destination + + def __enter__(self): + self.source = os.getcwd() + os.chdir(self.destination) + + def __exit__(self, type, value, traceback): + os.chdir(self.source) + +# Tool detection + +def has_curl(): + proc = subprocess.run(["curl", "--version"], stdout=subprocess.DEVNULL) + return proc.returncode == 0 + +def has_git(): + proc = subprocess.run(["git", "--version"], stdout=subprocess.DEVNULL) + return proc.returncode == 0 + +# HTTP requests + +def requests_get(url, params): + # Don't import requests until the gitea module confirms it's available + import requests + r = requests.get(url, params) + if r.status_code != 200: + raise NetworkError(r.status_code) + return r.text + +def curl_get(url, params): + if params: + url = url + "?" + "&".join(f"{n}={v}" for (n,v) in params.items()) + proc = subprocess.run(["curl", "-s", url], stdout=subprocess.PIPE) + assert proc.returncode == 0 + return proc.stdout.decode("utf-8") + +# Create path to folder + +def mkdir_p(folder): + try: + os.mkdir(folder) + except FileExistsError: + return + except FileNotFoundError: + mkdir_p(os.path.dirname(folder)) + os.mkdir(folder) + +# Run process and throw exception on error + +def run(process, *args, **kwargs): + proc = subprocess.run(process, *args, **kwargs) + if proc.returncode != 0: + raise ProcessError(proc) + return proc diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..701711a --- /dev/null +++ b/install.sh @@ -0,0 +1,56 @@ +#! /usr/bin/env bash + +TAG=$(printf "\x1b[36m\x1b[0m") +PREFIX=${GITEAPC_PREFIX:-$HOME/.local} +URL="https://gitea.planet-casio.com/Lephenixnoir/giteapc/archive/master.tar.gz" + +# Download the source code + +cd $(mktemp) +curl $URL -o giteapc-master.tar.gz +tar -xzf giteapc-master.tar.gz && cd giteapc + +# Install the program itself (not to $PREFIX, which is for programs installed +# by GiteaPC) + +make install + +# Check whether the bin folder is already in the PATH + +if [[ ":$PATH:" =~ ":$PREFIX/bin:" ]]; then + echo "$TAG $PREFIX/bin is already in your PATH, we're good to go!" + exit 0 +fi + +# Try to find a suitable startup file to extend the PATH in + +default="$HOME/.profile" +candidates=".bashrc .zshrc .bash_profile .profile .zprofile" + +for c in $candidates; do + [[ -f "$HOME/$c" ]] && default="$HOME/$c" +done + +# Suggest to add the path to binaries to the PATH at startup + +cat < Press Enter to add this command to $default, or +$TAG -> Type another file name to add this command to, or +$TAG -> Type "-" to skip setting the PATH entirely. +EOF + +read -p "> " startup_file +[[ -z "$startup_file" ]] && startup_file=$default + +if [[ "$startup_file" == "-" ]]; then + echo "$TAG Skipped setting the PATH." +else + echo "export PATH=\"\$PATH:$PREFIX/bin\"" >> $startup_file + echo "$TAG Set the PATH in $startup_file, this will take effect next login." +fi