Created
May 22, 2021 22:40
-
-
Save Aareon/e18e9b0eb854551812cbc0db0e03c7b5 to your computer and use it in GitHub Desktop.
Pythonista-Tools Installer Patch
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!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