#!/usr/bin/env python
# encoding: utf-8
"""
#########################
Alfred Bundler for Python
#########################
Alfred Bundler is a framework to help workflow authors manage external
utilities and libraries required by their workflows without having to
include them with each and every workflow.
`Alfred Bundler Homepage/Documentation `_.
**NOTE**: By necessity, this Python implementation does not work in
exactly the same way as the reference PHP/bash implementations by
Shawn Rice.
The purpose of the Bundler is to enable workflow authors to easily
access utilites and libraries that are commonly used without having to
include a copy in every ``.alfredworkflow`` file or worry about installing
them themselves. This way, we can hopefully avoid having, say, 15 copies
of ``cocaoDialog`` clogging up users' Dropboxes, and also allow authors to
distribute workflows with sizeable requirements without their exceeding
GitHub's 10 MB limit for ``.alfredworkflow`` files or causing authors
excessive bandwidth costs.
Unfortunately, due to the nature of Python's import system, it isn't
possible to provide versioned libraries in the way that the PHP Bundler
does, so this version of the Bundler creates an individual library
directory for each workflow and adds it to ``sys.path`` with one simple call.
It is based on `Pip `_,
the de facto Python library installer, and you must create a
``requirements.txt`` file in `the format required by pip `_
in order to take advantage of automatic dependency installation.
Usage
======
Simply include this ``bundler.py`` file (from the Alfred Bundler's
``bundler/bundlets`` directory) alongside your workflow's Python code
where it can be imported.
The Python Bundler provides two main features: the ability to use common
utility programs (e.g. `cocaoDialog `_
or `Pashua `_) simply by asking for
them by name (they will automatically be installed if necessary), and the
ability to automatically install and update any Python libraries required
by your workflows.
Using utilities/assets
----------------------
The basic interface for utilities is::
import bundler
util_path = bundler.utility('utilityName')
which will return the path to the appropriate executable, installing
it first if necessary. You may optionally specify a version number
and/or your own JSON file that defines a non-standard utility.
Please see `the Alfred Bundler documentation `_
for details of the JSON file format and how the Bundler works in general.
Handling Python dependencies
----------------------------
The Python Bundler can also take care of your workflow's dependencies for
you if you create a `requirements.txt `_
file in your workflow root directory and put the following in your Python
source files before trying to import any of those dependencies::
import bundler
bundler.init()
:func:`~bundler.init()` will find your ``requirements.txt`` file (you may alternatively
specify the path explicitly—see :func:`~bundler.init()`)
and call Pip with it (installing Pip first if necessary). Then it will
add the directory it's installed the libraries in to ``sys.path``, so you
can immediately ``import`` those libraries::
import bundler
bundler.init()
import requests # specified in `requirements.txt`
The Bundler doesn't define any explicit exceptions, but may raise any number
of different ones (e.g. :class:`~exceptions.IOError` if a file doesn't
exist or if the computer or PyPi is offline).
By and large, these are not recoverable errors, but if you'd like to ensure
your workflow's users are notified, I recommend (shameless plug) building
your Python workflow with
`Alfred-Workflow `_,
which can catch workflow errors and warn the user (amongst other cool stuff).
Any problems with the Bundler may be raised on
`Alfred's forum `_
or on `GitHub `_.
Alfred Bundler Methods
======================
"""
from __future__ import print_function, unicode_literals
import os
import subprocess
import urllib2
import imp
import logging
import logging.handlers
VERSION = '0.2'
BUNDLER_VERSION = 'devel'
if os.getenv('AB_BRANCH'):
BUNDLER_VERSION = os.getenv('AB_BRANCH')
# Used for notifications, paths
BUNDLER_ID = 'net.deanishe.alfred-bundler-python'
# Bundler paths
BUNDLER_DIR = os.path.expanduser(
'~/Library/Application Support/Alfred 2/Workflow Data/'
'alfred.bundler-{}'.format(BUNDLER_VERSION))
DATA_DIR = os.path.join(BUNDLER_DIR, 'data')
CACHE_DIR = os.path.expanduser(
'~/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/'
'alfred.bundler-{}'.format(BUNDLER_VERSION))
# Main Python library
BUNDLER_PY_LIB = os.path.join(BUNDLER_DIR, 'bundler', 'AlfredBundler.py')
# Root directory under which workflow-specific Python libraries are installed
PYTHON_LIB_DIR = os.path.join(DATA_DIR, 'assets', 'python')
# Wrappers module path
WRAPPERS_DIR = os.path.join(
BUNDLER_DIR, 'bundler', 'includes',
'wrappers', 'python'
)
# Where helper scripts and metadata are stored
HELPER_DIR = os.path.join(PYTHON_LIB_DIR, BUNDLER_ID)
# Where colour alternatives are cached
COLOUR_CACHE = os.path.join(DATA_DIR, 'color-cache')
# Where installer.sh can be downloaded from
BASH_BUNDLET_URL = (
'https://raw.githubusercontent.com/shawnrice/alfred-bundler/'
'{}/bundler/bundlets/alfred.bundler.sh'.format(
BUNDLER_VERSION))
# Bundler log file
BUNDLER_LOGFILE = os.path.join(DATA_DIR, 'logs',
'bundler-{}.log'.format(BUNDLER_VERSION))
# HTTP timeout
HTTP_TIMEOUT = 5
# The actual bundler module will be imported into this variable
_bundler = None
# The wrappers object will be saved to here
_wrappers = None
#-----------------------------------------------------------------------
# Logging
#-----------------------------------------------------------------------
_logdir = os.path.dirname(BUNDLER_LOGFILE)
if not os.path.exists(_logdir): # pragma: no cover
os.makedirs(_logdir, 0755)
_log = logging.getLogger('bundler')
_logfile = logging.handlers.RotatingFileHandler(BUNDLER_LOGFILE,
maxBytes=1024*1024,
backupCount=1)
_console = logging.StreamHandler()
_fmtc = logging.Formatter('[%(asctime)s] [%(filename)s:%(lineno)s] '
'[%(levelname)s] %(message)s',
datefmt='%H:%M:%S')
_fmtf = logging.Formatter('[%(asctime)s] [%(filename)s:%(lineno)s] '
'[%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
_logfile.setFormatter(_fmtf)
_console.setFormatter(_fmtc)
_log.addHandler(_logfile)
_log.addHandler(_console)
_log.setLevel(logging.DEBUG)
_log.debug('Bundler version : {}'.format(BUNDLER_VERSION))
#-----------------------------------------------------------------------
# Installation/update functions
#-----------------------------------------------------------------------
class InstallationError(Exception):
"""Raised if installation of the bash helper script fails"""
def _download(url, filepath):
"""Download ``url`` to ``filepath``
May raise IOError or urllib2.HTTPError
:param url: URL to download
:type url: ``unicode`` or ``str``
:param filepath: Path to download URL to
:type filepath: ``unicode`` or ``str``
:returns: None
"""
_log.debug('Opening URL `{}` ...'.format(url))
response = urllib2.urlopen(url, timeout=HTTP_TIMEOUT)
_log.debug('[{}] {}'.format(response.getcode(), url))
if response.getcode() != 200:
raise IOError(2, 'Error retrieving URL. Server returned {}'.format(
response.getcode()), url)
dirpath = os.path.dirname(filepath)
if not os.path.exists(dirpath):
os.makedirs(dirpath, 0755)
with open(filepath, 'wb') as file:
_log.info('Downloading `{}` ...'.format(url))
file.write(response.read())
_log.info('Saved `{}`'.format(filepath))
def _bootstrap():
"""Check if bundler bash bundlet is installed and install it if not.
:returns: ``None``
"""
global _bundler
global _wrappers
if _bundler is not None: # Already bootstrapped
return
# Create local directories if they don't exist
for dirpath in (HELPER_DIR, CACHE_DIR, COLOUR_CACHE):
if not os.path.exists(dirpath):
_log.debug('Creating directory `{}`'.format(dirpath))
os.makedirs(dirpath)
if not os.path.exists(BUNDLER_PY_LIB): # Install bundler
_log.info('Installing Alfred Dependency Bundler '
'version `{}` ...'.format(BUNDLER_VERSION))
# Install bash bundlet from GitHub
bundlet_path = os.path.join(CACHE_DIR,
'bundlet-{}.sh'.format(os.getpid()))
bash_code = 'source "{}"'.format(bundlet_path)
try:
_download(BASH_BUNDLET_URL, bundlet_path)
except Exception as err:
_log.exception(err)
raise InstallationError(
'Error downloading `{}` to `{}`: {}'.format(
BASH_BUNDLET_URL, bundlet_path, err))
_log.debug('Executing script : `{}`'.format(bash_code))
try:
proc = subprocess.Popen(['/bin/bash'], stdin=subprocess.PIPE)
proc.communicate(bash_code)
if proc.returncode:
raise InstallationError(
'Install script failed (code : {})'.format(proc.returncode))
finally:
os.unlink(bundlet_path)
if not os.path.exists(BUNDLER_PY_LIB): # pragma: no cover
raise InstallationError(
'Error bootstrapping bundler. Bundler installation failed.')
# Import bundler
_bundler = imp.load_source('AlfredBundler', BUNDLER_PY_LIB)
_wrappers_file, _wrappers_filename, _wrappers_data = imp.find_module(
'wrappers', [WRAPPERS_DIR])
_wrappers = imp.load_module(
'wrappers', _wrappers_file, _wrappers_filename, _wrappers_data)
_log.debug('AlfredBundler.py imported')
_bundler.metadata.set_updated()
########################################################################
# User API
########################################################################
def wrapper(wrapper, debug=False):
""" Grab a wrapper's object.
:param wrapper: Title of wrapper referenced at wrappers/__init__.py
:type wrapper: ``str`` or ``unicode``
:param debug: Toggle debugging for returned wrapper
:type debug: bool
"""
_bootstrap()
return _wrappers.wrapper(wrapper.lower(), debug=debug)
def notify(title, message, icon=None): # pragma: no cover
"""Post a notification
:param title: The title of the notification
:type title: ``unicode`` or ``str``
:param message: Main body of the notification
:type message: ``unicode`` or ``str``
:param icon_path: Path to icon to show in notification. If no icon is
specified, the workflow's icon will be used.
:type icon_path: filepath
"""
_bootstrap()
if (isinstance(title, str) or isinstance(title, unicode)) and \
(isinstance(message, str) or isinstance(message, unicode)):
client = wrapper('cocoadialog', debug=True)
icon_type = 'icon'
if icon and (isinstance(icon, str) or isinstance(icon, unicode)):
if not os.path.exists(icon):
if icon not in client.global_icons:
icon_type = None
else:
icon_type = 'icon_file'
else:
icon_type = None
notification = {
'title': title,
'description': message,
'alpha': 1,
'background_top': 'ffffff',
'background_bottom': 'ffffff',
'border_color': 'ffffff',
'text_color': '000000',
'no_growl': True
}
if icon_type:
notification[icon_type] = icon
client.notify(**notification)
return True
else:
return False
def icon(font, icon, color='000000', alter=False):
"""Get path to specified icon, downloading it first if necessary.
``font``, ``icon`` and ``color`` are normalised to lowercase. In
addition, ``color`` is expanded to 6 characters if only 3 are passed.
:param font: name of the font
:type font: ``unicode`` or ``str``
:param icon: name of the font character
:type icon: ``unicode`` or ``str``
:param color: CSS colour in format "xxxxxx" (no preceding #)
:type color: ``unicode`` or ``str``
:param alter: Automatically adjust icon colour to light/dark theme
background
:type alter: ``Boolean``
:returns: path to icon file
:rtype: ``unicode``
See http://icons.deanishe.net to view available icons.
"""
_bootstrap()
return _bundler.icon(font, icon, color, alter)
def utility(name, version='latest', json_path=None):
"""Get path to specified utility or asset, installing it first if necessary.
Use this method to access common command line utilities, such as
`cocaoDialog `_ or
`Terminal-Notifier `_.
This function will return the path to the appropriate executable
(installing it first if necessary), which you can then utilise via
:mod:`subprocess`.
You can easily add your own utilities by means of JSON configuration
files specified with the ``json_path`` argument. Please see
`the Alfred Bundler documentation `_
for details of the JSON file format.
:param name: Name of the utility/asset to install
:type name: ``unicode`` or ``str``
:param version: Desired version of the utility/asset.
:type version: ``unicode`` or ``str``
:param json_path: Path to bundler configuration file
:type json_path: ``unicode`` or ``str``
:returns: Path to utility
:rtype: ``unicode``
"""
_bootstrap()
return _bundler.utility(name, version, json_path)
def asset(name, version='latest', json_path=None):
"""Synonym for :func:`~bundler.utility()`"""
return utility(name, version, json_path)
def init(requirements=None):
"""Install dependencies from ``requirements.txt`` to your workflow's
custom bundler directory and add this directory to ``sys.path``.
Will search up the directory tree for ``requirements.txt`` if
``requirements`` argument is not specified.
**Note:** Your workflow must have a bundle ID set in order to use
this function. (You should set one anyway, especially if you intend
to distribute your workflow.)
Your ``requirements.txt`` file must be in the
`format required by Pip `_.
:param requirements: Path to Pip requirements file
:type requirements: ``unicode`` or ``str``
:returns: ``None``
"""
_bootstrap()
return _bundler.init(requirements)
if __name__ == '__main__': # pragma: no cover
import random
def _colour():
r = random.randint(0, 255)
g = random.randint(0, 255)
b = random.randint(0, 255)
return '{:02x}{:02x}{:02x}'.format(r, g, b)
icon_path = icon('fontawesome', 'gift', 'bd1054')
for name, args in [
# ('Terminal-Notifier',
# ['-title', 'Test', '-message', 'Test']),
('cocoaDialog',
['notify', '--title', 'Bundler Test',
'--text', "How ya doin'?", '--icon-file', icon_path]),
('cocoaDialog',
['msgbox', '--text', 'Test', '--timeout', '2'])]:
path = utility(name)
subprocess.call([path] + args)
print('{} : {}'.format(name, path))
for font, char, colour in [('elusive', 'adjust', _colour()),
('elusive', 'cloud', _colour()),
('elusive', 'cog', _colour()),
('elusive', 'home-alt', _colour()),
('elusive', 'hand-left', _colour()),
('elusive', 'hand-up', _colour()),
('elusive', 'hand-right', _colour()),
('elusive', 'hand-down', _colour())]:
msg = '{} // {} // #{}'.format(font, char, colour)
icon_path = icon(font, char, colour)
notify('Alfred Bundler', msg, icon_path)
# cmd = dialog + ['--text', msg, '--icon-file', path]
# subprocess.call(cmd)