Skip to content

Instantly share code, notes, and snippets.

@Aareon
Created May 22, 2021 22:40
Show Gist options
  • Save Aareon/e18e9b0eb854551812cbc0db0e03c7b5 to your computer and use it in GitHub Desktop.
Save Aareon/e18e9b0eb854551812cbc0db0e03c7b5 to your computer and use it in GitHub Desktop.
Pythonista-Tools Installer Patch
#!coding: utf-8
"""
Installer program to help install tools registered on the Pythonista Tools GitHub repo. Python 3.6+ only
"""
__version__ = '1.1.0'
import functools
try:
import ujson as json # faster json
except ImportError:
import json
import os
from pathlib import Path
import re
import shutil
import sys
import traceback
import zipfile
import requests
# from urllib3.utils import urlparse, urljoin
try:
import ui
import console
import webbrowser
PYTHONISTA = True
except ImportError:
PYTHONISTA = False
INSTALL_PATH_DEFAULT = 'bin'
SCRIPT_DIR = Path(__file__).parent
# TODO: create settings view
# TODO: add cog to rhand btns in nav
# TODO: add readme view for repos and gists with readme
# TODO: change color of uninstall btn to red
# TODO: make install btn start at "Download", then "Install", then "Uninstall"
# TODO: validate zipfile after download for BadZipFile
# ISSUE: some repos use a different api_url for downloding archive
CONF_FILE = SCRIPT_DIR / 'ptinstaller.conf'
class InvalidGistURLError(Exception):
pass
class NoFilesInGistError(Exception):
pass
class RepoDownloadError(Exception):
pass
class GistDownloadError(Exception):
pass
class NoTempDirectory(Exception):
pass
class GitHubConnectionError(Exception):
pass
class GitHubAPI(object):
API_URL = 'https://api.github.com'
_dling = False
@classmethod
def contents(cls, owner, repo):
try:
cls._dling = True
r = requests.get(f'{GitHubAPI.API_URL}/repos/{owner}/{repo}/contents')
cls._dling = False
return json.loads(r.content)
except ConnectionError as e:
logging.info(repr(e))
raise
except Exception as e:
logging.info(repr(e))
raise
class PythonistaToolsRepo:
"""
Manage and gather information from the Pythonista Tools repo.
attrs:
owner: str - Name of repo owner
repo: str - Name of repo
cached_tools_dict: dict - cache of packages in PTools. Available after get_categories
"""
PATTERN_NAME_URL_DESCRIPTION = re.compile(r'^\| *\[([^]]+)\] *(\[([^]]*)\])?[^|]+\| *(.*) *\|', re.MULTILINE)
PATTERN_NAME_URL = re.compile(r'^\[([^]]+)\]: *(.*)', re.MULTILINE)
def __init__(self):
self.owner = 'Pythonista-Tools'
self.repo = 'Pythonista-Tools'
self.cached_tools_dict = {}
self.num_downloading = 0
self.still_getting = False
@ui.in_background
def get_categories(self):
"""
Get URL of all the markdown files that list Pythonista tools of different categories.
"""
self.still_getting = True # set lock while getting
categories = {}
contents = GitHubAPI.contents(self.owner, self.repo)
if contents:
pass
else:
raise GitHubConnectionError
for content in GitHubAPI.contents(self.owner, self.repo):
name = content['name']
if name.endswith('.md') and name != 'README.md':
categories[os.path.splitext(name)[0]] = {
'url': content['download_url'],
'sha': content['sha']}
return categories
def get_tools_from_md(self, url_md):
"""
Retrieve markdown file from the given URL and parse its content to build a dict of tools.
:return:
"""
# If results are available in the cache, avoid hitting the web
if url_md not in self.cached_tools_dict:
md = requests.get(url_md).text
# Find all script name and its url
tools_dict = {}
for name, _, url, description in self.PATTERN_NAME_URL_DESCRIPTION.findall(md):
tools_dict[name] = {'url': url, 'description': description.strip()}
for name, url in self.PATTERN_NAME_URL.findall(md):
if name in tools_dict:
tools_dict[name]['url'] = url
else:
for tool_name, tool_content in tools_dict.items():
if tool_content['url'] == name:
tool_content['url'] = url
if tool_content['description'] == '[{name}]':
tool_content['description'] = url
# Filter out tools that has no download url
self.cached_tools_dict[url_md] = {k: v for k, v in tools_dict.items() if v['url']}
return self.cached_tools_dict[url_md]
class GitHubRepoInstaller(object):
PATTERN_USER_REPO = r'^https?://github.com/(.+)/(.+)'
@staticmethod
def get_github_user_repo(url):
m = re.match(GitHubRepoInstaller.PATTERN_USER_REPO, url)
return m.groups() if m else None
def download(self, url):
user_name, repo_name = self.get_github_user_repo(url)
zipfile_url = f'{url}/{user_name}/{repo_name}/archive/master.zip'
tmp_zipfile = Path(os.environ['TMPDIR']).joinpath('{repo_name}-master.zip')
try:
r = requests.get(zipfile_url)
with open(tmp_zipfile, 'wb') as outs:
outs.write(r.content)
return tmp_zipfile
except ConnectionError:
console.hud_alert('Connection Error!', 1.0, 'error')
except Exception as exc:
logging.error(repr(exc))
finally:
raise
def install(self, url, target_folder):
tmp_zipfile = self.download(url)
base_dir = os.path.splitext(os.path.basename(tmp_zipfile))[0] + '/'
with open(tmp_zipfile, 'rb') as ins:
try:
zipfp = zipfile.ZipFile(ins)
except zipfile.BadZipfile:
console.hud_alert('Could not download archive', 1.0, 'error')
for name in zipfp.namelist():
data = zipfp.read(name)
name = name.split(base_dir, 1)[-1] # strip the top-level target_folder
if name == '': # skip top-level target_folder
continue
fname = target_folder.joinpath(name)
if fname.endswith('/'): # A target_folder
if not fname.exists():
fname.mkdir(parents=True)
else:
with open(fname, 'wb') as fp:
fp.write(data)
class GistInstaller(object):
PATTERN_GIST_ID = r'http(s?)://gist.github.com/([0-9a-zA-Z_-]*)/([0-9a-f]*)'
@staticmethod
def get_gist_id(url):
m = re.match(GistInstaller.PATTERN_GIST_ID, url)
return m.group(3) if m else None
def download(self, url):
gist_id = self.get_gist_id(url)
if not gist_id:
raise InvalidGistURLError(url)
api_url = f'https://api.github.com/gists/{gist_id}'
try:
gist_info = json.loads(requests.get(api_url).text)
files = gist_info['files']
except:
raise GistDownloadError(f'api_url: {json_url}')
file_info_list = []
for file_info in files.values():
lang = file_info.get('language')
if lang != 'Python' and not file_info['filename'].endswith('.pyui'):
continue
file_info_list.append(file_info)
if len(file_info_list) == 0:
raise NoFilesInGistError()
else:
return file_info_list
def install(self, url, target_folder):
for file_info in self.download(url):
with open(target_folder.joinpath(file_info['filename']), 'w') as outs:
outs.write(file_info['content'])
class InstallButton:
INSTALL = ' Install '
UNINSTALL = ' Uninstall '
LOADING = ' Loading '
def __init__(self, app, cell, category_name, tool_name, tool_url):
self.app, self.cell = app, cell
self.category_name, self.tool_name, self.tool_url = category_name, tool_name, tool_url
self.btn = ui.Button()
self.cell.content_view.add_subview(self.btn)
self.btn.font = ('Helvetica', 12)
self.btn.background_color = 'white'
self.btn.border_width = 1
self.btn.corner_radius = 5
self.btn.size_to_fit()
self.btn.width = 58
self.btn.x = self.app.nav_view.width - self.btn.width - 8
self.btn.y = (self.cell.height-self.btn.height) / 2
if self.app.is_tool_installed(self.category_name, tool_name):
self.set_state_uninstall()
else:
self.set_state_install()
def set_state_loading(self):
self.btn.title = self.LOADING
self.btn.action = None
self.btn.tint_color = 'green'
self.btn.border_color = 'green'
def set_state_install(self):
self.btn.title = self.INSTALL
self.btn.action = functools.partial(self.app.install, self)
self.btn.tint_color = 'blue'
self.btn.border_color = 'blue'
def set_state_uninstall(self):
self.btn.title = self.UNINSTALL
self.btn.action = functools.partial(self.app.uninstall, self)
self.btn.tint_color = (0, 0.478, 1)
self.btn.border_color = (0, 0.478, 1)
class ToolsTable(object):
def __init__(self, app, category_name, category_url):
self.app = app
self.category_name = category_name
self.category_url = category_url
self.view = ui.TableView(frame=(0, 0, 640, 640))
self.view.name = category_name
self.tools_dict = self.app.repo.get_tools_from_md(category_url)
self.tool_names = sorted(self.tools_dict.keys())
self.view.data_source = self
self.view.delegate = self
def tableview_number_of_sections(self, tableview):
return 1
def tableview_number_of_rows(self, tableview, section):
return len(self.tools_dict)
def tableview_cell_for_row(self, tableview, section, row):
cell = ui.TableViewCell('subtitle')
tool_name = self.tool_names[row]
tool_url = self.tools_dict[tool_name]['url']
cell.text_label.text = tool_name
cell.detail_text_label.text = self.tools_dict[tool_name]['description']
# TODO: Cell does not increase its height when label has multi lines of text
# cell.detail_text_label.line_break_mode = ui.LB_WORD_WRAP
# cell.detail_text_label.number_of_lines = 0
InstallButton(self.app, cell, self.category_name, tool_name, tool_url)
return cell
def tableview_can_delete(self, tableview, section, row):
return False
def tableview_can_move(self, tableview, section, row):
return False
class CategoriesTable(object):
def __init__(self, app):
self.app = app
self.view = ui.TableView(frame=(0, 0, 640, 640))
self.view.name = 'Categories'
self.categories_dict = {}
self.load()
@ui.in_background
def load(self):
self.app.activity_indicator.start()
try:
self.categories_dict = self.app.repo.get_categories()
if self.categories_dict is None:
# there was a problem getting categories
self.app.activity_indicator.stop()
return
categories_listdatasource = ui.ListDataSource(
{'title': category_name, 'accessory_type': 'disclosure_indicator'}
for category_name in sorted(self.categories_dict.keys())
)
categories_listdatasource.action = self.category_item_tapped
categories_listdatasource.delete_enabled = False
self.view.data_source = categories_listdatasource
self.view.delegate = categories_listdatasource
self.view.reload()
except Exception as e:
console.hud_alert('Failed to load Categories', 'error', 1.0)
logging.warning(f'{repr(e)}')
finally:
self.app.activity_indicator.stop()
@ui.in_background
def category_item_tapped(self, sender):
self.app.activity_indicator.start()
try:
category_name = sender.items[sender.selected_row]['title']
category_url = self.categories_dict[category_name]['url']
tools_table = ToolsTable(self.app, category_name, category_url)
self.app.nav_view.push_view(tools_table.view)
except Exception as e:
console.hud_alert('Failed to load tools list', 'error', 1.0)
logging.warning(f'Failed to load tools list: {repr(e)}')
finally:
self.app.activity_indicator.stop()
class PythonistaToolsInstaller(object):
def __init__(self):
self.repo = PythonistaToolsRepo()
self.github_installer = GitHubRepoInstaller()
self.gist_installer = GistInstaller()
self.activity_indicator = ui.ActivityIndicator(flex='LTRB')
self.activity_indicator.style = 10
categories_table = CategoriesTable(self)
self.nav_view = ui.NavigationView(categories_table.view)
self.nav_view.name = f'Pythonista Tools Installer ({__version__})'
self.nav_view.add_subview(self.activity_indicator)
self.activity_indicator.frame = (0, 0, self.nav_view.width, self.nav_view.height)
self.activity_indicator.bring_to_front()
@staticmethod
def get_install_path():
install_path = INSTALL_PATH_DEFAULT
try:
with open(CONF_FILE, 'r') as f:
config = json.load(f)
install_path = Path(config['install_path'])
except Exception as e:
install_path = INSTALL_PATH_DEFAULT
return install_path
@staticmethod
def get_target_folder(category_name, tool_name):
install_path = PythonistaToolsInstaller.get_install_path()
#install_root = os.path.expanduser('~/Documents/%s' % install_path)
install_root = Path("~/Documents") / install_path
return install_root.joinpath(category_name, tool_name)
@staticmethod
def is_tool_installed(category_name, tool_name):
return PythonistaToolsInstaller.get_target_folder(category_name, tool_name).exists()
@ui.in_background
def install(self, btn, sender):
try:
btn.set_state_loading()
self._install(btn)
except:
raise
@ui.in_background
def _install(self, btn):
self.activity_indicator.start()
install_path = PythonistaToolsInstaller.get_install_path()
target_folder = PythonistaToolsInstaller.get_target_folder(btn.category_name, btn.tool_name)
try:
if self.gist_installer.get_gist_id(btn.tool_url):
if not target_folder.exists():
target_folder.mkdir(parents=True)
self.gist_installer.install(btn.tool_url, target_folder)
elif self.github_installer.get_github_user_repo(btn.tool_url):
if not target_folder.exists():
target_folder.mkdir(parents=True)
self.github_installer.install(btn.tool_url, target_folder)
else: # any other url types, including iTunes
webbrowser.open(btn.tool_url)
btn.set_state_uninstall()
# console.hud_alert('%s installed at %s' % (btn.tool_name, install_path), 'success', 1.0)
console.hud_alert(f'{btn.tool_name} installed at {install_path}', 'success', 1.0)
except Exception as e:
logging.error(repr(e))
# clean up the directory
if target_folder.exists():
shutil.rmtree(target_folder)
btn.set_state_install() # revert the state
# Display some debug messages
etype, evalue, tb = sys.exc_info()
sys.stderr.write(f'{repr(e)}\n')
import traceback
traceback.print_exception(etype, evalue, tb)
console.hud_alert('Installation failed', 'error', 1.0)
finally:
self.activity_indicator.stop()
def uninstall(self, btn, sender):
target_folder = PythonistaToolsInstaller.get_target_folder(btn.category_name, btn.tool_name)
if target_folder.exists():
shutil.rmtree(target_folder)
btn.set_state_install()
console.hud_alert(f'{btn.tool_name} uninstalled', 'success', 1.0)
def launch(self):
self.nav_view.present('fullscreen')
if __name__ == '__main__':
import logging
logger = logging.getLogger('PythonistaTools')
sh = logging.StreamHandler(sys.stdout)
sh.setLevel(logging.INFO)
fh = logging.FileHandler(filename='ptinstaller.log')
fh.setLevel(logging.ERROR)
try:
ptinstaller = PythonistaToolsInstaller()
ptinstaller.launch()
except Exception as e:
logger.error(f'{repr(e)}')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment