Last active
April 28, 2019 18:48
-
-
Save deanishe/aaf9eb16466ed5ade9d5 to your computer and use it in GitHub Desktop.
Packal.org Python API library
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
#!/usr/bin/python | |
# encoding: utf-8 | |
# | |
# Copyright © 2015 deanishe@deanishe.net | |
# | |
# MIT Licence. See http://opensource.org/licenses/MIT | |
# | |
# Created on 2015-10-10 | |
# | |
""" | |
Packal.org API library. | |
Upload themes and workflows. | |
""" | |
from __future__ import print_function, unicode_literals, absolute_import | |
import json | |
import logging | |
import mimetypes | |
import os | |
import string | |
import random | |
import urllib | |
import urllib2 | |
__version__ = '0.1' | |
# Dev site | |
API_ENDPOINT = 'https://mellifluously.org/api/v1/alfred2/{type}/submit' | |
USER_AGENT = 'Packal-Python/{0}'.format(__version__) | |
# Valid characters for multipart form data boundaries | |
BOUNDARY_CHARS = string.digits + string.ascii_letters | |
log = logging.getLogger(__name__) | |
class PackalError(Exception): | |
"""Raised if the API rejects a call.""" | |
def str_dict(dic): | |
"""Convert keys and values in `dic` into UTF-8-encoded strings. | |
Args: | |
dic (dict): Dictionary of unicode keys and values. | |
Returns: | |
dict: Dictionary or UTF-8-encoded keys and values. | |
""" | |
dic2 = {} | |
for k, v in dic.items(): | |
if isinstance(k, unicode): | |
k = k.encode('utf-8') | |
if isinstance(v, unicode): | |
v = v.encode('utf-8') | |
dic2[k] = v | |
return dic2 | |
class Packal(object): | |
"""Interact with the Packal API. | |
Attributes: | |
password (basestring): Your Packal.org password | |
username (basestring): Your Packal.org username | |
""" | |
def __init__(self, username, password): | |
"""Initialise new Packal instance. | |
Args: | |
username (basestring): Your Packal.org username | |
password (basestring): Your Packal.org password | |
""" | |
self.username = username | |
self.password = password | |
# --------------------------------------------------------- | |
# oo | |
# | |
# .d8888b. 88d888b. dP | |
# 88' `88 88' `88 88 | |
# 88. .88 88. .88 88 | |
# `88888P8 88Y888P' dP | |
# 88 | |
# dP | |
# --------------------------------------------------------- | |
def upload_theme(self, name, uri, description, tags=None): | |
"""Upload new/update existing Alfred theme. | |
Args: | |
name (basestring): The name of your theme. | |
uri (basestring): The theme URI, e.g. `alfred://...` | |
description (basestring): Description of your theme. | |
May also be in Markdown format. | |
tags (list, optional): Tags for your theme, e.g. "dark" | |
or "light". | |
Raises: | |
PackalError: if the API rejects a call, e.g. if your | |
username or password is incorrect. | |
urllib2.HTTPError: Raised if there is an error connecting | |
to the Packal.org website. | |
""" | |
tags = tags or [] | |
url = API_ENDPOINT.format(type='theme') | |
theme = { | |
'uri': uri, | |
'name': name, | |
'description': description, | |
'tags': ','.join(tags) | |
} | |
data = self._nest_data('theme', theme) | |
self._request(url, data) | |
def upload_workflow(self, filepath, version): | |
"""Upload .alfredworkflow file at `filepath`. | |
Args: | |
filepath (basestring): Path to .alfredworkflow file. | |
version (basestring): Semver version number. | |
Raises: | |
PackalError: if the API rejects a call, e.g. if your | |
username or password is incorrect. | |
urllib2.HTTPError: Raised if there is an error connecting | |
to the Packal.org website. | |
""" | |
url = API_ENDPOINT.format(type='workflow') | |
data = {'workflow_revision[version]': version} | |
filename = os.path.basename(filepath) | |
with open(filepath, 'rb') as fp: | |
files = { | |
'workflow_revision[file]': | |
{ | |
'filename': filename, | |
'content': fp.read(), | |
'mimetype': 'application/octet-stream' | |
} | |
} | |
self._request(url, data, files) | |
# --------------------------------------------------------- | |
# dP dP | |
# 88 88 | |
# 88d888b. .d8888b. 88 88d888b. .d8888b. 88d888b. .d8888b. | |
# 88' `88 88ooood8 88 88' `88 88ooood8 88' `88 Y8ooooo. | |
# 88 88 88. ... 88 88. .88 88. ... 88 88 | |
# dP dP `88888P' dP 88Y888P' `88888P' dP `88888P' | |
# 88 | |
# dP | |
# --------------------------------------------------------- | |
def _nest_data(self, prefix, data): | |
"""Create nested `dict` for silly old Rails. | |
d = {'first': 'one', 'second': 'two'} | |
_nest_data('thingy', d) | |
{'thingy[first]': 'one', 'thingy[second]', 'two'} | |
Args: | |
prefix (unicode): Prefix under which to nest keys. | |
data (dict): Form field names and values to nest. | |
Returns: | |
dict: Nested dict of form data. | |
""" | |
nested = {} | |
for k, v in data.items(): | |
nested['{0}[{1}]'.format(prefix, k)] = v | |
return nested | |
def _request(self, url, data=None, files=None): | |
"""Send HTTPS request to Packal.org and parse response. | |
Args: | |
url (basestring): API endpoint URL | |
data (dict, optional): Mapping of form fields. Must be | |
a nested dictionary. Username and password are added | |
in this method. | |
files (dict, optional): Mapping of form field names to | |
file data dicts: | |
{'form_name': {'filename': 'somefile.txt', | |
'content': '<binary data>', | |
'mimetype': 'text/plain'}} | |
`mimetype` is optional. | |
Raises: | |
PackalError: if the API rejects a call, e.g. if your | |
username or password is incorrect. | |
urllib2.HTTPError: Raised if there is an error connecting | |
to the Packal.org website. | |
""" | |
headers = {'User-Agent': USER_AGENT} | |
data = data or {} | |
data['username'] = self.username | |
data['password'] = self.password | |
if files: | |
new_headers, data = self._encode_multipart_formdata(data, files) | |
headers.update(new_headers) | |
elif data and isinstance(data, dict): | |
data = urllib.urlencode(str_dict(data)) | |
headers = str_dict(headers) | |
if isinstance(url, unicode): | |
url = url.encode('utf-8') | |
log.debug('url : %s\nheaders: %s', url, headers) | |
status = None | |
error = None | |
req = urllib2.Request(url, data, headers) | |
try: | |
resp = urllib2.urlopen(req) | |
except urllib2.HTTPError as err: | |
error = err | |
try: | |
url = err.geturl() | |
except AttributeError: | |
pass | |
status = err.code | |
else: | |
status = resp.getcode() | |
url = resp.geturl() | |
log.debug('[%s] %s', status, url) | |
if error: | |
raise error | |
# Read JSON response | |
data = json.loads(resp.read()) | |
# log.debug('API response: %s', data) | |
code = data.get('code') | |
msg = data.get('message', 'Unknown error') | |
log.info('[%s] %s', code, msg) | |
if code != 200: | |
raise PackalError(msg) | |
def _encode_multipart_formdata(self, fields, files): | |
"""Encode form data `fields` and `files` for POST request. | |
Args: | |
fields (dict): `name:value` pairs for normal form fields. | |
files (dict): `fieldname:file` pairs for file data: | |
{'fieldname': {'filename': 'blah.txt', | |
'content': '<binary data>', | |
'mimetype': 'text/plain'}} | |
`fieldname` is the name of the HTML form field. | |
`mimetype` is optional. If not provided, it will be guessed | |
from the filename or `application/octet-stream` will be used. | |
Returns: | |
2-tuple: `(headers (dict), body (str))` | |
""" | |
def get_content_type(filename): | |
"""Guess mimetype of `filename`. | |
Args: | |
filename (basestring): Filename of file | |
Returns: | |
str: mimetype of `filename`, e.g. `text/html`. | |
""" | |
return (mimetypes.guess_type(filename)[0] or | |
b'application/octet-stream') | |
boundary = b'-----' + b''.join(random.choice(BOUNDARY_CHARS) | |
for i in range(30)) | |
CRLF = b'\r\n' | |
output = [] | |
# Normal form fields | |
for (name, value) in str_dict(fields).items(): | |
output.append(b'--' + boundary) | |
output.append(b'Content-Disposition: form-data; name="%s"' % name) | |
output.append(b'') | |
output.append(value) | |
# Files to upload | |
for name, d in files.items(): | |
filename = d['filename'] | |
content = d['content'] | |
if 'mimetype' in d: | |
mimetype = d['mimetype'] | |
else: | |
mimetype = get_content_type(filename) | |
if isinstance(name, unicode): | |
name = name.encode('utf-8') | |
if isinstance(filename, unicode): | |
filename = filename.encode('utf-8') | |
if isinstance(mimetype, unicode): | |
mimetype = mimetype.encode('utf-8') | |
output.append(b'--' + boundary) | |
output.append(b'Content-Disposition: form-data; ' | |
b'name="%s"; filename="%s"' % (name, filename)) | |
output.append(b'Content-Type: %s' % mimetype) | |
output.append(b'') | |
output.append(content) | |
output.append(b'--' + boundary + b'--') | |
output.append(b'') | |
body = CRLF.join(output) | |
headers = { | |
b'Content-Type': b'multipart/form-data; boundary=%s' % boundary, | |
b'Content-Length': str(len(body)), | |
} | |
return (headers, body) | |
if __name__ == '__main__': | |
p = Packal(os.getenv('PACKAL_USERNAME'), os.getenv('PACKAL_PASSWORD')) | |
filepath = os.getenv('PACKAL_WORKFLOW') | |
if filepath: | |
print('#' * 40) | |
print('Uploading workflow...') | |
print('#' * 40) | |
p.upload_workflow(filepath, '1.0') | |
else: # Upload theme | |
print('#' * 40) | |
print('Uploading theme...') | |
print('#' * 40) | |
logging.basicConfig(level=logging.DEBUG) | |
theme_uri = ('alfred://theme/searchForegroundColor=rgba(27,27,34,1.00)' | |
'&resultSubtextFontSize=1' | |
'&searchSelectionForegroundColor=rgba(0,0,0,1.00)' | |
'&separatorColor=rgba(148,194,117,0.46)' | |
'&resultSelectedBackgroundColor=rgba(88,137,92,1.00)' | |
'&shortcutColor=rgba(148,194,117,1.00)' | |
'&scrollbarColor=rgba(148,194,117,0.36)' | |
'&imageStyle=8&resultSubtextFont=Helvetica%20Neue' | |
'&background=rgba(183,222,118,1.00)' | |
'&shortcutFontSize=2&searchFontSize=2' | |
'&resultSubtextColor=rgba(88,137,92,0.73)' | |
'&searchBackgroundColor=rgba(233,228,124,1.00)' | |
'&name=Green&resultTextFontSize=2' | |
'&resultSelectedSubtextColor=rgba(148,194,117,1.00)' | |
'&shortcutSelectedColor=rgba(233,228,124,1.00)' | |
'&widthSize=2&border=rgba(9,54,66,0.00)' | |
'&resultTextFont=Helvetica%20Neue' | |
'&resultTextColor=rgba(88,137,92,1.00)' | |
'&cornerRoundness=3' | |
'&searchFont=Helvetica%20Neue%20Light' | |
'&searchPaddingSize=3' | |
'&credits=Dean%20Jackson' | |
'&searchSelectionBackgroundColor=rgba(178,215,255,1.00)' | |
'&resultSelectedTextColor=rgba(233,228,124,1.00)' | |
'&resultPaddingSize=2' | |
'&shortcutFont=Helvetica%20Neue%20Light') | |
description = "It's not easy being green." | |
tags = ['green', 'ugly', 'hard-to-read'] | |
p.upload_theme('Green', theme_uri, description, tags) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment