#!/usr/bin/env python
# encoding: utf-8
#
# Copyright (c) 2016 Dean Jackson <deanishe@deanishe.net>
#
# MIT Licence. See http://opensource.org/licenses/MIT
#
# Created on 2016-05-21
#
"""Lightweight Alfred 3+ workflow library."""
from __future__ import print_function, unicode_literals, absolute_import
import json
import os
import sys
import time
from uuid import uuid4
# System error-type icon. Used by rescue() on error.
ICON_ERROR = ('/System/Library/CoreServices/CoreTypes.bundle/Contents/'
'Resources/AlertStopIcon.icns')
[docs]def log(s, *args):
"""Print message `s` to STDERR. Run `s % args` with any `args`.
Args:
s (unicode): Message to print/format string.
*args (object): If given, used in format string `s % args`.
"""
if args:
s = s % args
if isinstance(s, unicode):
s = s.encode('utf-8')
print(s, file=sys.stderr)
[docs]def human_time(seconds):
"""Human-readable duration, e.g. 5m2s or 1h2m.
Args:
seconds (float): Number of seconds.
Returns:
unicode: Human-readable duration.
"""
s = seconds
if s < 5:
return '{:0.2f}s'.format(s)
if s < 60:
return '{:0.0f}s'.format(s)
m, s = divmod(s, 60)
if m < 60:
return '{:0.0f}m{:0.0f}s'.format(m, s)
h, m = divmod(m, 60)
if h < 24:
return '{:0.0f}h{:0.0f}m'.format(h, m)
d, h = divmod(h, 24)
return '{:0.0f}d{:0.0f}h{:0.0f}m'.format(d, h, m)
def _find_upwards(fname):
"""Find file named `fname` in the current directory or above.
Args:
fname (unicode): Filename
Returns:
unicode: Absolute path to file or `None`.
"""
dirpath = os.path.abspath(os.path.dirname(__file__))
while True:
p = os.path.join(dirpath, fname)
if os.path.exists(p):
log('Found `%s` at `%s`', fname, p)
return p
if dirpath == '/':
return None
dirpath = os.path.dirname(dirpath)
[docs]def run_command(cmd):
"""Run command and return output.
Args:
cmd (sequence): Sequence or arguments to pass to `Popen`
Returns:
tuple: `(stdout, stderr)`
Raises:
CalledProcessError: Raised if command exits with non-zero status
"""
from subprocess import Popen, CalledProcessError
p = Popen(cmd)
stdout, stderr = p.communicate()
if p.returncode != 0:
raise CalledProcessError(p.returncode, cmd)
return (stdout, stderr)
[docs]class AttrDict(dict):
"""Dictionary whose keys are also accessible as attributes."""
def __init__(self, *args, **kwargs):
"""Create new `AttrDict`.
Args:
*args (objects): Arguments to `dict.__init__()`
**kwargs (objects): Keyword arguments to `dict.__init__()`
"""
super(AttrDict, self).__init__(*args, **kwargs)
def __getattr__(self, key):
"""Look up attribute as dictionary key.
Args:
key (str): Dictionary key/attribute name.
Returns:
obj: Dictionary value for `key`.
Raises:
AttributeError: Raised if `key` isn't in dictionary.
"""
if key not in self:
raise AttributeError(
"AttrDict object has no attribute '%s'" % key)
return self[key]
def __setattr__(self, key, value):
"""Add `value` to the dictionary under `key`.
Args:
key (str): Dictionary key/attribute name.
value (obj): Value to store for `key`.
"""
self[key] = value
[docs]def alfred_vars():
"""Dict of Alfred's environment variables w/out ``alfred_`` prefix."""
d = AttrDict()
for k in os.environ:
if k.startswith('alfred_'):
d[k[7:]] = os.environ[k].decode('utf-8')
return d
av = alfred_vars()
[docs]class Feedback(object):
"""Alfred 3 JSON results.
Attributes:
items (list): Sequence of `dicts` as generated by `make_item()`.
"""
def __init__(self, items=None):
"""Create new `Feedback` object.
Args:
items (list, optional): Initial items.
"""
# self.vars = {}
# self.config = {}
self.items = items or []
def __str__(self):
"""Alfred 3 JSON format."""
return json.dumps({'items': self.items}, indent=2)
[docs] def send(self):
"""Send self as results to Alfred 3."""
print(str(self))
[docs]def make_item(title, subtitle=u'', arg=None, icon=None, match=None, **wfvars):
"""Create new Alfred 3 result.
Args:
title (unicode): Title of the result.
subtitle (unicode, optional): Subtitle of the result.
arg (unicode, optional): Arg (value) of the result.
icon (unicode, optional): Path to icon for result.
match (str, optional): Match field.
**wfvars (dict): Unicode values to set as Alfred workflow variables
with this result.
Returns:
dict: Alfred result.
"""
it = {
'title': title,
'subtitle': subtitle,
# 'autocomplete': title,
'text': {
'copy': title,
'largetype': title,
},
'valid': False,
}
if arg:
it['arg'] = arg
it['valid'] = True
it['text'] = {
'copy': arg,
'largetype': arg,
}
if match:
it['match'] = match
if icon is not None:
it['icon'] = {'path': icon}
if wfvars:
payload = {'alfredworkflow': {'arg': it.get('arg'),
'variables': wfvars}}
it['arg'] = json.dumps(payload)
return it
[docs]def rescue(fn, help_url=None):
"""Wrap callable `fn`, and catch and log any exceptions it raises.
Any captured exception is logged to STDERR and also sent to Alfred
as a result.
Args:
fn (callable): Function/method to call in try ... except block.
help_url (unicode, optional): URL to show when an exception is caught.
"""
st = time.time()
try:
fn()
except Exception as err:
from traceback import print_exc
log('################ FATAL ERROR ##################')
print_exc(file=sys.stderr)
log('################# END ERROR ###################')
if help_url:
log("find assistance at: %s", help_url)
# log('%r\n%s', sys.exc_info()[2], err)
fb = Feedback()
fb.items = [make_item('Fatal error in workflow', unicode(err),
icon=ICON_ERROR)]
print(fb)
log('--------------- %0.3fs elapsed ---------------', time.time() - st)
[docs]def random_bundle_id(prefix=None):
"""Generate random bundle ID based on UUID4.
Args:
prefix (unicode, optional): Prefix for the new bundle ID.
Returns:
unicode: Random bundle ID of form `prefix` + `UUID4`.
"""
return (prefix or '') + uuid4().hex
[docs]def change_bundle_id(newid):
"""Change the bundle ID of the current workflow.
WARNING: The change will not apply for the current run of the workflow.
Args:
newid (unicode): New bundle ID.
"""
ip = _find_upwards('info.plist')
pbcmd = 'Set :bundleid ' + newid
cmd = ['/usr/libexec/PlistBuddy', '-c', pbcmd, ip]
log('cmd=%r', cmd)
run_command(cmd)