Skip to content

Instantly share code, notes, and snippets.

@deanishe
Last active April 28, 2019 18:48
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save deanishe/aaf9eb16466ed5ade9d5 to your computer and use it in GitHub Desktop.
Save deanishe/aaf9eb16466ed5ade9d5 to your computer and use it in GitHub Desktop.
Packal.org Python API library
#!/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