Browse Source

most things, minus dependencies, updates, and auto-install

Lephenixnoir 7 months ago
Signed by untrusted user: Lephenixnoir GPG Key ID: 1BBA026E13FC0495
  1. 1
  2. 16
  3. 149
  4. 0
  5. 9
  6. 61
  7. 316
  8. 136
  9. 156
  10. 56


@ -0,0 +1 @@


@ -0,0 +1,16 @@
PREFIX ?= $(HOME)/.local
install: $(bin)
install -d $(PREFIX)/bin
sed -e 's*%PREFIX%*$(PREFIX)*; s*%VERSION%*$(VERSION)*' > $(PREFIX)/bin/giteapc
chmod +x $(PREFIX)/bin/giteapc
install -d $(PREFIX)/lib/giteapc/giteapc
install giteapc/*.py $(PREFIX)/lib/giteapc/giteapc
rm -f $(PREFIX)/bin/giteapc
rm -rf $(PREFIX)/lib/giteapc
@ echo "note: repositories cloned by GiteaPC have not been removed"
.PHONY: install uninstall


@ -0,0 +1,149 @@
#! /usr/bin/env python3
GiteaPC is an automated installer/updated for repositories of the Planète Casio
Gitea forge ( It is mainly used to set up
installs of the add-in development fxSDK.
# Install prefix (inserted at compile-time)
# Program version (inserted at compile-time)
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
# * @ 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:
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 "+\
"show": {
"args": "remote:-r,--remote path:-p,--path-only",
"build": {
"args": "install:-i,--install skip_configure:--skip-configure "+\
"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:
if "-v" in argv or "--version" in argv:
print(f"GiteaPC {VERSION}")
return 0
if argv == [] or argv[0] not in commands:
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("--"):
elif f[0] == "-" and len(f) == 2:
single += f[1]
raise Exception("invalid argument format o(x_x)o")
# Parse arguments
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
return commands[argv[0]]["function"](*data, **options)
except Error as e:
return fatal(e)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:], commands))



@ -0,0 +1,9 @@
import os
# URL to the Gitea forge supplying the resources
# 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"


@ -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
import requests
_http_get = requests_get
except ImportError:
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):
r = _get(f"/repos/{fullname}")
return giteapc.repos.RemoteRepo(json.loads(r))
except NetworkError as e:
if e.status == 404:
return None
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"]


@ -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 == name ]
def remote_match(name):
"""Find all remote repositories matching the given name."""
return [ r for r in gitea.all_remote_repos() if == 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]
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,, **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="")
if r.remote:
print(("\n" + r.description).replace("\n", "\n ")[1:])
print(" {W}Path:{_}".format(**colors()), r.folder, end="")
if os.path.islink(r.folder):
print(" ->", os.readlink(r.folder))
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="")
print(" " + b["name"], end="")
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,,**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()
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]}'")
print(f"no repository")
return 1
for r in repos:
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)}...")
if update:
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()))
msg(f"Fetching {pretty_repo(r)}...")
if update:
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:
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()))
if update:
# 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
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()
r.make("uninstall", env)
if not keep:
msg("{}: {R}Removing files{_}".format(pretty_repo(r), **colors()))
if os.path.isdir(r.folder):
elif os.path.islink(r.folder):
raise Error(f"cannot handle {r.folder} (not a folder/symlink)")
parent = os.path.dirname(r.folder)
if not os.listdir(parent):


@ -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
def name(self):
return self.j["name"]
def owner(self):
return self.j["owner"]["username"]
def fullname(self):
return self.j["full_name"]
def description(self):
return self.j["description"]
def parent(self):
p = self.j["parent"]
return RemoteRepo(p) if p is not None else None
def url(self):
return f"/repos/{self.owner}/{}"
def clone_url(self, protocol):
if protocol == "ssh":
return self.j["ssh_url"]
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, = fullname.split("/")
self.folder = REPO_FOLDER + "/" + fullname
self.remote = False
self.makefile = self.folder + "/giteapc.make"
def path(fullname):
return REPO_FOLDER + "/" + fullname
def exists(fullname):
return os.path.exists(LocalRepo.path(fullname))
def all():
return [ LocalRepo(path) for path in glob.glob(REPO_FOLDER + f"/*/*") ]
def clone(r, method="https"):
src = r.clone_url(method)
dst = LocalRepo.path(r.fullname)
cmd = ["git", "clone", src, dst]
run(cmd, stdout=PIPE, stderr=PIPE)
return LocalRepo(r.fullname)
except ProcessError as e:
# On error, delete the failed clone
raise e
# Git commands
def _git(self, command, *args, **kwargs):
return run(["git", "-C", self.folder] + command,
*args, **kwargs)
def is_on_branch(self):
self._git(["symbolic-ref", "-q", "HEAD"])
return True
except ProcessError as e:
if e.returncode == 1:
return False
raise e
def fetch(self):
def pull(self):
if self.is_on_branch():
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)


@ -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):
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): = 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"
spec = "local or remote repository"
return f"no such {spec}: '{}'"
class ResolveAmbiguousException(Error):
def __init__(self, name, matches, kind): = name
self.matches = [r.fullname for r in matches]
self.kind = kind
def __str__(self):
return f"multiple {self.kind} repositories match '{}': " + \
", ".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() }
return { name: "" for name in colors }
def msg(*args, **kwargs):
print("{c}<giteapc>{_}".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()
def __exit__(self, type, value, traceback):
# Tool detection
def has_curl():
proc =["curl", "--version"], stdout=subprocess.DEVNULL)
return proc.returncode == 0
def has_git():
proc =["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 =["curl", "-s", url], stdout=subprocess.PIPE)
assert proc.returncode == 0
return proc.stdout.decode("utf-8")
# Create path to folder
def mkdir_p(folder):
except FileExistsError:
except FileNotFoundError:
# Run process and throw exception on error
def run(process, *args, **kwargs):
proc =, *args, **kwargs)
if proc.returncode != 0:
raise ProcessError(proc)
return proc


@ -0,0 +1,56 @@
#! /usr/bin/env bash
TAG=$(printf "\x1b[36m<giteapc>\x1b[0m")
# 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
# Try to find a suitable startup file to extend the PATH in
candidates=".bashrc .zshrc .bash_profile .profile .zprofile"
for c in $candidates; do
[[ -f "$HOME/$c" ]] && default="$HOME/$c"
# Suggest to add the path to binaries to the PATH at startup
cat <<EOF
$TAG In order to use programs installed by GiteaPC, you will need to add their
$TAG install folder to your PATH. This can be done automatically when you log
$TAG in by adding the following command to your startup file:
$TAG export PATH="\$PATH:$PREFIX/bin"
$TAG -> 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.
read -p "> " startup_file
[[ -z "$startup_file" ]] && startup_file=$default
if [[ "$startup_file" == "-" ]]; then
echo "$TAG Skipped setting the PATH."
echo "export PATH=\"\$PATH:$PREFIX/bin\"" >> $startup_file
echo "$TAG Set the PATH in $startup_file, this will take effect next login."