Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
# -*- coding: utf-8 -*-
# FreeCAD Headless AddonManager test
# (c) 2019 FreeCAD community LGPL
"""
The module can be executed with:
./FreeCAD.AppImage -c <path_to_file> AddonManagerHeadless.py
Note:
In order to test updates, it possible to use git reset --hard HEAD~1
which will rollback a repo 1 commit behind master.
"""
# TODO:
# * Remove Addons requires removing their info from user.cfg
# * Add Macro support
# * Modularize AKA `import addonmanagerheadless`
# * Support checking dependencies (https://github.com/FreeCAD/FreeCAD-addons/issues/29)
# * Add custom repo support
# * Manually update user.cfg with git URL(s)
# * Add an interactive mode
# * Add verbose mode
# * Add quiet mode
# * Add unittest
# * Add ability to run unittests on FC (there is a flag already, but maybe we can report results?)
# * Add terminal color support
# * Add emoji support
# * Output columns so less scrollback is needed [DONE]
# * Show progress of git pull (percentage as a progress bar) https://stackoverflow.com/a/38780917
# * Support git branch swapping (https://github.com/FreeCAD/FreeCAD-addons/issues/151)
# * Skipping restart trick (https://github.com/FreeCAD/FreeCAD-addons/issues/29)
# * Proxy support (when it's implemented)
# * https://github.com/FreeCAD/FreeCAD-addons/issues/157
# * https://github.com/FreeCAD/FreeCAD-addons/issues/57
# * Security audit
# * Translations? https://github.com/FreeCAD/FreeCAD-addons/issues/68
import os, re, shutil, stat, sys, tempfile
import git
# import emoji # for friendlier output (maybe test terminal first to see if color emojis are allowed?)
import FreeCAD
import addonmanager_utilities as utils
import time # slow down script in certain chokepoints
# -----------------------------------------------
DEBUG = True # True/False to enable/disable debugging in this script
# import logging; logging.basicConfig(level=logging.DEBUG)
# logger = logging.getLogger()
# logger.disabled = True # This deactivates all logging.debug() functions in script
if DEBUG:
# logger.disabled = False # This activates all logging.debug() functions in script
import pdb # python debugger
import readline # allows for backspace deleting in debugger
# -----------------------------------------------
USAGE = """
CLI Addon Manager
Usage:
\tlist\t\t\t lists all official addons + which are currently installed
\tcheck\t\t\t check what addons have Updates
\tupdateall\t\t update all addons that have pending updates
\tinstall <addon> [<addon>] installs valid (case-sensitive) addons
\tremove <addon> [<addon>] remove valid (case-sensitive) addons
"""
# Blacklisted addons
MACROS_BLACKLIST = ["BOLTS",
"WorkFeatures",
"how to install",
"PartsLibrary",
"FCGear"]
# These addons will print an additional message informing the user
OBSOLETE = ["assembly2",
"drawing_dimensioning",
"cura_engine"]
# logging.debug("CLI arguments: {0}\n".format(sys.argv))
def ListRepo():
u = utils.urlopen("https://github.com/FreeCAD/FreeCAD-addons")
# pdb.set_trace() # breakpoint
if not u:
print("Unable to open URL")
quit()
p = u.read()
if sys.version_info.major >= 3 and isinstance(p, bytes):
p = p.decode("utf-8")
u.close()
p = p.replace("\n"," ")
p = re.findall("octicon-file-submodule(.*?)message",p)
basedir = FreeCAD.getUserAppDataDir()
moddir = basedir + os.sep + "Mod"
q = [] # store addons for column output
# Note:
# repos is a list of [name,url,installbit]
# name : Addon name
# url : Addon repository location
# installbit: 0 = Addon is not installed
# 1 = Addon is installed
# 2 = Addon is installed and checked for available updates (none pending)
# 3 = Addon is installed and has a pending update
repos = []
# querying official addons
for l in p:
#name = re.findall("data-skip-pjax=\"true\">(.*?)<",l)[0]
res = re.findall("title=\"(.*?) @",l)
if res:
name = res[0]
q.append(res[0])
else:
print("Debug: couldn't find title in",l)
continue
# print(name) # print repo name by itself
#url = re.findall("title=\"(.*?) @",l)[0]
url = utils.getRepoUrl(l)
if url:
addondir = moddir + os.sep + name
# DEBUG: print ("found:",name," at ",url)
if os.path.exists(addondir) and os.listdir(addondir):
# make sure the folder exists and it contains files!
state = 1
else:
state = 0
repos.append([name,url,state])
# querying custom addons
# customaddons = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons").GetString("CustomRepositories","").split("\n")
# for url in customaddons:
# if url:
# name = url.split("/")[-1]
# if name.lower().endswith(".git"):
# name = name[:-4]
# addondir = moddir + os.sep + name
# if not os.path.exists(addondir):
# sfor repo in repos:tate = 0
# else:
# state = 1
# repos.append([name,url,state])
if not repos:
print("Unable to download addon list.")
else:
repos = sorted(repos, key=lambda s: s[0].lower())
print("List official available Addons:\n")
time.sleep(1)
# for repo in repos:
# # addon installed
# if repo[2] == 1:
# # distinguish w/ preceding '+' and addon name
# print(' +\t' + repo[0])
# # addon not installed
# if repo[2] == 0:
# # print uninstalled addons name
# print('\t' + repo[0])
# print a legend
# print('\nLegend:\n{0} = Installed\n'.format(emoji.emojize(':heavy_check_mark:')))
# pdb.set_trace() # breakpoint
# print('\nLegend:\n+ = Installed\n')
# Output all official addons in 3 columns
for x,y,z in zip(q[::3],q[1::3],q[2::3]):
print('\t{:<30}{:<30}{:<30}'.format(x,y,z))
# display what addons are installed
print("\nCurrently installed Addons:\n")
for repo in repos:
if repo[2] == 1:
print('\t'+repo[0])
return(repos)
def CheckUpdateRepo(repos):
''' Check if Addon(s) has/have (an) update(s)'''
basedir = FreeCAD.getUserAppDataDir()
moddir = basedir + os.sep + "Mod"
# upds = [] # store updates
for repo in repos:
if repo[2] == 1:
print("Checking updates for",repo[0])
clonedir = moddir + os.sep + repo[0]
# if os.path.exists(clonedir):
# # check if this addon was not installed with git
# if not os.path.exists(clonedir + os.sep + '.git'):
# # It wasn't, therefore repair addon installed with raw download
# # creating a git.Repo object to represent repo
# bare_repo = git.Repo.clone_from(repo[1], clonedir + os.sep + '.git', bare=True)
# try:
# with bare_repo.config_writer() as cw:
# cw.set('core', 'bare', False)
# except AttributeError:
# if not gitpython_warning:
# print('Outdated GitPython detected, consider upgrading with pip')
# gitpython_warning = True
# cw = bare_repo.config_writer()
# cw.set('core', 'bare', False)
# del cw
# repo = git.Repo(clonedir)
# repo.head.reset('--hard')
# clone the addon repo
gitrepo = git.Git(clonedir)
try:
# gitrepo.fetch()
gitrepo.status()
# TODO: (?) write a little regex for "Your branch is up to date"
# print(gitrepo.status()+'\n')
repos[repos.index(repo)][2] = 2 # mark as already installed AND already checked for updates
# pdb.set_trace() # breakpoint
except:
print("AddonManager: Unable to fetch git updates for repo",repo[0])
else:
# pdb.set_trace() # breakpoint
# 'git status' will tell us if we need to 'git pull'
# note: print(gitrepo.status()) for troubleshooting
if "git pull" in gitrepo.status():
print('\t' + repo[0] + ' ' + 'has an update')
status = re.findall(r"Your branch (.*)\,", gitrepo.status())
# print how many commits we are behind master
print('\t\t' + repo[0] + ' ' + status[0] + '\n')
# upds.append(repo) # not sure this is necessary due to:
repos[repos.index(repo)][2] = 3 # mark as already installed AND already checked for updates AND update available
# pdb.set_trace() # breakpoint
return(repos)
def UpdateRepo(repos):
'''Updates the selected addon(s)'''
basedir = FreeCAD.getUserAppDataDir()
moddir = basedir + os.sep + "Mod"
if not os.path.exists(moddir):
os.makedirs(moddir)
for repo in repos:
if repo[2] == 3: # installed but has an update pending
clonedir = moddir + os.sep + repo[0]
if os.path.exists(clonedir):
print('\tUpdating ' + repo[0] + ' addon...')
# first check if the addon was originally downloaded as a zip
# if not os.path.exists(clonedir + os.sep + '.git'):
# # Repair addon installed with raw download
# bare_repo = git.Repo.clone_from(repo[1], clonedir + os.sep + '.git', bare=True)
# try:
# with bare_repo.config_writer() as cw:
# cw.set('core', 'bare', False)
# except AttributeError:
# print("Outdated GitPython detected, consider upgrading with pip.")
# cw = bare_repo.config_writer()
# cw.set('core', 'bare', False)
# del cw
# repo = git.Repo(clonedir)
# repo.head.reset('--hard')
repo = git.Git(clonedir)
try:
answer = repo.pull()
except:
print("Error updating module ",repo[1]," - Please fix manually")
answer = repo.status()
print(answer)
else:
# Update the submodules for this repository
repo_sms = git.Repo(clonedir)
for submodule in repo_sms.submodules:
submodule.update(init=True, recursive=True)
else:
print("Seems like " +clonedir+ " doesn't exist")
return(repos)
def CheckRequested(repos, requested):
'''Test requested to be installed addon against official list'''
validAddons = []
req = ' '.join(requested)
print("\nChecking {0} against official addons\n".format(req))
for request in requested:
for repo in repos:
if repo[0] == request:
print('\t' + request + ' is an official valid Addon')
# store addon in a separate list
validAddons.append(repo)
# somehow notify user that an addon name was invalid
# else:
# print(request + ' is not a official and valid Addon')
# requested.remove(request)
# return
# for valid in validAddon
# print(valid)
# Valid addon contains the workbenches that are to be installed
return(validAddons)
def InstallRepo(repos, validAddons):
'''installs selected addon(s)'''
basedir = FreeCAD.getUserAppDataDir()
moddir = basedir + os.sep + "Mod"
if not os.path.exists(moddir):
os.makedirs(moddir)
# validAddons is a list of 1 or more Addons to install
for addon in validAddons:
clonedir = moddir + os.sep + addon[0]
# pdb.set_trace() # breakpoint
if os.path.exists(clonedir):
print('Installing ' + addon[0] + ' addon...')
# first check if the addon was originally downloaded as a zip
if not os.path.exists(clonedir + os.sep + '.git'):
# Repair addon installed with raw download
bare_repo = git.Repo.clone_from(addon[1], clonedir + os.sep + '.git', bare=True)
try:
with bare_repo.config_writer() as cw:
cw.set('core', 'bare', False)
except AttributeError:
print("Outdated GitPython detected, consider upgrading with pip.")
cw = bare_repo.config_writer()
cw.set('core', 'bare', False)
del cw
repo = git.Repo(clonedir)
repo.head.reset('--hard')
repo = git.Git(clonedir)
try:
print('\tInstalling ' + clonedir)
answer = repo.pull()
print('\tInstalled ' + addon + '\n')
except:
print("\tError updating module ",addon[1]," - Please fix manually")
print("\tPath: " + clonedir + '\n')
answer = repo.status()
print(answer)
else:
# Update the submodules for this repository
repo_sms = git.Repo(clonedir)
for submodule in repo_sms.submodules:
submodule.update(init=True, recursive=True)
def remove_readonly(func, path, _):
"Clear the readonly bit and reattempt the removal"
os.chmod(path, stat.S_IWRITE)
func(path)
def RemoveRepo(removing):
"""uninstalls a macro or workbench"""
basedir = FreeCAD.getUserAppDataDir()
moddir = basedir + os.sep + "Mod"
success = ''
# removing is a list of 1 or more Addons to remove
for remove in removing:
clonedir = moddir + os.sep + remove
if os.path.exists(clonedir):
shutil.rmtree(clonedir, onerror=remove_readonly)
print("Removed {0} addon successfully\n".format(remove))
success = 1
else:
print("Unable to remove the {} addon".format(remove))
if (success):
print("\tNote: Please restart FreeCAD (if it's running in the foreground) for changes to take effect")
# Show progress bars for git activities <-- Unimplemented
class Progress(git.remote.RemoteProgress):
def update(self, op_code, cur_count, max_count=None, message=''):
print(self._cur_line)
# def update(self, op_code, cur_count, max_count=None, message=''):
# print(op_code, cur_count, max_count, cur_count / (max_count or 100.0), message or "NO MESSAGE")
if __name__ == '__main__':
# if no command line arguments OR 'help'
if len(sys.argv) == 3 or sys.argv[3] is 'help':
print(USAGE)
# list
elif sys.argv[3] == "list":
ListRepo()
# check
elif sys.argv[3] == "check":
# repos list is the updated snapshot of local addon system
repos = ListRepo()
CheckUpdateRepo(repos)
# updateall
elif sys.argv[3] == 'updateall':
# repos list is the updated snapshot of local addon system
repos = ListRepo()
repos = CheckUpdateRepo(repos)
repos = UpdateRepo(repos)
# install
elif sys.argv[3] == "install":
if len(sys.argv) == 4:
print('''Error: missing addon name(s).
Please specify valid and case-sensitive addons to pass to the 'install' flag.
For example: install A2plus BIM
''')
sys.exit()
elif sys.argv[4]:
requested = sys.argv[4:]
output = ' '.join(requested)
print('Addon(s) requested:\n\t{0}\n'.format(output))
# first get updated snapshot of the addon system
repos = ListRepo()
# check if the addons requested are valid
validAddons = CheckRequested(repos, requested)
# install requested valid addons
InstallRepo(repos, validAddons)
else:
print("Unknown error in 'install' flag")
sys.exit()
# remove
elif sys.argv[3] == "remove":
if len(sys.argv) == 4:
print('''Error: missing addon name(s).
Please specify valid and case-sensitive addons to pass to the 'remove' flag.
For example: remove A2plus BIM
''')
sys.exit()
elif sys.argv[4]:
removing = sys.argv[4:]
output = ' '.join(removing)
print('Addon(s) to remove:\n\t{0}\n'.format(output))
# no checks, just send potential addons to get deleted
RemoveRepo(removing)
else:
print("Unknown error in 'remove' flag")
sys.exit()
else:
print(USAGE)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.