diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/13B27ECF-46AA-426A-A93B-8F4085F2F630.png b/13B27ECF-46AA-426A-A93B-8F4085F2F630.png
new file mode 100644
index 0000000..47d8d69
Binary files /dev/null and b/13B27ECF-46AA-426A-A93B-8F4085F2F630.png differ
diff --git a/8256C27F-E95A-4E03-B205-674155C0E420.png b/8256C27F-E95A-4E03-B205-674155C0E420.png
new file mode 100644
index 0000000..47d8d69
Binary files /dev/null and b/8256C27F-E95A-4E03-B205-674155C0E420.png differ
diff --git a/README.md b/README.md
index 95b3d43..220e0b5 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,32 @@
# semaphoreci-workflow
An Alfred3 Workflow for SemaphoreCI
+
+# Usage
+
+Configure auth token
+
+1. Run `ci-auth`.
+1. Press Enter.
+1. Paste your `auth_token` you retrieve from https://semaphoreci.com/users/edit
+
+Search your repos
+
+1. Type `ci`, optionally followed by the name of the project you are looking for
+1. Press `cmd`+`Enter` to open the relative SemaphoreCI page or...
+1. Press `Enter` to see the list of builds and servers and their status.
+
+![Demo](http://g.recordit.co/9LhgHy3Om6.gif)
+
+## Contributing
+
+Bug reports and pull requests are welcome on GitHub.
+This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to
+the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
+
+## License
+
+The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
+
+## Copyright
+
+Coypright 2018 [Renuo AG](https://www.renuo.ch/)
\ No newline at end of file
diff --git a/auth.py b/auth.py
new file mode 100755
index 0000000..fd46678
--- /dev/null
+++ b/auth.py
@@ -0,0 +1,17 @@
+#!/usr/bin/python
+# encoding: utf-8
+
+import sys
+import os
+import re
+
+from workflow import Workflow3, web
+
+def main(wf):
+ query = wf.args[0]
+ wf.save_password('semaphoreci-auth-token', query)
+ wf.send_feedback()
+
+if __name__ == '__main__':
+ wf = Workflow3()
+ sys.exit(wf.run(main))
\ No newline at end of file
diff --git a/failed.png b/failed.png
new file mode 100644
index 0000000..cc8dee6
Binary files /dev/null and b/failed.png differ
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..47d8d69
Binary files /dev/null and b/icon.png differ
diff --git a/info.plist b/info.plist
new file mode 100644
index 0000000..07c40cb
--- /dev/null
+++ b/info.plist
@@ -0,0 +1,294 @@
+
+
+
+
+ bundleid
+
+ connections
+
+ 13B27ECF-46AA-426A-A93B-8F4085F2F630
+
+
+ destinationuid
+ 8256C27F-E95A-4E03-B205-674155C0E420
+ modifiers
+ 0
+ modifiersubtext
+
+ vitoclose
+
+
+
+ destinationuid
+ 74BFD9A4-96CF-46AA-9A9D-0F4544164A86
+ modifiers
+ 1048576
+ modifiersubtext
+
+ vitoclose
+
+
+
+ 643EA774-BE5C-465A-9B51-814C38B96E04
+
+
+ destinationuid
+ 68D40A57-DE4B-45ED-8FCB-3FEABF6D8117
+ modifiers
+ 0
+ modifiersubtext
+
+ vitoclose
+
+
+
+ 8256C27F-E95A-4E03-B205-674155C0E420
+
+
+ destinationuid
+ 8FA5AC9A-FC0F-4FC0-979F-E77F4C4474C9
+ modifiers
+ 0
+ modifiersubtext
+
+ vitoclose
+
+
+
+
+ createdby
+ Alessandro Rodi
+ description
+ Lists SemaphoreCI projects and status
+ disabled
+
+ name
+ SemaphoreCI
+ objects
+
+
+ config
+
+ alfredfiltersresults
+
+ alfredfiltersresultsmatchmode
+ 0
+ argumenttrimmode
+ 0
+ argumenttype
+ 2
+ escaping
+ 0
+ queuedelaycustom
+ 3
+ queuedelayimmediatelyinitially
+
+ queuedelaymode
+ 0
+ queuemode
+ 1
+ runningsubtext
+
+ script
+
+ scriptargtype
+ 1
+ scriptfile
+ semaphore.py
+ subtext
+
+ title
+ App actions
+ type
+ 8
+ withspace
+
+
+ type
+ alfred.workflow.input.scriptfilter
+ uid
+ 8256C27F-E95A-4E03-B205-674155C0E420
+ version
+ 2
+
+
+ config
+
+ alfredfiltersresults
+
+ alfredfiltersresultsmatchmode
+ 2
+ argumenttrimmode
+ 0
+ argumenttype
+ 1
+ escaping
+ 0
+ keyword
+ ci
+ queuedelaycustom
+ 3
+ queuedelayimmediatelyinitially
+
+ queuedelaymode
+ 0
+ queuemode
+ 1
+ runningsubtext
+ Loading the projects list...
+ script
+
+ scriptargtype
+ 1
+ scriptfile
+ semaphore.py
+ subtext
+
+ title
+ Semaphore Projects List
+ type
+ 8
+ withspace
+
+
+ type
+ alfred.workflow.input.scriptfilter
+ uid
+ 13B27ECF-46AA-426A-A93B-8F4085F2F630
+ version
+ 2
+
+
+ config
+
+ browser
+
+ spaces
+
+ url
+ {query}
+ utf8
+
+
+ type
+ alfred.workflow.action.openurl
+ uid
+ 8FA5AC9A-FC0F-4FC0-979F-E77F4C4474C9
+ version
+ 1
+
+
+ config
+
+ browser
+
+ spaces
+
+ url
+ {var:html_url}
+ utf8
+
+
+ type
+ alfred.workflow.action.openurl
+ uid
+ 74BFD9A4-96CF-46AA-9A9D-0F4544164A86
+ version
+ 1
+
+
+ config
+
+ argumenttype
+ 0
+ keyword
+ ci-auth
+ subtext
+
+ text
+ Set your auth key
+ withspace
+
+
+ type
+ alfred.workflow.input.keyword
+ uid
+ 643EA774-BE5C-465A-9B51-814C38B96E04
+ version
+ 1
+
+
+ config
+
+ concurrently
+
+ escaping
+ 0
+ script
+
+ scriptargtype
+ 1
+ scriptfile
+ auth.py
+ type
+ 8
+
+ type
+ alfred.workflow.action.script
+ uid
+ 68D40A57-DE4B-45ED-8FCB-3FEABF6D8117
+ version
+ 2
+
+
+ readme
+
+ uidata
+
+ 13B27ECF-46AA-426A-A93B-8F4085F2F630
+
+ xpos
+ 110
+ ypos
+ 120
+
+ 643EA774-BE5C-465A-9B51-814C38B96E04
+
+ xpos
+ 210
+ ypos
+ 470
+
+ 68D40A57-DE4B-45ED-8FCB-3FEABF6D8117
+
+ xpos
+ 520
+ ypos
+ 500
+
+ 74BFD9A4-96CF-46AA-9A9D-0F4544164A86
+
+ xpos
+ 460
+ ypos
+ 350
+
+ 8256C27F-E95A-4E03-B205-674155C0E420
+
+ xpos
+ 460
+ ypos
+ 70
+
+ 8FA5AC9A-FC0F-4FC0-979F-E77F4C4474C9
+
+ xpos
+ 760
+ ypos
+ 250
+
+
+ webaddress
+
+
+
diff --git a/passed.png b/passed.png
new file mode 100644
index 0000000..0263208
Binary files /dev/null and b/passed.png differ
diff --git a/pending.png b/pending.png
new file mode 100644
index 0000000..7d38b96
Binary files /dev/null and b/pending.png differ
diff --git a/semaphore.png b/semaphore.png
new file mode 100644
index 0000000..47d8d69
Binary files /dev/null and b/semaphore.png differ
diff --git a/semaphore.py b/semaphore.py
new file mode 100755
index 0000000..74ce200
--- /dev/null
+++ b/semaphore.py
@@ -0,0 +1,71 @@
+#!/usr/bin/python
+# encoding: utf-8
+
+import sys
+import os
+import re
+import json
+
+from workflow import Workflow3, ICON_WEB, ICON_WARNING, web, Variables, PasswordNotFound
+from workflow.background import run_in_background, is_running
+
+ICON_SEMAPHORE = '%s/semaphore.png' %(os.path.dirname(os.path.abspath(__file__)))
+
+def main(wf):
+ args = wf.args
+
+ auth_token = None
+ try:
+ auth_token = wf.get_password('semaphoreci-auth-token')
+ except PasswordNotFound: # API key has not yet been set
+ wf.add_item('No API key set.',
+ 'Please use ci-auth to set your SemaphoreCI auth key.',
+ arg='https://semaphoreci.com/users/edit',
+ valid=False,
+ icon=ICON_WARNING)
+ wf.send_feedback()
+ return 0
+ query = None
+
+ if not wf.cached_data_fresh('projects', 10):
+ run_in_background('update',['/usr/bin/python', wf.workflowfile('update_projects.py')])
+ projects = sorted(wf.cached_data('projects', max_age=0), key=lambda project: project['updated_at'], reverse=True)
+
+ if projects:
+ if (len(args) == 0):
+ for project in projects:
+ arg = project['name']
+ item = wf.add_item(project['name'], '', arg=arg, valid=True, icon=ICON_SEMAPHORE)
+ item.setvar('html_url', project['html_url'])
+ else:
+ query = args[0]
+ project = next( (p for p in projects if p['name'] == query), None)
+ if project is not None:
+ branches = sorted(project['branches'], key=lambda branch: branch['started_at'], reverse=True)[:3]
+ for branch in branches:
+ subtitle = '%s by %s' %(branch['commit']['message'], branch['commit']['author_name'])
+ icon = '%s/%s.png' %(os.path.dirname(os.path.abspath(__file__)),branch['result'])
+ wf.add_item(branch['branch_name'], subtitle, arg=branch['build_url'],
+ valid=True, icon=icon)
+
+ servers = project['servers']
+ for server in filter(None, servers):
+ subtitle = '%s by %s' %(server['commit']['message'], server['commit']['author_name'])
+ icon = '%s/server_%s.png' %(os.path.dirname(os.path.abspath(__file__)),branch['result'])
+ wf.add_item(server['server_name'], subtitle, arg=server['server_html_url'],
+ valid=True, icon=icon)
+ if len(branches) > 0:
+ github_url = branches[0]['commit']['url']
+ if 'github.com' in github_url:
+ pr_url = re.sub(r'/commit/.*','/pulls', github_url)
+ wf.add_item('Open GitHub Pull Requests', pr_url, arg=pr_url,
+ valid=True, icon=ICON_WEB)
+ wf.add_item('Open on SemaphoreCI', project['html_url'], arg=project['html_url'],
+ valid=True, icon=ICON_WEB)
+
+ wf.send_feedback()
+
+
+if __name__ == '__main__':
+ wf = Workflow3()
+ sys.exit(wf.run(main))
\ No newline at end of file
diff --git a/server_failed.png b/server_failed.png
new file mode 100644
index 0000000..504ffbb
Binary files /dev/null and b/server_failed.png differ
diff --git a/server_passed.png b/server_passed.png
new file mode 100644
index 0000000..852309b
Binary files /dev/null and b/server_passed.png differ
diff --git a/server_stopped.png b/server_stopped.png
new file mode 100644
index 0000000..504ffbb
Binary files /dev/null and b/server_stopped.png differ
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..8128165
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[install]
+prefix=
diff --git a/stopped.png b/stopped.png
new file mode 100644
index 0000000..f6cb238
Binary files /dev/null and b/stopped.png differ
diff --git a/update_projects.py b/update_projects.py
new file mode 100755
index 0000000..751d6fe
--- /dev/null
+++ b/update_projects.py
@@ -0,0 +1,30 @@
+#!/usr/bin/python
+# encoding: utf-8
+
+import sys
+import os
+import re
+
+from workflow import Workflow3, web
+
+def get_projects_list(auth_token):
+ alfred_projects = []
+ url = 'https://semaphoreci.com/api/v1/projects'
+ params = dict(auth_token=auth_token)
+ r = web.get(url, params)
+ r.raise_for_status()
+ alfred_projects = r.json()
+ return sorted(alfred_projects, key=lambda project: project['name'].lower())
+
+def main(wf):
+
+ def wrapper():
+ auth_token = wf.get_password('semaphoreci-auth-token')
+ return get_projects_list(auth_token)
+
+ wf.cached_data('projects', wrapper)
+ wf.send_feedback()
+
+if __name__ == '__main__':
+ wf = Workflow3()
+ sys.exit(wf.run(main))
\ No newline at end of file
diff --git a/workflow/.alfredversionchecked b/workflow/.alfredversionchecked
new file mode 100644
index 0000000..e69de29
diff --git a/workflow/Notify.tgz b/workflow/Notify.tgz
new file mode 100644
index 0000000..174e9a7
Binary files /dev/null and b/workflow/Notify.tgz differ
diff --git a/workflow/__init__.py b/workflow/__init__.py
new file mode 100644
index 0000000..2c4f8c0
--- /dev/null
+++ b/workflow/__init__.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""A helper library for `Alfred `_ workflows."""
+
+import os
+
+# Workflow objects
+from .workflow import Workflow, manager
+from .workflow3 import Variables, Workflow3
+
+# Exceptions
+from .workflow import PasswordNotFound, KeychainError
+
+# Icons
+from .workflow import (
+ ICON_ACCOUNT,
+ ICON_BURN,
+ ICON_CLOCK,
+ ICON_COLOR,
+ ICON_COLOUR,
+ ICON_EJECT,
+ ICON_ERROR,
+ ICON_FAVORITE,
+ ICON_FAVOURITE,
+ ICON_GROUP,
+ ICON_HELP,
+ ICON_HOME,
+ ICON_INFO,
+ ICON_NETWORK,
+ ICON_NOTE,
+ ICON_SETTINGS,
+ ICON_SWIRL,
+ ICON_SWITCH,
+ ICON_SYNC,
+ ICON_TRASH,
+ ICON_USER,
+ ICON_WARNING,
+ ICON_WEB,
+)
+
+# Filter matching rules
+from .workflow import (
+ MATCH_ALL,
+ MATCH_ALLCHARS,
+ MATCH_ATOM,
+ MATCH_CAPITALS,
+ MATCH_INITIALS,
+ MATCH_INITIALS_CONTAIN,
+ MATCH_INITIALS_STARTSWITH,
+ MATCH_STARTSWITH,
+ MATCH_SUBSTRING,
+)
+
+
+__title__ = 'Alfred-Workflow'
+__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
+__author__ = 'Dean Jackson'
+__licence__ = 'MIT'
+__copyright__ = 'Copyright 2014-2017 Dean Jackson'
+
+__all__ = [
+ 'Variables',
+ 'Workflow',
+ 'Workflow3',
+ 'manager',
+ 'PasswordNotFound',
+ 'KeychainError',
+ 'ICON_ACCOUNT',
+ 'ICON_BURN',
+ 'ICON_CLOCK',
+ 'ICON_COLOR',
+ 'ICON_COLOUR',
+ 'ICON_EJECT',
+ 'ICON_ERROR',
+ 'ICON_FAVORITE',
+ 'ICON_FAVOURITE',
+ 'ICON_GROUP',
+ 'ICON_HELP',
+ 'ICON_HOME',
+ 'ICON_INFO',
+ 'ICON_NETWORK',
+ 'ICON_NOTE',
+ 'ICON_SETTINGS',
+ 'ICON_SWIRL',
+ 'ICON_SWITCH',
+ 'ICON_SYNC',
+ 'ICON_TRASH',
+ 'ICON_USER',
+ 'ICON_WARNING',
+ 'ICON_WEB',
+ 'MATCH_ALL',
+ 'MATCH_ALLCHARS',
+ 'MATCH_ATOM',
+ 'MATCH_CAPITALS',
+ 'MATCH_INITIALS',
+ 'MATCH_INITIALS_CONTAIN',
+ 'MATCH_INITIALS_STARTSWITH',
+ 'MATCH_STARTSWITH',
+ 'MATCH_SUBSTRING',
+]
diff --git a/workflow/__init__.pyc b/workflow/__init__.pyc
new file mode 100644
index 0000000..fcf47bb
Binary files /dev/null and b/workflow/__init__.pyc differ
diff --git a/workflow/background.py b/workflow/background.py
new file mode 100644
index 0000000..cd5400b
--- /dev/null
+++ b/workflow/background.py
@@ -0,0 +1,293 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 deanishe@deanishe.net
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-04-06
+#
+
+"""
+This module provides an API to run commands in background processes.
+Combine with the :ref:`caching API ` to work from cached data
+while you fetch fresh data in the background.
+
+See :ref:`the User Manual ` for more information
+and examples.
+"""
+
+from __future__ import print_function, unicode_literals
+
+import signal
+import sys
+import os
+import subprocess
+import pickle
+
+from workflow import Workflow
+
+__all__ = ['is_running', 'run_in_background']
+
+_wf = None
+
+
+def wf():
+ global _wf
+ if _wf is None:
+ _wf = Workflow()
+ return _wf
+
+
+def _log():
+ return wf().logger
+
+
+def _arg_cache(name):
+ """Return path to pickle cache file for arguments.
+
+ :param name: name of task
+ :type name: ``unicode``
+ :returns: Path to cache file
+ :rtype: ``unicode`` filepath
+
+ """
+ return wf().cachefile(name + '.argcache')
+
+
+def _pid_file(name):
+ """Return path to PID file for ``name``.
+
+ :param name: name of task
+ :type name: ``unicode``
+ :returns: Path to PID file for task
+ :rtype: ``unicode`` filepath
+
+ """
+ return wf().cachefile(name + '.pid')
+
+
+def _process_exists(pid):
+ """Check if a process with PID ``pid`` exists.
+
+ :param pid: PID to check
+ :type pid: ``int``
+ :returns: ``True`` if process exists, else ``False``
+ :rtype: ``Boolean``
+
+ """
+ try:
+ os.kill(pid, 0)
+ except OSError: # not running
+ return False
+ return True
+
+
+def _job_pid(name):
+ """Get PID of job or `None` if job does not exist.
+
+ Args:
+ name (str): Name of job.
+
+ Returns:
+ int: PID of job process (or `None` if job doesn't exist).
+ """
+ pidfile = _pid_file(name)
+ if not os.path.exists(pidfile):
+ return
+
+ with open(pidfile, 'rb') as fp:
+ pid = int(fp.read())
+
+ if _process_exists(pid):
+ return pid
+
+ try:
+ os.unlink(pidfile)
+ except Exception: # pragma: no cover
+ pass
+
+
+def is_running(name):
+ """Test whether task ``name`` is currently running.
+
+ :param name: name of task
+ :type name: unicode
+ :returns: ``True`` if task with name ``name`` is running, else ``False``
+ :rtype: bool
+
+ """
+ if _job_pid(name) is not None:
+ return True
+
+ return False
+
+
+def _background(pidfile, stdin='/dev/null', stdout='/dev/null',
+ stderr='/dev/null'): # pragma: no cover
+ """Fork the current process into a background daemon.
+
+ :param pidfile: file to write PID of daemon process to.
+ :type pidfile: filepath
+ :param stdin: where to read input
+ :type stdin: filepath
+ :param stdout: where to write stdout output
+ :type stdout: filepath
+ :param stderr: where to write stderr output
+ :type stderr: filepath
+
+ """
+ def _fork_and_exit_parent(errmsg, wait=False, write=False):
+ try:
+ pid = os.fork()
+ if pid > 0:
+ if write: # write PID of child process to `pidfile`
+ tmp = pidfile + '.tmp'
+ with open(tmp, 'wb') as fp:
+ fp.write(str(pid))
+ os.rename(tmp, pidfile)
+ if wait: # wait for child process to exit
+ os.waitpid(pid, 0)
+ os._exit(0)
+ except OSError as err:
+ _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror)
+ raise err
+
+ # Do first fork and wait for second fork to finish.
+ _fork_and_exit_parent('fork #1 failed', wait=True)
+
+ # Decouple from parent environment.
+ os.chdir(wf().workflowdir)
+ os.setsid()
+
+ # Do second fork and write PID to pidfile.
+ _fork_and_exit_parent('fork #2 failed', write=True)
+
+ # Now I am a daemon!
+ # Redirect standard file descriptors.
+ si = open(stdin, 'r', 0)
+ so = open(stdout, 'a+', 0)
+ se = open(stderr, 'a+', 0)
+ if hasattr(sys.stdin, 'fileno'):
+ os.dup2(si.fileno(), sys.stdin.fileno())
+ if hasattr(sys.stdout, 'fileno'):
+ os.dup2(so.fileno(), sys.stdout.fileno())
+ if hasattr(sys.stderr, 'fileno'):
+ os.dup2(se.fileno(), sys.stderr.fileno())
+
+
+def kill(name, sig=signal.SIGTERM):
+ """Send a signal to job ``name`` via :func:`os.kill`.
+
+ .. versionadded:: 1.29
+
+ Args:
+ name (str): Name of the job
+ sig (int, optional): Signal to send (default: SIGTERM)
+
+ Returns:
+ bool: `False` if job isn't running, `True` if signal was sent.
+ """
+ pid = _job_pid(name)
+ if pid is None:
+ return False
+
+ os.kill(pid, sig)
+ return True
+
+
+def run_in_background(name, args, **kwargs):
+ r"""Cache arguments then call this script again via :func:`subprocess.call`.
+
+ :param name: name of job
+ :type name: unicode
+ :param args: arguments passed as first argument to :func:`subprocess.call`
+ :param \**kwargs: keyword arguments to :func:`subprocess.call`
+ :returns: exit code of sub-process
+ :rtype: int
+
+ When you call this function, it caches its arguments and then calls
+ ``background.py`` in a subprocess. The Python subprocess will load the
+ cached arguments, fork into the background, and then run the command you
+ specified.
+
+ This function will return as soon as the ``background.py`` subprocess has
+ forked, returning the exit code of *that* process (i.e. not of the command
+ you're trying to run).
+
+ If that process fails, an error will be written to the log file.
+
+ If a process is already running under the same name, this function will
+ return immediately and will not run the specified command.
+
+ """
+ if is_running(name):
+ _log().info('[%s] job already running', name)
+ return
+
+ argcache = _arg_cache(name)
+
+ # Cache arguments
+ with open(argcache, 'wb') as fp:
+ pickle.dump({'args': args, 'kwargs': kwargs}, fp)
+ _log().debug('[%s] command cached: %s', name, argcache)
+
+ # Call this script
+ cmd = ['/usr/bin/python', __file__, name]
+ _log().debug('[%s] passing job to background runner: %r', name, cmd)
+ retcode = subprocess.call(cmd)
+
+ if retcode: # pragma: no cover
+ _log().error('[%s] background runner failed with %d', name, retcode)
+ else:
+ _log().debug('[%s] background job started', name)
+
+ return retcode
+
+
+def main(wf): # pragma: no cover
+ """Run command in a background process.
+
+ Load cached arguments, fork into background, then call
+ :meth:`subprocess.call` with cached arguments.
+
+ """
+ log = wf.logger
+ name = wf.args[0]
+ argcache = _arg_cache(name)
+ if not os.path.exists(argcache):
+ msg = '[{0}] command cache not found: {1}'.format(name, argcache)
+ log.critical(msg)
+ raise IOError(msg)
+
+ # Fork to background and run command
+ pidfile = _pid_file(name)
+ _background(pidfile)
+
+ # Load cached arguments
+ with open(argcache, 'rb') as fp:
+ data = pickle.load(fp)
+
+ # Cached arguments
+ args = data['args']
+ kwargs = data['kwargs']
+
+ # Delete argument cache file
+ os.unlink(argcache)
+
+ try:
+ # Run the command
+ log.debug('[%s] running command: %r', name, args)
+
+ retcode = subprocess.call(args, **kwargs)
+
+ if retcode:
+ log.error('[%s] command failed with status %d', name, retcode)
+ finally:
+ os.unlink(pidfile)
+
+ log.debug('[%s] job complete', name)
+
+
+if __name__ == '__main__': # pragma: no cover
+ wf().run(main)
diff --git a/workflow/background.pyc b/workflow/background.pyc
new file mode 100644
index 0000000..baab8b3
Binary files /dev/null and b/workflow/background.pyc differ
diff --git a/workflow/notify.py b/workflow/notify.py
new file mode 100644
index 0000000..4542c78
--- /dev/null
+++ b/workflow/notify.py
@@ -0,0 +1,345 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2015 deanishe@deanishe.net
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2015-11-26
+#
+
+# TODO: Exclude this module from test and code coverage in py2.6
+
+"""
+Post notifications via the macOS Notification Center. This feature
+is only available on Mountain Lion (10.8) and later. It will
+silently fail on older systems.
+
+The main API is a single function, :func:`~workflow.notify.notify`.
+
+It works by copying a simple application to your workflow's data
+directory. It replaces the application's icon with your workflow's
+icon and then calls the application to post notifications.
+"""
+
+from __future__ import print_function, unicode_literals
+
+import os
+import plistlib
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import uuid
+
+import workflow
+
+
+_wf = None
+_log = None
+
+
+#: Available system sounds from System Preferences > Sound > Sound Effects
+SOUNDS = (
+ 'Basso',
+ 'Blow',
+ 'Bottle',
+ 'Frog',
+ 'Funk',
+ 'Glass',
+ 'Hero',
+ 'Morse',
+ 'Ping',
+ 'Pop',
+ 'Purr',
+ 'Sosumi',
+ 'Submarine',
+ 'Tink',
+)
+
+
+def wf():
+ """Return Workflow object for this module.
+
+ Returns:
+ workflow.Workflow: Workflow object for current workflow.
+ """
+ global _wf
+ if _wf is None:
+ _wf = workflow.Workflow()
+ return _wf
+
+
+def log():
+ """Return logger for this module.
+
+ Returns:
+ logging.Logger: Logger for this module.
+ """
+ global _log
+ if _log is None:
+ _log = wf().logger
+ return _log
+
+
+def notifier_program():
+ """Return path to notifier applet executable.
+
+ Returns:
+ unicode: Path to Notify.app ``applet`` executable.
+ """
+ return wf().datafile('Notify.app/Contents/MacOS/applet')
+
+
+def notifier_icon_path():
+ """Return path to icon file in installed Notify.app.
+
+ Returns:
+ unicode: Path to ``applet.icns`` within the app bundle.
+ """
+ return wf().datafile('Notify.app/Contents/Resources/applet.icns')
+
+
+def install_notifier():
+ """Extract ``Notify.app`` from the workflow to data directory.
+
+ Changes the bundle ID of the installed app and gives it the
+ workflow's icon.
+ """
+ archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz')
+ destdir = wf().datadir
+ app_path = os.path.join(destdir, 'Notify.app')
+ n = notifier_program()
+ log().debug('installing Notify.app to %r ...', destdir)
+ # z = zipfile.ZipFile(archive, 'r')
+ # z.extractall(destdir)
+ tgz = tarfile.open(archive, 'r:gz')
+ tgz.extractall(destdir)
+ assert os.path.exists(n), \
+ 'Notify.app could not be installed in %s' % destdir
+
+ # Replace applet icon
+ icon = notifier_icon_path()
+ workflow_icon = wf().workflowfile('icon.png')
+ if os.path.exists(icon):
+ os.unlink(icon)
+
+ png_to_icns(workflow_icon, icon)
+
+ # Set file icon
+ # PyObjC isn't available for 2.6, so this is 2.7 only. Actually,
+ # none of this code will "work" on pre-10.8 systems. Let it run
+ # until I figure out a better way of excluding this module
+ # from coverage in py2.6.
+ if sys.version_info >= (2, 7): # pragma: no cover
+ from AppKit import NSWorkspace, NSImage
+
+ ws = NSWorkspace.sharedWorkspace()
+ img = NSImage.alloc().init()
+ img.initWithContentsOfFile_(icon)
+ ws.setIcon_forFile_options_(img, app_path, 0)
+
+ # Change bundle ID of installed app
+ ip_path = os.path.join(app_path, 'Contents/Info.plist')
+ bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex)
+ data = plistlib.readPlist(ip_path)
+ log().debug('changing bundle ID to %r', bundle_id)
+ data['CFBundleIdentifier'] = bundle_id
+ plistlib.writePlist(data, ip_path)
+
+
+def validate_sound(sound):
+ """Coerce ``sound`` to valid sound name.
+
+ Returns ``None`` for invalid sounds. Sound names can be found
+ in ``System Preferences > Sound > Sound Effects``.
+
+ Args:
+ sound (str): Name of system sound.
+
+ Returns:
+ str: Proper name of sound or ``None``.
+ """
+ if not sound:
+ return None
+
+ # Case-insensitive comparison of `sound`
+ if sound.lower() in [s.lower() for s in SOUNDS]:
+ # Title-case is correct for all system sounds as of macOS 10.11
+ return sound.title()
+ return None
+
+
+def notify(title='', text='', sound=None):
+ """Post notification via Notify.app helper.
+
+ Args:
+ title (str, optional): Notification title.
+ text (str, optional): Notification body text.
+ sound (str, optional): Name of sound to play.
+
+ Raises:
+ ValueError: Raised if both ``title`` and ``text`` are empty.
+
+ Returns:
+ bool: ``True`` if notification was posted, else ``False``.
+ """
+ if title == text == '':
+ raise ValueError('Empty notification')
+
+ sound = validate_sound(sound) or ''
+
+ n = notifier_program()
+
+ if not os.path.exists(n):
+ install_notifier()
+
+ env = os.environ.copy()
+ enc = 'utf-8'
+ env['NOTIFY_TITLE'] = title.encode(enc)
+ env['NOTIFY_MESSAGE'] = text.encode(enc)
+ env['NOTIFY_SOUND'] = sound.encode(enc)
+ cmd = [n]
+ retcode = subprocess.call(cmd, env=env)
+ if retcode == 0:
+ return True
+
+ log().error('Notify.app exited with status {0}.'.format(retcode))
+ return False
+
+
+def convert_image(inpath, outpath, size):
+ """Convert an image file using ``sips``.
+
+ Args:
+ inpath (str): Path of source file.
+ outpath (str): Path to destination file.
+ size (int): Width and height of destination image in pixels.
+
+ Raises:
+ RuntimeError: Raised if ``sips`` exits with non-zero status.
+ """
+ cmd = [
+ b'sips',
+ b'-z', str(size), str(size),
+ inpath,
+ b'--out', outpath]
+ # log().debug(cmd)
+ with open(os.devnull, 'w') as pipe:
+ retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
+
+ if retcode != 0:
+ raise RuntimeError('sips exited with %d' % retcode)
+
+
+def png_to_icns(png_path, icns_path):
+ """Convert PNG file to ICNS using ``iconutil``.
+
+ Create an iconset from the source PNG file. Generate PNG files
+ in each size required by macOS, then call ``iconutil`` to turn
+ them into a single ICNS file.
+
+ Args:
+ png_path (str): Path to source PNG file.
+ icns_path (str): Path to destination ICNS file.
+
+ Raises:
+ RuntimeError: Raised if ``iconutil`` or ``sips`` fail.
+ """
+ tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir)
+
+ try:
+ iconset = os.path.join(tempdir, 'Icon.iconset')
+
+ assert not os.path.exists(iconset), \
+ 'iconset already exists: ' + iconset
+ os.makedirs(iconset)
+
+ # Copy source icon to icon set and generate all the other
+ # sizes needed
+ configs = []
+ for i in (16, 32, 128, 256, 512):
+ configs.append(('icon_{0}x{0}.png'.format(i), i))
+ configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2)))
+
+ shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png'))
+ shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png'))
+
+ for name, size in configs:
+ outpath = os.path.join(iconset, name)
+ if os.path.exists(outpath):
+ continue
+ convert_image(png_path, outpath, size)
+
+ cmd = [
+ b'iconutil',
+ b'-c', b'icns',
+ b'-o', icns_path,
+ iconset]
+
+ retcode = subprocess.call(cmd)
+ if retcode != 0:
+ raise RuntimeError('iconset exited with %d' % retcode)
+
+ assert os.path.exists(icns_path), \
+ 'generated ICNS file not found: ' + repr(icns_path)
+ finally:
+ try:
+ shutil.rmtree(tempdir)
+ except OSError: # pragma: no cover
+ pass
+
+
+if __name__ == '__main__': # pragma: nocover
+ # Simple command-line script to test module with
+ # This won't work on 2.6, as `argparse` isn't available
+ # by default.
+ import argparse
+
+ from unicodedata import normalize
+
+ def ustr(s):
+ """Coerce `s` to normalised Unicode."""
+ return normalize('NFD', s.decode('utf-8'))
+
+ p = argparse.ArgumentParser()
+ p.add_argument('-p', '--png', help="PNG image to convert to ICNS.")
+ p.add_argument('-l', '--list-sounds', help="Show available sounds.",
+ action='store_true')
+ p.add_argument('-t', '--title',
+ help="Notification title.", type=ustr,
+ default='')
+ p.add_argument('-s', '--sound', type=ustr,
+ help="Optional notification sound.", default='')
+ p.add_argument('text', type=ustr,
+ help="Notification body text.", default='', nargs='?')
+ o = p.parse_args()
+
+ # List available sounds
+ if o.list_sounds:
+ for sound in SOUNDS:
+ print(sound)
+ sys.exit(0)
+
+ # Convert PNG to ICNS
+ if o.png:
+ icns = os.path.join(
+ os.path.dirname(o.png),
+ os.path.splitext(os.path.basename(o.png))[0] + '.icns')
+
+ print('converting {0!r} to {1!r} ...'.format(o.png, icns),
+ file=sys.stderr)
+
+ assert not os.path.exists(icns), \
+ 'destination file already exists: ' + icns
+
+ png_to_icns(o.png, icns)
+ sys.exit(0)
+
+ # Post notification
+ if o.title == o.text == '':
+ print('ERROR: empty notification.', file=sys.stderr)
+ sys.exit(1)
+ else:
+ notify(o.title, o.text, o.sound)
diff --git a/workflow/notify.pyc b/workflow/notify.pyc
new file mode 100644
index 0000000..dbba7e5
Binary files /dev/null and b/workflow/notify.pyc differ
diff --git a/workflow/update.py b/workflow/update.py
new file mode 100644
index 0000000..44bd1a8
--- /dev/null
+++ b/workflow/update.py
@@ -0,0 +1,434 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 Fabio Niephaus ,
+# Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-08-16
+#
+
+"""Self-updating from GitHub.
+
+.. versionadded:: 1.9
+
+.. note::
+
+ This module is not intended to be used directly. Automatic updates
+ are controlled by the ``update_settings`` :class:`dict` passed to
+ :class:`~workflow.workflow.Workflow` objects.
+
+"""
+
+from __future__ import print_function, unicode_literals
+
+import os
+import tempfile
+import re
+import subprocess
+
+import workflow
+import web
+
+# __all__ = []
+
+
+RELEASES_BASE = 'https://api.github.com/repos/{0}/releases'
+
+
+_wf = None
+
+
+def wf():
+ """Lazy `Workflow` object."""
+ global _wf
+ if _wf is None:
+ _wf = workflow.Workflow()
+ return _wf
+
+
+class Version(object):
+ """Mostly semantic versioning.
+
+ The main difference to proper :ref:`semantic versioning `
+ is that this implementation doesn't require a minor or patch version.
+
+ Version strings may also be prefixed with "v", e.g.:
+
+ >>> v = Version('v1.1.1')
+ >>> v.tuple
+ (1, 1, 1, '')
+
+ >>> v = Version('2.0')
+ >>> v.tuple
+ (2, 0, 0, '')
+
+ >>> Version('3.1-beta').tuple
+ (3, 1, 0, 'beta')
+
+ >>> Version('1.0.1') > Version('0.0.1')
+ True
+ """
+
+ #: Match version and pre-release/build information in version strings
+ match_version = re.compile(r'([0-9\.]+)(.+)?').match
+
+ def __init__(self, vstr):
+ """Create new `Version` object.
+
+ Args:
+ vstr (basestring): Semantic version string.
+ """
+ self.vstr = vstr
+ self.major = 0
+ self.minor = 0
+ self.patch = 0
+ self.suffix = ''
+ self.build = ''
+ self._parse(vstr)
+
+ def _parse(self, vstr):
+ if vstr.startswith('v'):
+ m = self.match_version(vstr[1:])
+ else:
+ m = self.match_version(vstr)
+ if not m:
+ raise ValueError('invalid version number: {0}'.format(vstr))
+
+ version, suffix = m.groups()
+ parts = self._parse_dotted_string(version)
+ self.major = parts.pop(0)
+ if len(parts):
+ self.minor = parts.pop(0)
+ if len(parts):
+ self.patch = parts.pop(0)
+ if not len(parts) == 0:
+ raise ValueError('invalid version (too long) : {0}'.format(vstr))
+
+ if suffix:
+ # Build info
+ idx = suffix.find('+')
+ if idx > -1:
+ self.build = suffix[idx+1:]
+ suffix = suffix[:idx]
+ if suffix:
+ if not suffix.startswith('-'):
+ raise ValueError(
+ 'suffix must start with - : {0}'.format(suffix))
+ self.suffix = suffix[1:]
+
+ # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self)))
+
+ def _parse_dotted_string(self, s):
+ """Parse string ``s`` into list of ints and strings."""
+ parsed = []
+ parts = s.split('.')
+ for p in parts:
+ if p.isdigit():
+ p = int(p)
+ parsed.append(p)
+ return parsed
+
+ @property
+ def tuple(self):
+ """Version number as a tuple of major, minor, patch, pre-release."""
+ return (self.major, self.minor, self.patch, self.suffix)
+
+ def __lt__(self, other):
+ """Implement comparison."""
+ if not isinstance(other, Version):
+ raise ValueError('not a Version instance: {0!r}'.format(other))
+ t = self.tuple[:3]
+ o = other.tuple[:3]
+ if t < o:
+ return True
+ if t == o: # We need to compare suffixes
+ if self.suffix and not other.suffix:
+ return True
+ if other.suffix and not self.suffix:
+ return False
+ return (self._parse_dotted_string(self.suffix) <
+ self._parse_dotted_string(other.suffix))
+ # t > o
+ return False
+
+ def __eq__(self, other):
+ """Implement comparison."""
+ if not isinstance(other, Version):
+ raise ValueError('not a Version instance: {0!r}'.format(other))
+ return self.tuple == other.tuple
+
+ def __ne__(self, other):
+ """Implement comparison."""
+ return not self.__eq__(other)
+
+ def __gt__(self, other):
+ """Implement comparison."""
+ if not isinstance(other, Version):
+ raise ValueError('not a Version instance: {0!r}'.format(other))
+ return other.__lt__(self)
+
+ def __le__(self, other):
+ """Implement comparison."""
+ if not isinstance(other, Version):
+ raise ValueError('not a Version instance: {0!r}'.format(other))
+ return not other.__lt__(self)
+
+ def __ge__(self, other):
+ """Implement comparison."""
+ return not self.__lt__(other)
+
+ def __str__(self):
+ """Return semantic version string."""
+ vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch)
+ if self.suffix:
+ vstr = '{0}-{1}'.format(vstr, self.suffix)
+ if self.build:
+ vstr = '{0}+{1}'.format(vstr, self.build)
+ return vstr
+
+ def __repr__(self):
+ """Return 'code' representation of `Version`."""
+ return "Version('{0}')".format(str(self))
+
+
+def download_workflow(url):
+ """Download workflow at ``url`` to a local temporary file.
+
+ :param url: URL to .alfredworkflow file in GitHub repo
+ :returns: path to downloaded file
+
+ """
+ filename = url.split('/')[-1]
+
+ if (not filename.endswith('.alfredworkflow') and
+ not filename.endswith('.alfred3workflow')):
+ raise ValueError('attachment not a workflow: {0}'.format(filename))
+
+ local_path = os.path.join(tempfile.gettempdir(), filename)
+
+ wf().logger.debug(
+ 'downloading updated workflow from `%s` to `%s` ...', url, local_path)
+
+ response = web.get(url)
+
+ with open(local_path, 'wb') as output:
+ output.write(response.content)
+
+ return local_path
+
+
+def build_api_url(slug):
+ """Generate releases URL from GitHub slug.
+
+ :param slug: Repo name in form ``username/repo``
+ :returns: URL to the API endpoint for the repo's releases
+
+ """
+ if len(slug.split('/')) != 2:
+ raise ValueError('invalid GitHub slug: {0}'.format(slug))
+
+ return RELEASES_BASE.format(slug)
+
+
+def _validate_release(release):
+ """Return release for running version of Alfred."""
+ alf3 = wf().alfred_version.major == 3
+
+ downloads = {'.alfredworkflow': [], '.alfred3workflow': []}
+ dl_count = 0
+ version = release['tag_name']
+
+ for asset in release.get('assets', []):
+ url = asset.get('browser_download_url')
+ if not url: # pragma: nocover
+ continue
+
+ ext = os.path.splitext(url)[1].lower()
+ if ext not in downloads:
+ continue
+
+ # Ignore Alfred 3-only files if Alfred 2 is running
+ if ext == '.alfred3workflow' and not alf3:
+ continue
+
+ downloads[ext].append(url)
+ dl_count += 1
+
+ # download_urls.append(url)
+
+ if dl_count == 0:
+ wf().logger.warning(
+ 'invalid release (no workflow file): %s', version)
+ return None
+
+ for k in downloads:
+ if len(downloads[k]) > 1:
+ wf().logger.warning(
+ 'invalid release (multiple %s files): %s', k, version)
+ return None
+
+ # Prefer .alfred3workflow file if there is one and Alfred 3 is
+ # running.
+ if alf3 and len(downloads['.alfred3workflow']):
+ download_url = downloads['.alfred3workflow'][0]
+
+ else:
+ download_url = downloads['.alfredworkflow'][0]
+
+ wf().logger.debug('release %s: %s', version, download_url)
+
+ return {
+ 'version': version,
+ 'download_url': download_url,
+ 'prerelease': release['prerelease']
+ }
+
+
+def get_valid_releases(github_slug, prereleases=False):
+ """Return list of all valid releases.
+
+ :param github_slug: ``username/repo`` for workflow's GitHub repo
+ :param prereleases: Whether to include pre-releases.
+ :returns: list of dicts. Each :class:`dict` has the form
+ ``{'version': '1.1', 'download_url': 'http://github.com/...',
+ 'prerelease': False }``
+
+
+ A valid release is one that contains one ``.alfredworkflow`` file.
+
+ If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading
+ ``v`` will be stripped.
+
+ """
+ api_url = build_api_url(github_slug)
+ releases = []
+
+ wf().logger.debug('retrieving releases list: %s', api_url)
+
+ def retrieve_releases():
+ wf().logger.info(
+ 'retrieving releases: %s', github_slug)
+ return web.get(api_url).json()
+
+ slug = github_slug.replace('/', '-')
+ for release in wf().cached_data('gh-releases-' + slug, retrieve_releases):
+
+ release = _validate_release(release)
+ if release is None:
+ wf().logger.debug('invalid release: %r', release)
+ continue
+
+ elif release['prerelease'] and not prereleases:
+ wf().logger.debug('ignoring prerelease: %s', release['version'])
+ continue
+
+ wf().logger.debug('release: %r', release)
+
+ releases.append(release)
+
+ return releases
+
+
+def check_update(github_slug, current_version, prereleases=False):
+ """Check whether a newer release is available on GitHub.
+
+ :param github_slug: ``username/repo`` for workflow's GitHub repo
+ :param current_version: the currently installed version of the
+ workflow. :ref:`Semantic versioning ` is required.
+ :param prereleases: Whether to include pre-releases.
+ :type current_version: ``unicode``
+ :returns: ``True`` if an update is available, else ``False``
+
+ If an update is available, its version number and download URL will
+ be cached.
+
+ """
+ releases = get_valid_releases(github_slug, prereleases)
+
+ if not len(releases):
+ wf().logger.warning('no valid releases for %s', github_slug)
+ wf().cache_data('__workflow_update_status', {'available': False})
+ return False
+
+ wf().logger.info('%d releases for %s', len(releases), github_slug)
+
+ # GitHub returns releases newest-first
+ latest_release = releases[0]
+
+ # (latest_version, download_url) = get_latest_release(releases)
+ vr = Version(latest_release['version'])
+ vl = Version(current_version)
+ wf().logger.debug('latest=%r, installed=%r', vr, vl)
+ if vr > vl:
+
+ wf().cache_data('__workflow_update_status', {
+ 'version': latest_release['version'],
+ 'download_url': latest_release['download_url'],
+ 'available': True
+ })
+
+ return True
+
+ wf().cache_data('__workflow_update_status', {'available': False})
+ return False
+
+
+def install_update():
+ """If a newer release is available, download and install it.
+
+ :returns: ``True`` if an update is installed, else ``False``
+
+ """
+ update_data = wf().cached_data('__workflow_update_status', max_age=0)
+
+ if not update_data or not update_data.get('available'):
+ wf().logger.info('no update available')
+ return False
+
+ local_file = download_workflow(update_data['download_url'])
+
+ wf().logger.info('installing updated workflow ...')
+ subprocess.call(['open', local_file])
+
+ update_data['available'] = False
+ wf().cache_data('__workflow_update_status', update_data)
+ return True
+
+
+if __name__ == '__main__': # pragma: nocover
+ import sys
+
+ def show_help(status=0):
+ """Print help message."""
+ print('Usage : update.py (check|install) '
+ '[--prereleases] ')
+ sys.exit(status)
+
+ argv = sys.argv[:]
+ if '-h' in argv or '--help' in argv:
+ show_help()
+
+ prereleases = '--prereleases' in argv
+
+ if prereleases:
+ argv.remove('--prereleases')
+
+ if len(argv) != 4:
+ show_help(1)
+
+ action, github_slug, version = argv[1:]
+
+ try:
+
+ if action == 'check':
+ check_update(github_slug, version, prereleases)
+ elif action == 'install':
+ install_update()
+ else:
+ show_help(1)
+
+ except Exception as err: # ensure traceback is in log file
+ wf().logger.exception(err)
+ raise err
diff --git a/workflow/update.pyc b/workflow/update.pyc
new file mode 100644
index 0000000..3f84809
Binary files /dev/null and b/workflow/update.pyc differ
diff --git a/workflow/util.py b/workflow/util.py
new file mode 100644
index 0000000..257654e
--- /dev/null
+++ b/workflow/util.py
@@ -0,0 +1,533 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2017 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2017-12-17
+#
+
+"""A selection of helper functions useful for building workflows."""
+
+from __future__ import print_function, absolute_import
+
+import atexit
+from collections import namedtuple
+from contextlib import contextmanager
+import errno
+import fcntl
+import functools
+import os
+import signal
+import subprocess
+import sys
+from threading import Event
+import time
+
+# AppleScript to call an External Trigger in Alfred
+AS_TRIGGER = """
+tell application "Alfred 3"
+run trigger "{name}" in workflow "{bundleid}" {arg}
+end tell
+"""
+
+# AppleScript to save a variable in info.plist
+AS_CONFIG_SET = """
+tell application "Alfred 3"
+set configuration "{name}" to value "{value}" in workflow "{bundleid}" {export}
+end tell
+"""
+
+# AppleScript to remove a variable from info.plist
+AS_CONFIG_UNSET = """
+tell application "Alfred 3"
+remove configuration "{name}" in workflow "{bundleid}"
+end tell
+"""
+
+
+class AcquisitionError(Exception):
+ """Raised if a lock cannot be acquired."""
+
+
+AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid'])
+"""Information about an installed application.
+
+Returned by :func:`appinfo`. All attributes are Unicode.
+
+.. py:attribute:: name
+
+ Name of the application, e.g. ``u'Safari'``.
+
+.. py:attribute:: path
+
+ Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
+
+.. py:attribute:: bundleid
+
+ Application's bundle ID, e.g. ``u'com.apple.Safari'``.
+
+"""
+
+
+def unicodify(s, encoding='utf-8', norm=None):
+ """Ensure string is Unicode.
+
+ .. versionadded:: 1.31
+
+ Decode encoded strings using ``encoding`` and normalise Unicode
+ to form ``norm`` if specified.
+
+ Args:
+ s (str): String to decode. May also be Unicode.
+ encoding (str, optional): Encoding to use on bytestrings.
+ norm (None, optional): Normalisation form to apply to Unicode string.
+
+ Returns:
+ unicode: Decoded, optionally normalised, Unicode string.
+
+ """
+ if not isinstance(s, unicode):
+ s = unicode(s, encoding)
+
+ if norm:
+ from unicodedata import normalize
+ s = normalize(norm, s)
+
+ return s
+
+
+def utf8ify(s):
+ """Ensure string is a bytestring.
+
+ .. versionadded:: 1.31
+
+ Returns `str` objects unchanced, encodes `unicode` objects to
+ UTF-8, and calls :func:`str` on anything else.
+
+ Args:
+ s (object): A Python object
+
+ Returns:
+ str: UTF-8 string or string representation of s.
+
+ """
+ if isinstance(s, str):
+ return s
+
+ if isinstance(s, unicode):
+ return s.encode('utf-8')
+
+ return str(s)
+
+
+def applescriptify(s):
+ """Escape string for insertion into an AppleScript string.
+
+ .. versionadded:: 1.31
+
+ Replaces ``"`` with `"& quote &"`. Use this function if you want
+
+ to insert a string into an AppleScript script:
+ >>> script = 'tell application "Alfred 3" to search "{}"'
+ >>> query = 'g "python" test'
+ >>> script.format(applescriptify(query))
+ 'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"'
+
+ Args:
+ s (unicode): Unicode string to escape.
+
+ Returns:
+ unicode: Escaped string
+
+ """
+ return s.replace(u'"', u'" & quote & "')
+
+
+def run_command(cmd, **kwargs):
+ """Run a command and return the output.
+
+ .. versionadded:: 1.31
+
+ A thin wrapper around :func:`subprocess.check_output` that ensures
+ all arguments are encoded to UTF-8 first.
+
+ Args:
+ cmd (list): Command arguments to pass to ``check_output``.
+ **kwargs: Keyword arguments to pass to ``check_output``.
+
+ Returns:
+ str: Output returned by ``check_output``.
+
+ """
+ cmd = [utf8ify(s) for s in cmd]
+ return subprocess.check_output(cmd, **kwargs)
+
+
+def run_applescript(script, *args, **kwargs):
+ """Execute an AppleScript script and return its output.
+
+ .. versionadded:: 1.31
+
+ Run AppleScript either by filepath or code. If ``script`` is a valid
+ filepath, that script will be run, otherwise ``script`` is treated
+ as code.
+
+ Args:
+ script (str, optional): Filepath of script or code to run.
+ *args: Optional command-line arguments to pass to the script.
+ **kwargs: Pass ``lang`` to run a language other than AppleScript.
+
+ Returns:
+ str: Output of run command.
+
+ """
+ cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')]
+
+ if os.path.exists(script):
+ cmd += [script]
+ else:
+ cmd += ['-e', script]
+
+ cmd.extend(args)
+
+ return run_command(cmd)
+
+
+def run_jxa(script, *args):
+ """Execute a JXA script and return its output.
+
+ .. versionadded:: 1.31
+
+ Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
+
+ Args:
+ script (str): Filepath of script or code to run.
+ *args: Optional command-line arguments to pass to script.
+
+ Returns:
+ str: Output of script.
+
+ """
+ return run_applescript(script, *args, lang='JavaScript')
+
+
+def run_trigger(name, bundleid=None, arg=None):
+ """Call an Alfred External Trigger.
+
+ .. versionadded:: 1.31
+
+ If ``bundleid`` is not specified, reads the bundle ID of the current
+ workflow from Alfred's environment variables.
+
+ Args:
+ name (str): Name of External Trigger to call.
+ bundleid (str, optional): Bundle ID of workflow trigger belongs to.
+ arg (str, optional): Argument to pass to trigger.
+
+ """
+ if not bundleid:
+ bundleid = os.getenv('alfred_workflow_bundleid')
+
+ if arg:
+ arg = 'with argument "{}"'.format(applescriptify(arg))
+ else:
+ arg = ''
+
+ script = AS_TRIGGER.format(name=name, bundleid=bundleid,
+ arg=arg)
+
+ run_applescript(script)
+
+
+def set_config(name, value, bundleid=None, exportable=False):
+ """Set a workflow variable in ``info.plist``.
+
+ .. versionadded:: 1.33
+
+ Args:
+ name (str): Name of variable to set.
+ value (str): Value to set variable to.
+ bundleid (str, optional): Bundle ID of workflow variable belongs to.
+ exportable (bool, optional): Whether variable should be marked
+ as exportable (Don't Export checkbox).
+
+ """
+ if not bundleid:
+ bundleid = os.getenv('alfred_workflow_bundleid')
+
+ name = applescriptify(name)
+ value = applescriptify(value)
+ bundleid = applescriptify(bundleid)
+
+ if exportable:
+ export = 'exportable true'
+ else:
+ export = 'exportable false'
+
+ script = AS_CONFIG_SET.format(name=name, bundleid=bundleid,
+ value=value, export=export)
+
+ run_applescript(script)
+
+
+def unset_config(name, bundleid=None):
+ """Delete a workflow variable from ``info.plist``.
+
+ .. versionadded:: 1.33
+
+ Args:
+ name (str): Name of variable to delete.
+ bundleid (str, optional): Bundle ID of workflow variable belongs to.
+
+ """
+ if not bundleid:
+ bundleid = os.getenv('alfred_workflow_bundleid')
+
+ name = applescriptify(name)
+ bundleid = applescriptify(bundleid)
+
+ script = AS_CONFIG_UNSET.format(name=name, bundleid=bundleid)
+
+ run_applescript(script)
+
+
+def appinfo(name):
+ """Get information about an installed application.
+
+ .. versionadded:: 1.31
+
+ Args:
+ name (str): Name of application to look up.
+
+ Returns:
+ AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
+
+ """
+ cmd = ['mdfind', '-onlyin', '/Applications',
+ '-onlyin', os.path.expanduser('~/Applications'),
+ '(kMDItemContentTypeTree == com.apple.application &&'
+ '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'
+ .format(name)]
+
+ output = run_command(cmd).strip()
+ if not output:
+ return None
+
+ path = output.split('\n')[0]
+
+ cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path]
+ bid = run_command(cmd).strip()
+ if not bid: # pragma: no cover
+ return None
+
+ return AppInfo(unicodify(name), unicodify(path), unicodify(bid))
+
+
+@contextmanager
+def atomic_writer(fpath, mode):
+ """Atomic file writer.
+
+ .. versionadded:: 1.12
+
+ Context manager that ensures the file is only written if the write
+ succeeds. The data is first written to a temporary file.
+
+ :param fpath: path of file to write to.
+ :type fpath: ``unicode``
+ :param mode: sames as for :func:`open`
+ :type mode: string
+
+ """
+ suffix = '.{}.tmp'.format(os.getpid())
+ temppath = fpath + suffix
+ with open(temppath, mode) as fp:
+ try:
+ yield fp
+ os.rename(temppath, fpath)
+ finally:
+ try:
+ os.remove(temppath)
+ except (OSError, IOError):
+ pass
+
+
+class LockFile(object):
+ """Context manager to protect filepaths with lockfiles.
+
+ .. versionadded:: 1.13
+
+ Creates a lockfile alongside ``protected_path``. Other ``LockFile``
+ instances will refuse to lock the same path.
+
+ >>> path = '/path/to/file'
+ >>> with LockFile(path):
+ >>> with open(path, 'wb') as fp:
+ >>> fp.write(data)
+
+ Args:
+ protected_path (unicode): File to protect with a lockfile
+ timeout (float, optional): Raises an :class:`AcquisitionError`
+ if lock cannot be acquired within this number of seconds.
+ If ``timeout`` is 0 (the default), wait forever.
+ delay (float, optional): How often to check (in seconds) if
+ lock has been released.
+
+ Attributes:
+ delay (float): How often to check (in seconds) whether the lock
+ can be acquired.
+ lockfile (unicode): Path of the lockfile.
+ timeout (float): How long to wait to acquire the lock.
+
+ """
+
+ def __init__(self, protected_path, timeout=0.0, delay=0.05):
+ """Create new :class:`LockFile` object."""
+ self.lockfile = protected_path + '.lock'
+ self._lockfile = None
+ self.timeout = timeout
+ self.delay = delay
+ self._lock = Event()
+ atexit.register(self.release)
+
+ @property
+ def locked(self):
+ """``True`` if file is locked by this instance."""
+ return self._lock.is_set()
+
+ def acquire(self, blocking=True):
+ """Acquire the lock if possible.
+
+ If the lock is in use and ``blocking`` is ``False``, return
+ ``False``.
+
+ Otherwise, check every :attr:`delay` seconds until it acquires
+ lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
+
+ """
+ if self.locked and not blocking:
+ return False
+
+ start = time.time()
+ while True:
+
+ # Raise error if we've been waiting too long to acquire the lock
+ if self.timeout and (time.time() - start) >= self.timeout:
+ raise AcquisitionError('lock acquisition timed out')
+
+ # If already locked, wait then try again
+ if self.locked:
+ time.sleep(self.delay)
+ continue
+
+ # Create in append mode so we don't lose any contents
+ if self._lockfile is None:
+ self._lockfile = open(self.lockfile, 'a')
+
+ # Try to acquire the lock
+ try:
+ fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ self._lock.set()
+ break
+ except IOError as err: # pragma: no cover
+ if err.errno not in (errno.EACCES, errno.EAGAIN):
+ raise
+
+ # Don't try again
+ if not blocking: # pragma: no cover
+ return False
+
+ # Wait, then try again
+ time.sleep(self.delay)
+
+ return True
+
+ def release(self):
+ """Release the lock by deleting `self.lockfile`."""
+ if not self._lock.is_set():
+ return False
+
+ try:
+ fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
+ except IOError: # pragma: no cover
+ pass
+ finally:
+ self._lock.clear()
+ self._lockfile = None
+ try:
+ os.unlink(self.lockfile)
+ except (IOError, OSError): # pragma: no cover
+ pass
+
+ return True
+
+ def __enter__(self):
+ """Acquire lock."""
+ self.acquire()
+ return self
+
+ def __exit__(self, typ, value, traceback):
+ """Release lock."""
+ self.release()
+
+ def __del__(self):
+ """Clear up `self.lockfile`."""
+ self.release() # pragma: no cover
+
+
+class uninterruptible(object):
+ """Decorator that postpones SIGTERM until wrapped function returns.
+
+ .. versionadded:: 1.12
+
+ .. important:: This decorator is NOT thread-safe.
+
+ As of version 2.7, Alfred allows Script Filters to be killed. If
+ your workflow is killed in the middle of critical code (e.g.
+ writing data to disk), this may corrupt your workflow's data.
+
+ Use this decorator to wrap critical functions that *must* complete.
+ If the script is killed while a wrapped function is executing,
+ the SIGTERM will be caught and handled after your function has
+ finished executing.
+
+ Alfred-Workflow uses this internally to ensure its settings, data
+ and cache writes complete.
+
+ """
+
+ def __init__(self, func, class_name=''):
+ """Decorate `func`."""
+ self.func = func
+ functools.update_wrapper(self, func)
+ self._caught_signal = None
+
+ def signal_handler(self, signum, frame):
+ """Called when process receives SIGTERM."""
+ self._caught_signal = (signum, frame)
+
+ def __call__(self, *args, **kwargs):
+ """Trap ``SIGTERM`` and call wrapped function."""
+ self._caught_signal = None
+ # Register handler for SIGTERM, then call `self.func`
+ self.old_signal_handler = signal.getsignal(signal.SIGTERM)
+ signal.signal(signal.SIGTERM, self.signal_handler)
+
+ self.func(*args, **kwargs)
+
+ # Restore old signal handler
+ signal.signal(signal.SIGTERM, self.old_signal_handler)
+
+ # Handle any signal caught during execution
+ if self._caught_signal is not None:
+ signum, frame = self._caught_signal
+ if callable(self.old_signal_handler):
+ self.old_signal_handler(signum, frame)
+ elif self.old_signal_handler == signal.SIG_DFL:
+ sys.exit(0)
+
+ def __get__(self, obj=None, klass=None):
+ """Decorator API."""
+ return self.__class__(self.func.__get__(obj, klass),
+ klass.__name__)
diff --git a/workflow/util.pyc b/workflow/util.pyc
new file mode 100644
index 0000000..2a1b087
Binary files /dev/null and b/workflow/util.pyc differ
diff --git a/workflow/version b/workflow/version
new file mode 100644
index 0000000..1caa5ab
--- /dev/null
+++ b/workflow/version
@@ -0,0 +1 @@
+1.36
\ No newline at end of file
diff --git a/workflow/web.py b/workflow/web.py
new file mode 100644
index 0000000..d64bb6f
--- /dev/null
+++ b/workflow/web.py
@@ -0,0 +1,678 @@
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""Lightweight HTTP library with a requests-like interface."""
+
+import codecs
+import json
+import mimetypes
+import os
+import random
+import re
+import socket
+import string
+import unicodedata
+import urllib
+import urllib2
+import urlparse
+import zlib
+
+
+USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)'
+
+# Valid characters for multipart form data boundaries
+BOUNDARY_CHARS = string.digits + string.ascii_letters
+
+# HTTP response codes
+RESPONSES = {
+ 100: 'Continue',
+ 101: 'Switching Protocols',
+ 200: 'OK',
+ 201: 'Created',
+ 202: 'Accepted',
+ 203: 'Non-Authoritative Information',
+ 204: 'No Content',
+ 205: 'Reset Content',
+ 206: 'Partial Content',
+ 300: 'Multiple Choices',
+ 301: 'Moved Permanently',
+ 302: 'Found',
+ 303: 'See Other',
+ 304: 'Not Modified',
+ 305: 'Use Proxy',
+ 307: 'Temporary Redirect',
+ 400: 'Bad Request',
+ 401: 'Unauthorized',
+ 402: 'Payment Required',
+ 403: 'Forbidden',
+ 404: 'Not Found',
+ 405: 'Method Not Allowed',
+ 406: 'Not Acceptable',
+ 407: 'Proxy Authentication Required',
+ 408: 'Request Timeout',
+ 409: 'Conflict',
+ 410: 'Gone',
+ 411: 'Length Required',
+ 412: 'Precondition Failed',
+ 413: 'Request Entity Too Large',
+ 414: 'Request-URI Too Long',
+ 415: 'Unsupported Media Type',
+ 416: 'Requested Range Not Satisfiable',
+ 417: 'Expectation Failed',
+ 500: 'Internal Server Error',
+ 501: 'Not Implemented',
+ 502: 'Bad Gateway',
+ 503: 'Service Unavailable',
+ 504: 'Gateway Timeout',
+ 505: 'HTTP Version Not Supported'
+}
+
+
+def str_dict(dic):
+ """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
+
+ :param dic: Mapping of Unicode strings
+ :type dic: dict
+ :returns: Dictionary containing only UTF-8 strings
+ :rtype: dict
+
+ """
+ if isinstance(dic, CaseInsensitiveDictionary):
+ dic2 = CaseInsensitiveDictionary()
+ else:
+ 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 NoRedirectHandler(urllib2.HTTPRedirectHandler):
+ """Prevent redirections."""
+
+ def redirect_request(self, *args):
+ return None
+
+
+# Adapted from https://gist.github.com/babakness/3901174
+class CaseInsensitiveDictionary(dict):
+ """Dictionary with caseless key search.
+
+ Enables case insensitive searching while preserving case sensitivity
+ when keys are listed, ie, via keys() or items() methods.
+
+ Works by storing a lowercase version of the key as the new key and
+ stores the original key-value pair as the key's value
+ (values become dictionaries).
+
+ """
+
+ def __init__(self, initval=None):
+ """Create new case-insensitive dictionary."""
+ if isinstance(initval, dict):
+ for key, value in initval.iteritems():
+ self.__setitem__(key, value)
+
+ elif isinstance(initval, list):
+ for (key, value) in initval:
+ self.__setitem__(key, value)
+
+ def __contains__(self, key):
+ return dict.__contains__(self, key.lower())
+
+ def __getitem__(self, key):
+ return dict.__getitem__(self, key.lower())['val']
+
+ def __setitem__(self, key, value):
+ return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
+
+ def get(self, key, default=None):
+ try:
+ v = dict.__getitem__(self, key.lower())
+ except KeyError:
+ return default
+ else:
+ return v['val']
+
+ def update(self, other):
+ for k, v in other.items():
+ self[k] = v
+
+ def items(self):
+ return [(v['key'], v['val']) for v in dict.itervalues(self)]
+
+ def keys(self):
+ return [v['key'] for v in dict.itervalues(self)]
+
+ def values(self):
+ return [v['val'] for v in dict.itervalues(self)]
+
+ def iteritems(self):
+ for v in dict.itervalues(self):
+ yield v['key'], v['val']
+
+ def iterkeys(self):
+ for v in dict.itervalues(self):
+ yield v['key']
+
+ def itervalues(self):
+ for v in dict.itervalues(self):
+ yield v['val']
+
+
+class Response(object):
+ """
+ Returned by :func:`request` / :func:`get` / :func:`post` functions.
+
+ Simplified version of the ``Response`` object in the ``requests`` library.
+
+ >>> r = request('http://www.google.com')
+ >>> r.status_code
+ 200
+ >>> r.encoding
+ ISO-8859-1
+ >>> r.content # bytes
+ ...
+ >>> r.text # unicode, decoded according to charset in HTTP header/meta tag
+ u' ...'
+ >>> r.json() # content parsed as JSON
+
+ """
+
+ def __init__(self, request, stream=False):
+ """Call `request` with :mod:`urllib2` and process results.
+
+ :param request: :class:`urllib2.Request` instance
+ :param stream: Whether to stream response or retrieve it all at once
+ :type stream: bool
+
+ """
+ self.request = request
+ self._stream = stream
+ self.url = None
+ self.raw = None
+ self._encoding = None
+ self.error = None
+ self.status_code = None
+ self.reason = None
+ self.headers = CaseInsensitiveDictionary()
+ self._content = None
+ self._content_loaded = False
+ self._gzipped = False
+
+ # Execute query
+ try:
+ self.raw = urllib2.urlopen(request)
+ except urllib2.HTTPError as err:
+ self.error = err
+ try:
+ self.url = err.geturl()
+ # sometimes (e.g. when authentication fails)
+ # urllib can't get a URL from an HTTPError
+ # This behaviour changes across Python versions,
+ # so no test cover (it isn't important).
+ except AttributeError: # pragma: no cover
+ pass
+ self.status_code = err.code
+ else:
+ self.status_code = self.raw.getcode()
+ self.url = self.raw.geturl()
+ self.reason = RESPONSES.get(self.status_code)
+
+ # Parse additional info if request succeeded
+ if not self.error:
+ headers = self.raw.info()
+ self.transfer_encoding = headers.getencoding()
+ self.mimetype = headers.gettype()
+ for key in headers.keys():
+ self.headers[key.lower()] = headers.get(key)
+
+ # Is content gzipped?
+ # Transfer-Encoding appears to not be used in the wild
+ # (contrary to the HTTP standard), but no harm in testing
+ # for it
+ if ('gzip' in headers.get('content-encoding', '') or
+ 'gzip' in headers.get('transfer-encoding', '')):
+ self._gzipped = True
+
+ @property
+ def stream(self):
+ """Whether response is streamed.
+
+ Returns:
+ bool: `True` if response is streamed.
+ """
+ return self._stream
+
+ @stream.setter
+ def stream(self, value):
+ if self._content_loaded:
+ raise RuntimeError("`content` has already been read from "
+ "this Response.")
+
+ self._stream = value
+
+ def json(self):
+ """Decode response contents as JSON.
+
+ :returns: object decoded from JSON
+ :rtype: list, dict or unicode
+
+ """
+ return json.loads(self.content, self.encoding or 'utf-8')
+
+ @property
+ def encoding(self):
+ """Text encoding of document or ``None``.
+
+ :returns: Text encoding if found.
+ :rtype: str or ``None``
+
+ """
+ if not self._encoding:
+ self._encoding = self._get_encoding()
+
+ return self._encoding
+
+ @property
+ def content(self):
+ """Raw content of response (i.e. bytes).
+
+ :returns: Body of HTTP response
+ :rtype: str
+
+ """
+ if not self._content:
+
+ # Decompress gzipped content
+ if self._gzipped:
+ decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
+ self._content = decoder.decompress(self.raw.read())
+
+ else:
+ self._content = self.raw.read()
+
+ self._content_loaded = True
+
+ return self._content
+
+ @property
+ def text(self):
+ """Unicode-decoded content of response body.
+
+ If no encoding can be determined from HTTP headers or the content
+ itself, the encoded response body will be returned instead.
+
+ :returns: Body of HTTP response
+ :rtype: unicode or str
+
+ """
+ if self.encoding:
+ return unicodedata.normalize('NFC', unicode(self.content,
+ self.encoding))
+ return self.content
+
+ def iter_content(self, chunk_size=4096, decode_unicode=False):
+ """Iterate over response data.
+
+ .. versionadded:: 1.6
+
+ :param chunk_size: Number of bytes to read into memory
+ :type chunk_size: int
+ :param decode_unicode: Decode to Unicode using detected encoding
+ :type decode_unicode: bool
+ :returns: iterator
+
+ """
+ if not self.stream:
+ raise RuntimeError("You cannot call `iter_content` on a "
+ "Response unless you passed `stream=True`"
+ " to `get()`/`post()`/`request()`.")
+
+ if self._content_loaded:
+ raise RuntimeError(
+ "`content` has already been read from this Response.")
+
+ def decode_stream(iterator, r):
+
+ decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace')
+
+ for chunk in iterator:
+ data = decoder.decode(chunk)
+ if data:
+ yield data
+
+ data = decoder.decode(b'', final=True)
+ if data: # pragma: no cover
+ yield data
+
+ def generate():
+
+ if self._gzipped:
+ decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
+
+ while True:
+ chunk = self.raw.read(chunk_size)
+ if not chunk:
+ break
+
+ if self._gzipped:
+ chunk = decoder.decompress(chunk)
+
+ yield chunk
+
+ chunks = generate()
+
+ if decode_unicode and self.encoding:
+ chunks = decode_stream(chunks, self)
+
+ return chunks
+
+ def save_to_path(self, filepath):
+ """Save retrieved data to file at ``filepath``.
+
+ .. versionadded: 1.9.6
+
+ :param filepath: Path to save retrieved data.
+
+ """
+ filepath = os.path.abspath(filepath)
+ dirname = os.path.dirname(filepath)
+ if not os.path.exists(dirname):
+ os.makedirs(dirname)
+
+ self.stream = True
+
+ with open(filepath, 'wb') as fileobj:
+ for data in self.iter_content():
+ fileobj.write(data)
+
+ def raise_for_status(self):
+ """Raise stored error if one occurred.
+
+ error will be instance of :class:`urllib2.HTTPError`
+ """
+ if self.error is not None:
+ raise self.error
+ return
+
+ def _get_encoding(self):
+ """Get encoding from HTTP headers or content.
+
+ :returns: encoding or `None`
+ :rtype: unicode or ``None``
+
+ """
+ headers = self.raw.info()
+ encoding = None
+
+ if headers.getparam('charset'):
+ encoding = headers.getparam('charset')
+
+ # HTTP Content-Type header
+ for param in headers.getplist():
+ if param.startswith('charset='):
+ encoding = param[8:]
+ break
+
+ if not self.stream: # Try sniffing response content
+ # Encoding declared in document should override HTTP headers
+ if self.mimetype == 'text/html': # sniff HTML headers
+ m = re.search("""""",
+ self.content)
+ if m:
+ encoding = m.group(1)
+
+ elif ((self.mimetype.startswith('application/') or
+ self.mimetype.startswith('text/')) and
+ 'xml' in self.mimetype):
+ m = re.search("""]*\?>""",
+ self.content)
+ if m:
+ encoding = m.group(1)
+
+ # Format defaults
+ if self.mimetype == 'application/json' and not encoding:
+ # The default encoding for JSON
+ encoding = 'utf-8'
+
+ elif self.mimetype == 'application/xml' and not encoding:
+ # The default for 'application/xml'
+ encoding = 'utf-8'
+
+ if encoding:
+ encoding = encoding.lower()
+
+ return encoding
+
+
+def request(method, url, params=None, data=None, headers=None, cookies=None,
+ files=None, auth=None, timeout=60, allow_redirects=False,
+ stream=False):
+ """Initiate an HTTP(S) request. Returns :class:`Response` object.
+
+ :param method: 'GET' or 'POST'
+ :type method: unicode
+ :param url: URL to open
+ :type url: unicode
+ :param params: mapping of URL parameters
+ :type params: dict
+ :param data: mapping of form data ``{'field_name': 'value'}`` or
+ :class:`str`
+ :type data: dict or str
+ :param headers: HTTP headers
+ :type headers: dict
+ :param cookies: cookies to send to server
+ :type cookies: dict
+ :param files: files to upload (see below).
+ :type files: dict
+ :param auth: username, password
+ :type auth: tuple
+ :param timeout: connection timeout limit in seconds
+ :type timeout: int
+ :param allow_redirects: follow redirections
+ :type allow_redirects: bool
+ :param stream: Stream content instead of fetching it all at once.
+ :type stream: bool
+ :returns: Response object
+ :rtype: :class:`Response`
+
+
+ The ``files`` argument is a dictionary::
+
+ {'fieldname' : { 'filename': 'blah.txt',
+ 'content': '',
+ 'mimetype': 'text/plain'}
+ }
+
+ * ``fieldname`` is the name of the field in the HTML form.
+ * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
+ be used to guess the mimetype, or ``application/octet-stream``
+ will be used.
+
+ """
+ # TODO: cookies
+ socket.setdefaulttimeout(timeout)
+
+ # Default handlers
+ openers = []
+
+ if not allow_redirects:
+ openers.append(NoRedirectHandler())
+
+ if auth is not None: # Add authorisation handler
+ username, password = auth
+ password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
+ password_manager.add_password(None, url, username, password)
+ auth_manager = urllib2.HTTPBasicAuthHandler(password_manager)
+ openers.append(auth_manager)
+
+ # Install our custom chain of openers
+ opener = urllib2.build_opener(*openers)
+ urllib2.install_opener(opener)
+
+ if not headers:
+ headers = CaseInsensitiveDictionary()
+ else:
+ headers = CaseInsensitiveDictionary(headers)
+
+ if 'user-agent' not in headers:
+ headers['user-agent'] = USER_AGENT
+
+ # Accept gzip-encoded content
+ encodings = [s.strip() for s in
+ headers.get('accept-encoding', '').split(',')]
+ if 'gzip' not in encodings:
+ encodings.append('gzip')
+
+ headers['accept-encoding'] = ', '.join(encodings)
+
+ # Force POST by providing an empty data string
+ if method == 'POST' and not data:
+ data = ''
+
+ if files:
+ if not data:
+ data = {}
+ new_headers, data = encode_multipart_formdata(data, files)
+ headers.update(new_headers)
+ elif data and isinstance(data, dict):
+ data = urllib.urlencode(str_dict(data))
+
+ # Make sure everything is encoded text
+ headers = str_dict(headers)
+
+ if isinstance(url, unicode):
+ url = url.encode('utf-8')
+
+ if params: # GET args (POST args are handled in encode_multipart_formdata)
+
+ scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
+
+ if query: # Combine query string and `params`
+ url_params = urlparse.parse_qs(query)
+ # `params` take precedence over URL query string
+ url_params.update(params)
+ params = url_params
+
+ query = urllib.urlencode(str_dict(params), doseq=True)
+ url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
+
+ req = urllib2.Request(url, data, headers)
+ return Response(req, stream)
+
+
+def get(url, params=None, headers=None, cookies=None, auth=None,
+ timeout=60, allow_redirects=True, stream=False):
+ """Initiate a GET request. Arguments as for :func:`request`.
+
+ :returns: :class:`Response` instance
+
+ """
+ return request('GET', url, params, headers=headers, cookies=cookies,
+ auth=auth, timeout=timeout, allow_redirects=allow_redirects,
+ stream=stream)
+
+
+def post(url, params=None, data=None, headers=None, cookies=None, files=None,
+ auth=None, timeout=60, allow_redirects=False, stream=False):
+ """Initiate a POST request. Arguments as for :func:`request`.
+
+ :returns: :class:`Response` instance
+
+ """
+ return request('POST', url, params, data, headers, cookies, files, auth,
+ timeout, allow_redirects, stream)
+
+
+def encode_multipart_formdata(fields, files):
+ """Encode form data (``fields``) and ``files`` for POST request.
+
+ :param fields: mapping of ``{name : value}`` pairs for normal form fields.
+ :type fields: dict
+ :param files: dictionary of fieldnames/files elements for file data.
+ See below for details.
+ :type files: dict of :class:`dict`
+ :returns: ``(headers, body)`` ``headers`` is a
+ :class:`dict` of HTTP headers
+ :rtype: 2-tuple ``(dict, str)``
+
+ The ``files`` argument is a dictionary::
+
+ {'fieldname' : { 'filename': 'blah.txt',
+ 'content': '',
+ 'mimetype': 'text/plain'}
+ }
+
+ - ``fieldname`` is the name of the field in the HTML form.
+ - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
+ be used to guess the mimetype, or ``application/octet-stream``
+ will be used.
+
+ """
+ def get_content_type(filename):
+ """Return or guess mimetype of ``filename``.
+
+ :param filename: filename of file
+ :type filename: unicode/str
+ :returns: mime-type, e.g. ``text/html``
+ :rtype: str
+
+ """
+
+ return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
+
+ boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS)
+ for i in range(30))
+ CRLF = '\r\n'
+ output = []
+
+ # Normal form fields
+ for (name, value) in fields.items():
+ if isinstance(name, unicode):
+ name = name.encode('utf-8')
+ if isinstance(value, unicode):
+ value = value.encode('utf-8')
+ output.append('--' + boundary)
+ output.append('Content-Disposition: form-data; name="%s"' % name)
+ output.append('')
+ output.append(value)
+
+ # Files to upload
+ for name, d in files.items():
+ filename = d[u'filename']
+ content = d[u'content']
+ if u'mimetype' in d:
+ mimetype = d[u'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('--' + boundary)
+ output.append('Content-Disposition: form-data; '
+ 'name="%s"; filename="%s"' % (name, filename))
+ output.append('Content-Type: %s' % mimetype)
+ output.append('')
+ output.append(content)
+
+ output.append('--' + boundary + '--')
+ output.append('')
+ body = CRLF.join(output)
+ headers = {
+ 'Content-Type': 'multipart/form-data; boundary=%s' % boundary,
+ 'Content-Length': str(len(body)),
+ }
+ return (headers, body)
diff --git a/workflow/web.pyc b/workflow/web.pyc
new file mode 100644
index 0000000..84a87c1
Binary files /dev/null and b/workflow/web.pyc differ
diff --git a/workflow/workflow.py b/workflow/workflow.py
new file mode 100644
index 0000000..c2c1616
--- /dev/null
+++ b/workflow/workflow.py
@@ -0,0 +1,2818 @@
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""The :class:`Workflow` object is the main interface to this library.
+
+:class:`Workflow` is targeted at Alfred 2. Use
+:class:`~workflow.Workflow3` if you want to use Alfred 3's new
+features, such as :ref:`workflow variables ` or
+more powerful modifiers.
+
+See :ref:`setup` in the :ref:`user-manual` for an example of how to set
+up your Python script to best utilise the :class:`Workflow` object.
+
+"""
+
+from __future__ import print_function, unicode_literals
+
+import binascii
+import cPickle
+from copy import deepcopy
+import json
+import logging
+import logging.handlers
+import os
+import pickle
+import plistlib
+import re
+import shutil
+import string
+import subprocess
+import sys
+import time
+import unicodedata
+
+try:
+ import xml.etree.cElementTree as ET
+except ImportError: # pragma: no cover
+ import xml.etree.ElementTree as ET
+
+from util import (
+ AcquisitionError, # imported to maintain API
+ atomic_writer,
+ LockFile,
+ uninterruptible,
+)
+
+#: Sentinel for properties that haven't been set yet (that might
+#: correctly have the value ``None``)
+UNSET = object()
+
+####################################################################
+# Standard system icons
+####################################################################
+
+# These icons are default macOS icons. They are super-high quality, and
+# will be familiar to users.
+# This library uses `ICON_ERROR` when a workflow dies in flames, so
+# in my own workflows, I use `ICON_WARNING` for less fatal errors
+# (e.g. bad user input, no results etc.)
+
+# The system icons are all in this directory. There are many more than
+# are listed here
+
+ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources'
+
+ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns')
+ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns')
+ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns')
+ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns')
+ICON_COLOUR = ICON_COLOR # Queen's English, if you please
+ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns')
+# Shown when a workflow throws an error
+ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns')
+ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns')
+ICON_FAVOURITE = ICON_FAVORITE
+ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns')
+ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns')
+ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns')
+ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns')
+ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns')
+ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns')
+ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns')
+ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns')
+ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns')
+ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns')
+ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns')
+ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns')
+ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns')
+ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns')
+
+####################################################################
+# non-ASCII to ASCII diacritic folding.
+# Used by `fold_to_ascii` method
+####################################################################
+
+ASCII_REPLACEMENTS = {
+ 'À': 'A',
+ 'Á': 'A',
+ 'Â': 'A',
+ 'Ã': 'A',
+ 'Ä': 'A',
+ 'Å': 'A',
+ 'Æ': 'AE',
+ 'Ç': 'C',
+ 'È': 'E',
+ 'É': 'E',
+ 'Ê': 'E',
+ 'Ë': 'E',
+ 'Ì': 'I',
+ 'Í': 'I',
+ 'Î': 'I',
+ 'Ï': 'I',
+ 'Ð': 'D',
+ 'Ñ': 'N',
+ 'Ò': 'O',
+ 'Ó': 'O',
+ 'Ô': 'O',
+ 'Õ': 'O',
+ 'Ö': 'O',
+ 'Ø': 'O',
+ 'Ù': 'U',
+ 'Ú': 'U',
+ 'Û': 'U',
+ 'Ü': 'U',
+ 'Ý': 'Y',
+ 'Þ': 'Th',
+ 'ß': 'ss',
+ 'à': 'a',
+ 'á': 'a',
+ 'â': 'a',
+ 'ã': 'a',
+ 'ä': 'a',
+ 'å': 'a',
+ 'æ': 'ae',
+ 'ç': 'c',
+ 'è': 'e',
+ 'é': 'e',
+ 'ê': 'e',
+ 'ë': 'e',
+ 'ì': 'i',
+ 'í': 'i',
+ 'î': 'i',
+ 'ï': 'i',
+ 'ð': 'd',
+ 'ñ': 'n',
+ 'ò': 'o',
+ 'ó': 'o',
+ 'ô': 'o',
+ 'õ': 'o',
+ 'ö': 'o',
+ 'ø': 'o',
+ 'ù': 'u',
+ 'ú': 'u',
+ 'û': 'u',
+ 'ü': 'u',
+ 'ý': 'y',
+ 'þ': 'th',
+ 'ÿ': 'y',
+ 'Ł': 'L',
+ 'ł': 'l',
+ 'Ń': 'N',
+ 'ń': 'n',
+ 'Ņ': 'N',
+ 'ņ': 'n',
+ 'Ň': 'N',
+ 'ň': 'n',
+ 'Ŋ': 'ng',
+ 'ŋ': 'NG',
+ 'Ō': 'O',
+ 'ō': 'o',
+ 'Ŏ': 'O',
+ 'ŏ': 'o',
+ 'Ő': 'O',
+ 'ő': 'o',
+ 'Œ': 'OE',
+ 'œ': 'oe',
+ 'Ŕ': 'R',
+ 'ŕ': 'r',
+ 'Ŗ': 'R',
+ 'ŗ': 'r',
+ 'Ř': 'R',
+ 'ř': 'r',
+ 'Ś': 'S',
+ 'ś': 's',
+ 'Ŝ': 'S',
+ 'ŝ': 's',
+ 'Ş': 'S',
+ 'ş': 's',
+ 'Š': 'S',
+ 'š': 's',
+ 'Ţ': 'T',
+ 'ţ': 't',
+ 'Ť': 'T',
+ 'ť': 't',
+ 'Ŧ': 'T',
+ 'ŧ': 't',
+ 'Ũ': 'U',
+ 'ũ': 'u',
+ 'Ū': 'U',
+ 'ū': 'u',
+ 'Ŭ': 'U',
+ 'ŭ': 'u',
+ 'Ů': 'U',
+ 'ů': 'u',
+ 'Ű': 'U',
+ 'ű': 'u',
+ 'Ŵ': 'W',
+ 'ŵ': 'w',
+ 'Ŷ': 'Y',
+ 'ŷ': 'y',
+ 'Ÿ': 'Y',
+ 'Ź': 'Z',
+ 'ź': 'z',
+ 'Ż': 'Z',
+ 'ż': 'z',
+ 'Ž': 'Z',
+ 'ž': 'z',
+ 'ſ': 's',
+ 'Α': 'A',
+ 'Β': 'B',
+ 'Γ': 'G',
+ 'Δ': 'D',
+ 'Ε': 'E',
+ 'Ζ': 'Z',
+ 'Η': 'E',
+ 'Θ': 'Th',
+ 'Ι': 'I',
+ 'Κ': 'K',
+ 'Λ': 'L',
+ 'Μ': 'M',
+ 'Ν': 'N',
+ 'Ξ': 'Ks',
+ 'Ο': 'O',
+ 'Π': 'P',
+ 'Ρ': 'R',
+ 'Σ': 'S',
+ 'Τ': 'T',
+ 'Υ': 'U',
+ 'Φ': 'Ph',
+ 'Χ': 'Kh',
+ 'Ψ': 'Ps',
+ 'Ω': 'O',
+ 'α': 'a',
+ 'β': 'b',
+ 'γ': 'g',
+ 'δ': 'd',
+ 'ε': 'e',
+ 'ζ': 'z',
+ 'η': 'e',
+ 'θ': 'th',
+ 'ι': 'i',
+ 'κ': 'k',
+ 'λ': 'l',
+ 'μ': 'm',
+ 'ν': 'n',
+ 'ξ': 'x',
+ 'ο': 'o',
+ 'π': 'p',
+ 'ρ': 'r',
+ 'ς': 's',
+ 'σ': 's',
+ 'τ': 't',
+ 'υ': 'u',
+ 'φ': 'ph',
+ 'χ': 'kh',
+ 'ψ': 'ps',
+ 'ω': 'o',
+ 'А': 'A',
+ 'Б': 'B',
+ 'В': 'V',
+ 'Г': 'G',
+ 'Д': 'D',
+ 'Е': 'E',
+ 'Ж': 'Zh',
+ 'З': 'Z',
+ 'И': 'I',
+ 'Й': 'I',
+ 'К': 'K',
+ 'Л': 'L',
+ 'М': 'M',
+ 'Н': 'N',
+ 'О': 'O',
+ 'П': 'P',
+ 'Р': 'R',
+ 'С': 'S',
+ 'Т': 'T',
+ 'У': 'U',
+ 'Ф': 'F',
+ 'Х': 'Kh',
+ 'Ц': 'Ts',
+ 'Ч': 'Ch',
+ 'Ш': 'Sh',
+ 'Щ': 'Shch',
+ 'Ъ': "'",
+ 'Ы': 'Y',
+ 'Ь': "'",
+ 'Э': 'E',
+ 'Ю': 'Iu',
+ 'Я': 'Ia',
+ 'а': 'a',
+ 'б': 'b',
+ 'в': 'v',
+ 'г': 'g',
+ 'д': 'd',
+ 'е': 'e',
+ 'ж': 'zh',
+ 'з': 'z',
+ 'и': 'i',
+ 'й': 'i',
+ 'к': 'k',
+ 'л': 'l',
+ 'м': 'm',
+ 'н': 'n',
+ 'о': 'o',
+ 'п': 'p',
+ 'р': 'r',
+ 'с': 's',
+ 'т': 't',
+ 'у': 'u',
+ 'ф': 'f',
+ 'х': 'kh',
+ 'ц': 'ts',
+ 'ч': 'ch',
+ 'ш': 'sh',
+ 'щ': 'shch',
+ 'ъ': "'",
+ 'ы': 'y',
+ 'ь': "'",
+ 'э': 'e',
+ 'ю': 'iu',
+ 'я': 'ia',
+ # 'ᴀ': '',
+ # 'ᴁ': '',
+ # 'ᴂ': '',
+ # 'ᴃ': '',
+ # 'ᴄ': '',
+ # 'ᴅ': '',
+ # 'ᴆ': '',
+ # 'ᴇ': '',
+ # 'ᴈ': '',
+ # 'ᴉ': '',
+ # 'ᴊ': '',
+ # 'ᴋ': '',
+ # 'ᴌ': '',
+ # 'ᴍ': '',
+ # 'ᴎ': '',
+ # 'ᴏ': '',
+ # 'ᴐ': '',
+ # 'ᴑ': '',
+ # 'ᴒ': '',
+ # 'ᴓ': '',
+ # 'ᴔ': '',
+ # 'ᴕ': '',
+ # 'ᴖ': '',
+ # 'ᴗ': '',
+ # 'ᴘ': '',
+ # 'ᴙ': '',
+ # 'ᴚ': '',
+ # 'ᴛ': '',
+ # 'ᴜ': '',
+ # 'ᴝ': '',
+ # 'ᴞ': '',
+ # 'ᴟ': '',
+ # 'ᴠ': '',
+ # 'ᴡ': '',
+ # 'ᴢ': '',
+ # 'ᴣ': '',
+ # 'ᴤ': '',
+ # 'ᴥ': '',
+ 'ᴦ': 'G',
+ 'ᴧ': 'L',
+ 'ᴨ': 'P',
+ 'ᴩ': 'R',
+ 'ᴪ': 'PS',
+ 'ẞ': 'Ss',
+ 'Ỳ': 'Y',
+ 'ỳ': 'y',
+ 'Ỵ': 'Y',
+ 'ỵ': 'y',
+ 'Ỹ': 'Y',
+ 'ỹ': 'y',
+}
+
+####################################################################
+# Smart-to-dumb punctuation mapping
+####################################################################
+
+DUMB_PUNCTUATION = {
+ '‘': "'",
+ '’': "'",
+ '‚': "'",
+ '“': '"',
+ '”': '"',
+ '„': '"',
+ '–': '-',
+ '—': '-'
+}
+
+
+####################################################################
+# Used by `Workflow.filter`
+####################################################################
+
+# Anchor characters in a name
+#: Characters that indicate the beginning of a "word" in CamelCase
+INITIALS = string.ascii_uppercase + string.digits
+
+#: Split on non-letters, numbers
+split_on_delimiters = re.compile('[^a-zA-Z0-9]').split
+
+# Match filter flags
+#: Match items that start with ``query``
+MATCH_STARTSWITH = 1
+#: Match items whose capital letters start with ``query``
+MATCH_CAPITALS = 2
+#: Match items with a component "word" that matches ``query``
+MATCH_ATOM = 4
+#: Match items whose initials (based on atoms) start with ``query``
+MATCH_INITIALS_STARTSWITH = 8
+#: Match items whose initials (based on atoms) contain ``query``
+MATCH_INITIALS_CONTAIN = 16
+#: Combination of :const:`MATCH_INITIALS_STARTSWITH` and
+#: :const:`MATCH_INITIALS_CONTAIN`
+MATCH_INITIALS = 24
+#: Match items if ``query`` is a substring
+MATCH_SUBSTRING = 32
+#: Match items if all characters in ``query`` appear in the item in order
+MATCH_ALLCHARS = 64
+#: Combination of all other ``MATCH_*`` constants
+MATCH_ALL = 127
+
+
+####################################################################
+# Used by `Workflow.check_update`
+####################################################################
+
+# Number of days to wait between checking for updates to the workflow
+DEFAULT_UPDATE_FREQUENCY = 1
+
+
+####################################################################
+# Keychain access errors
+####################################################################
+
+
+class KeychainError(Exception):
+ """Raised for unknown Keychain errors.
+
+ Raised by methods :meth:`Workflow.save_password`,
+ :meth:`Workflow.get_password` and :meth:`Workflow.delete_password`
+ when ``security`` CLI app returns an unknown error code.
+
+ """
+
+
+class PasswordNotFound(KeychainError):
+ """Password not in Keychain.
+
+ Raised by method :meth:`Workflow.get_password` when ``account``
+ is unknown to the Keychain.
+
+ """
+
+
+class PasswordExists(KeychainError):
+ """Raised when trying to overwrite an existing account password.
+
+ You should never receive this error: it is used internally
+ by the :meth:`Workflow.save_password` method to know if it needs
+ to delete the old password first (a Keychain implementation detail).
+
+ """
+
+
+####################################################################
+# Helper functions
+####################################################################
+
+def isascii(text):
+ """Test if ``text`` contains only ASCII characters.
+
+ :param text: text to test for ASCII-ness
+ :type text: ``unicode``
+ :returns: ``True`` if ``text`` contains only ASCII characters
+ :rtype: ``Boolean``
+
+ """
+ try:
+ text.encode('ascii')
+ except UnicodeEncodeError:
+ return False
+ return True
+
+
+####################################################################
+# Implementation classes
+####################################################################
+
+class SerializerManager(object):
+ """Contains registered serializers.
+
+ .. versionadded:: 1.8
+
+ A configured instance of this class is available at
+ :attr:`workflow.manager`.
+
+ Use :meth:`register()` to register new (or replace
+ existing) serializers, which you can specify by name when calling
+ :class:`~workflow.Workflow` data storage methods.
+
+ See :ref:`guide-serialization` and :ref:`guide-persistent-data`
+ for further information.
+
+ """
+
+ def __init__(self):
+ """Create new SerializerManager object."""
+ self._serializers = {}
+
+ def register(self, name, serializer):
+ """Register ``serializer`` object under ``name``.
+
+ Raises :class:`AttributeError` if ``serializer`` in invalid.
+
+ .. note::
+
+ ``name`` will be used as the file extension of the saved files.
+
+ :param name: Name to register ``serializer`` under
+ :type name: ``unicode`` or ``str``
+ :param serializer: object with ``load()`` and ``dump()``
+ methods
+
+ """
+ # Basic validation
+ getattr(serializer, 'load')
+ getattr(serializer, 'dump')
+
+ self._serializers[name] = serializer
+
+ def serializer(self, name):
+ """Return serializer object for ``name``.
+
+ :param name: Name of serializer to return
+ :type name: ``unicode`` or ``str``
+ :returns: serializer object or ``None`` if no such serializer
+ is registered.
+
+ """
+ return self._serializers.get(name)
+
+ def unregister(self, name):
+ """Remove registered serializer with ``name``.
+
+ Raises a :class:`ValueError` if there is no such registered
+ serializer.
+
+ :param name: Name of serializer to remove
+ :type name: ``unicode`` or ``str``
+ :returns: serializer object
+
+ """
+ if name not in self._serializers:
+ raise ValueError('No such serializer registered : {0}'.format(
+ name))
+
+ serializer = self._serializers[name]
+ del self._serializers[name]
+
+ return serializer
+
+ @property
+ def serializers(self):
+ """Return names of registered serializers."""
+ return sorted(self._serializers.keys())
+
+
+class JSONSerializer(object):
+ """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``.
+
+ .. versionadded:: 1.8
+
+ Use this serializer if you need readable data files. JSON doesn't
+ support Python objects as well as ``cPickle``/``pickle``, so be
+ careful which data you try to serialize as JSON.
+
+ """
+
+ @classmethod
+ def load(cls, file_obj):
+ """Load serialized object from open JSON file.
+
+ .. versionadded:: 1.8
+
+ :param file_obj: file handle
+ :type file_obj: ``file`` object
+ :returns: object loaded from JSON file
+ :rtype: object
+
+ """
+ return json.load(file_obj)
+
+ @classmethod
+ def dump(cls, obj, file_obj):
+ """Serialize object ``obj`` to open JSON file.
+
+ .. versionadded:: 1.8
+
+ :param obj: Python object to serialize
+ :type obj: JSON-serializable data structure
+ :param file_obj: file handle
+ :type file_obj: ``file`` object
+
+ """
+ return json.dump(obj, file_obj, indent=2, encoding='utf-8')
+
+
+class CPickleSerializer(object):
+ """Wrapper around :mod:`cPickle`. Sets ``protocol``.
+
+ .. versionadded:: 1.8
+
+ This is the default serializer and the best combination of speed and
+ flexibility.
+
+ """
+
+ @classmethod
+ def load(cls, file_obj):
+ """Load serialized object from open pickle file.
+
+ .. versionadded:: 1.8
+
+ :param file_obj: file handle
+ :type file_obj: ``file`` object
+ :returns: object loaded from pickle file
+ :rtype: object
+
+ """
+ return cPickle.load(file_obj)
+
+ @classmethod
+ def dump(cls, obj, file_obj):
+ """Serialize object ``obj`` to open pickle file.
+
+ .. versionadded:: 1.8
+
+ :param obj: Python object to serialize
+ :type obj: Python object
+ :param file_obj: file handle
+ :type file_obj: ``file`` object
+
+ """
+ return cPickle.dump(obj, file_obj, protocol=-1)
+
+
+class PickleSerializer(object):
+ """Wrapper around :mod:`pickle`. Sets ``protocol``.
+
+ .. versionadded:: 1.8
+
+ Use this serializer if you need to add custom pickling.
+
+ """
+
+ @classmethod
+ def load(cls, file_obj):
+ """Load serialized object from open pickle file.
+
+ .. versionadded:: 1.8
+
+ :param file_obj: file handle
+ :type file_obj: ``file`` object
+ :returns: object loaded from pickle file
+ :rtype: object
+
+ """
+ return pickle.load(file_obj)
+
+ @classmethod
+ def dump(cls, obj, file_obj):
+ """Serialize object ``obj`` to open pickle file.
+
+ .. versionadded:: 1.8
+
+ :param obj: Python object to serialize
+ :type obj: Python object
+ :param file_obj: file handle
+ :type file_obj: ``file`` object
+
+ """
+ return pickle.dump(obj, file_obj, protocol=-1)
+
+
+# Set up default manager and register built-in serializers
+manager = SerializerManager()
+manager.register('cpickle', CPickleSerializer)
+manager.register('pickle', PickleSerializer)
+manager.register('json', JSONSerializer)
+
+
+class Item(object):
+ """Represents a feedback item for Alfred.
+
+ Generates Alfred-compliant XML for a single item.
+
+ You probably shouldn't use this class directly, but via
+ :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item`
+ for details of arguments.
+
+ """
+
+ def __init__(self, title, subtitle='', modifier_subtitles=None,
+ arg=None, autocomplete=None, valid=False, uid=None,
+ icon=None, icontype=None, type=None, largetext=None,
+ copytext=None, quicklookurl=None):
+ """Same arguments as :meth:`Workflow.add_item`."""
+ self.title = title
+ self.subtitle = subtitle
+ self.modifier_subtitles = modifier_subtitles or {}
+ self.arg = arg
+ self.autocomplete = autocomplete
+ self.valid = valid
+ self.uid = uid
+ self.icon = icon
+ self.icontype = icontype
+ self.type = type
+ self.largetext = largetext
+ self.copytext = copytext
+ self.quicklookurl = quicklookurl
+
+ @property
+ def elem(self):
+ """Create and return feedback item for Alfred.
+
+ :returns: :class:`ElementTree.Element `
+ instance for this :class:`Item` instance.
+
+ """
+ # Attributes on - element
+ attr = {}
+ if self.valid:
+ attr['valid'] = 'yes'
+ else:
+ attr['valid'] = 'no'
+ # Allow empty string for autocomplete. This is a useful value,
+ # as TABing the result will revert the query back to just the
+ # keyword
+ if self.autocomplete is not None:
+ attr['autocomplete'] = self.autocomplete
+
+ # Optional attributes
+ for name in ('uid', 'type'):
+ value = getattr(self, name, None)
+ if value:
+ attr[name] = value
+
+ root = ET.Element('item', attr)
+ ET.SubElement(root, 'title').text = self.title
+ ET.SubElement(root, 'subtitle').text = self.subtitle
+
+ # Add modifier subtitles
+ for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'):
+ if mod in self.modifier_subtitles:
+ ET.SubElement(root, 'subtitle',
+ {'mod': mod}).text = self.modifier_subtitles[mod]
+
+ # Add arg as element instead of attribute on
- , as it's more
+ # flexible (newlines aren't allowed in attributes)
+ if self.arg:
+ ET.SubElement(root, 'arg').text = self.arg
+
+ # Add icon if there is one
+ if self.icon:
+ if self.icontype:
+ attr = dict(type=self.icontype)
+ else:
+ attr = {}
+ ET.SubElement(root, 'icon', attr).text = self.icon
+
+ if self.largetext:
+ ET.SubElement(root, 'text',
+ {'type': 'largetype'}).text = self.largetext
+
+ if self.copytext:
+ ET.SubElement(root, 'text',
+ {'type': 'copy'}).text = self.copytext
+
+ if self.quicklookurl:
+ ET.SubElement(root, 'quicklookurl').text = self.quicklookurl
+
+ return root
+
+
+class Settings(dict):
+ """A dictionary that saves itself when changed.
+
+ Dictionary keys & values will be saved as a JSON file
+ at ``filepath``. If the file does not exist, the dictionary
+ (and settings file) will be initialised with ``defaults``.
+
+ :param filepath: where to save the settings
+ :type filepath: :class:`unicode`
+ :param defaults: dict of default settings
+ :type defaults: :class:`dict`
+
+
+ An appropriate instance is provided by :class:`Workflow` instances at
+ :attr:`Workflow.settings`.
+
+ """
+
+ def __init__(self, filepath, defaults=None):
+ """Create new :class:`Settings` object."""
+ super(Settings, self).__init__()
+ self._filepath = filepath
+ self._nosave = False
+ self._original = {}
+ if os.path.exists(self._filepath):
+ self._load()
+ elif defaults:
+ for key, val in defaults.items():
+ self[key] = val
+ self.save() # save default settings
+
+ def _load(self):
+ """Load cached settings from JSON file `self._filepath`."""
+ data = {}
+ with LockFile(self._filepath, 0.5):
+ with open(self._filepath, 'rb') as fp:
+ data.update(json.load(fp))
+
+ self._original = deepcopy(data)
+
+ self._nosave = True
+ self.update(data)
+ self._nosave = False
+
+ @uninterruptible
+ def save(self):
+ """Save settings to JSON file specified in ``self._filepath``.
+
+ If you're using this class via :attr:`Workflow.settings`, which
+ you probably are, ``self._filepath`` will be ``settings.json``
+ in your workflow's data directory (see :attr:`~Workflow.datadir`).
+ """
+ if self._nosave:
+ return
+
+ data = {}
+ data.update(self)
+
+ with LockFile(self._filepath, 0.5):
+ with atomic_writer(self._filepath, 'wb') as fp:
+ json.dump(data, fp, sort_keys=True, indent=2,
+ encoding='utf-8')
+
+ # dict methods
+ def __setitem__(self, key, value):
+ """Implement :class:`dict` interface."""
+ if self._original.get(key) != value:
+ super(Settings, self).__setitem__(key, value)
+ self.save()
+
+ def __delitem__(self, key):
+ """Implement :class:`dict` interface."""
+ super(Settings, self).__delitem__(key)
+ self.save()
+
+ def update(self, *args, **kwargs):
+ """Override :class:`dict` method to save on update."""
+ super(Settings, self).update(*args, **kwargs)
+ self.save()
+
+ def setdefault(self, key, value=None):
+ """Override :class:`dict` method to save on update."""
+ ret = super(Settings, self).setdefault(key, value)
+ self.save()
+ return ret
+
+
+class Workflow(object):
+ """The ``Workflow`` object is the main interface to Alfred-Workflow.
+
+ It provides APIs for accessing the Alfred/workflow environment,
+ storing & caching data, using Keychain, and generating Script
+ Filter feedback.
+
+ ``Workflow`` is compatible with both Alfred 2 and 3. The
+ :class:`~workflow.Workflow3` subclass provides additional,
+ Alfred 3-only features, such as workflow variables.
+
+ :param default_settings: default workflow settings. If no settings file
+ exists, :class:`Workflow.settings` will be pre-populated with
+ ``default_settings``.
+ :type default_settings: :class:`dict`
+ :param update_settings: settings for updating your workflow from
+ GitHub releases. The only required key is ``github_slug``,
+ whose value must take the form of ``username/repo``.
+ If specified, ``Workflow`` will check the repo's releases
+ for updates. Your workflow must also have a semantic version
+ number. Please see the :ref:`User Manual ` and
+ `update API docs ` for more information.
+ :type update_settings: :class:`dict`
+ :param input_encoding: encoding of command line arguments. You
+ should probably leave this as the default (``utf-8``), which
+ is the encoding Alfred uses.
+ :type input_encoding: :class:`unicode`
+ :param normalization: normalisation to apply to CLI args.
+ See :meth:`Workflow.decode` for more details.
+ :type normalization: :class:`unicode`
+ :param capture_args: Capture and act on ``workflow:*`` arguments. See
+ :ref:`Magic arguments ` for details.
+ :type capture_args: :class:`Boolean`
+ :param libraries: sequence of paths to directories containing
+ libraries. These paths will be prepended to ``sys.path``.
+ :type libraries: :class:`tuple` or :class:`list`
+ :param help_url: URL to webpage where a user can ask for help with
+ the workflow, report bugs, etc. This could be the GitHub repo
+ or a page on AlfredForum.com. If your workflow throws an error,
+ this URL will be displayed in the log and Alfred's debugger. It can
+ also be opened directly in a web browser with the ``workflow:help``
+ :ref:`magic argument `.
+ :type help_url: :class:`unicode` or :class:`str`
+
+ """
+
+ # Which class to use to generate feedback items. You probably
+ # won't want to change this
+ item_class = Item
+
+ def __init__(self, default_settings=None, update_settings=None,
+ input_encoding='utf-8', normalization='NFC',
+ capture_args=True, libraries=None,
+ help_url=None):
+ """Create new :class:`Workflow` object."""
+ self._default_settings = default_settings or {}
+ self._update_settings = update_settings or {}
+ self._input_encoding = input_encoding
+ self._normalizsation = normalization
+ self._capture_args = capture_args
+ self.help_url = help_url
+ self._workflowdir = None
+ self._settings_path = None
+ self._settings = None
+ self._bundleid = None
+ self._debugging = None
+ self._name = None
+ self._cache_serializer = 'cpickle'
+ self._data_serializer = 'cpickle'
+ self._info = None
+ self._info_loaded = False
+ self._logger = None
+ self._items = []
+ self._alfred_env = None
+ # Version number of the workflow
+ self._version = UNSET
+ # Version from last workflow run
+ self._last_version_run = UNSET
+ # Cache for regex patterns created for filter keys
+ self._search_pattern_cache = {}
+ # Magic arguments
+ #: The prefix for all magic arguments. Default is ``workflow:``
+ self.magic_prefix = 'workflow:'
+ #: Mapping of available magic arguments. The built-in magic
+ #: arguments are registered by default. To add your own magic arguments
+ #: (or override built-ins), add a key:value pair where the key is
+ #: what the user should enter (prefixed with :attr:`magic_prefix`)
+ #: and the value is a callable that will be called when the argument
+ #: is entered. If you would like to display a message in Alfred, the
+ #: function should return a ``unicode`` string.
+ #:
+ #: By default, the magic arguments documented
+ #: :ref:`here ` are registered.
+ self.magic_arguments = {}
+
+ self._register_default_magic()
+
+ if libraries:
+ sys.path = libraries + sys.path
+
+ ####################################################################
+ # API methods
+ ####################################################################
+
+ # info.plist contents and alfred_* environment variables ----------
+
+ @property
+ def alfred_version(self):
+ """Alfred version as :class:`~workflow.update.Version` object."""
+ from update import Version
+ return Version(self.alfred_env.get('version'))
+
+ @property
+ def alfred_env(self):
+ """Dict of Alfred's environmental variables minus ``alfred_`` prefix.
+
+ .. versionadded:: 1.7
+
+ The variables Alfred 2.4+ exports are:
+
+ ============================ =========================================
+ Variable Description
+ ============================ =========================================
+ debug Set to ``1`` if Alfred's debugger is
+ open, otherwise unset.
+ preferences Path to Alfred.alfredpreferences
+ (where your workflows and settings are
+ stored).
+ preferences_localhash Machine-specific preferences are stored
+ in ``Alfred.alfredpreferences/preferences/local/``
+ (see ``preferences`` above for
+ the path to ``Alfred.alfredpreferences``)
+ theme ID of selected theme
+ theme_background Background colour of selected theme in
+ format ``rgba(r,g,b,a)``
+ theme_subtext Show result subtext.
+ ``0`` = Always,
+ ``1`` = Alternative actions only,
+ ``2`` = Selected result only,
+ ``3`` = Never
+ version Alfred version number, e.g. ``'2.4'``
+ version_build Alfred build number, e.g. ``277``
+ workflow_bundleid Bundle ID, e.g.
+ ``net.deanishe.alfred-mailto``
+ workflow_cache Path to workflow's cache directory
+ workflow_data Path to workflow's data directory
+ workflow_name Name of current workflow
+ workflow_uid UID of workflow
+ workflow_version The version number specified in the
+ workflow configuration sheet/info.plist
+ ============================ =========================================
+
+ **Note:** all values are Unicode strings except ``version_build`` and
+ ``theme_subtext``, which are integers.
+
+ :returns: ``dict`` of Alfred's environmental variables without the
+ ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``.
+
+ """
+ if self._alfred_env is not None:
+ return self._alfred_env
+
+ data = {}
+
+ for key in (
+ 'alfred_debug',
+ 'alfred_preferences',
+ 'alfred_preferences_localhash',
+ 'alfred_theme',
+ 'alfred_theme_background',
+ 'alfred_theme_subtext',
+ 'alfred_version',
+ 'alfred_version_build',
+ 'alfred_workflow_bundleid',
+ 'alfred_workflow_cache',
+ 'alfred_workflow_data',
+ 'alfred_workflow_name',
+ 'alfred_workflow_uid',
+ 'alfred_workflow_version'):
+
+ value = os.getenv(key)
+
+ if isinstance(value, str):
+ if key in ('alfred_debug', 'alfred_version_build',
+ 'alfred_theme_subtext'):
+ value = int(value)
+ else:
+ value = self.decode(value)
+
+ data[key[7:]] = value
+
+ self._alfred_env = data
+
+ return self._alfred_env
+
+ @property
+ def info(self):
+ """:class:`dict` of ``info.plist`` contents."""
+ if not self._info_loaded:
+ self._load_info_plist()
+ return self._info
+
+ @property
+ def bundleid(self):
+ """Workflow bundle ID from environmental vars or ``info.plist``.
+
+ :returns: bundle ID
+ :rtype: ``unicode``
+
+ """
+ if not self._bundleid:
+ if self.alfred_env.get('workflow_bundleid'):
+ self._bundleid = self.alfred_env.get('workflow_bundleid')
+ else:
+ self._bundleid = unicode(self.info['bundleid'], 'utf-8')
+
+ return self._bundleid
+
+ @property
+ def debugging(self):
+ """Whether Alfred's debugger is open.
+
+ :returns: ``True`` if Alfred's debugger is open.
+ :rtype: ``bool``
+
+ """
+ if self._debugging is None:
+ if self.alfred_env.get('debug') == 1:
+ self._debugging = True
+ else:
+ self._debugging = False
+ return self._debugging
+
+ @property
+ def name(self):
+ """Workflow name from Alfred's environmental vars or ``info.plist``.
+
+ :returns: workflow name
+ :rtype: ``unicode``
+
+ """
+ if not self._name:
+ if self.alfred_env.get('workflow_name'):
+ self._name = self.decode(self.alfred_env.get('workflow_name'))
+ else:
+ self._name = self.decode(self.info['name'])
+
+ return self._name
+
+ @property
+ def version(self):
+ """Return the version of the workflow.
+
+ .. versionadded:: 1.9.10
+
+ Get the workflow version from environment variable,
+ the ``update_settings`` dict passed on
+ instantiation, the ``version`` file located in the workflow's
+ root directory or ``info.plist``. Return ``None`` if none
+ exists or :class:`ValueError` if the version number is invalid
+ (i.e. not semantic).
+
+ :returns: Version of the workflow (not Alfred-Workflow)
+ :rtype: :class:`~workflow.update.Version` object
+
+ """
+ if self._version is UNSET:
+
+ version = None
+ # environment variable has priority
+ if self.alfred_env.get('workflow_version'):
+ version = self.alfred_env['workflow_version']
+
+ # Try `update_settings`
+ elif self._update_settings:
+ version = self._update_settings.get('version')
+
+ # `version` file
+ if not version:
+ filepath = self.workflowfile('version')
+
+ if os.path.exists(filepath):
+ with open(filepath, 'rb') as fileobj:
+ version = fileobj.read()
+
+ # info.plist
+ if not version:
+ version = self.info.get('version')
+
+ if version:
+ from update import Version
+ version = Version(version)
+
+ self._version = version
+
+ return self._version
+
+ # Workflow utility methods -----------------------------------------
+
+ @property
+ def args(self):
+ """Return command line args as normalised unicode.
+
+ Args are decoded and normalised via :meth:`~Workflow.decode`.
+
+ The encoding and normalisation are the ``input_encoding`` and
+ ``normalization`` arguments passed to :class:`Workflow` (``UTF-8``
+ and ``NFC`` are the defaults).
+
+ If :class:`Workflow` is called with ``capture_args=True``
+ (the default), :class:`Workflow` will look for certain
+ ``workflow:*`` args and, if found, perform the corresponding
+ actions and exit the workflow.
+
+ See :ref:`Magic arguments ` for details.
+
+ """
+ msg = None
+ args = [self.decode(arg) for arg in sys.argv[1:]]
+
+ # Handle magic args
+ if len(args) and self._capture_args:
+ for name in self.magic_arguments:
+ key = '{0}{1}'.format(self.magic_prefix, name)
+ if key in args:
+ msg = self.magic_arguments[name]()
+
+ if msg:
+ self.logger.debug(msg)
+ if not sys.stdout.isatty(): # Show message in Alfred
+ self.add_item(msg, valid=False, icon=ICON_INFO)
+ self.send_feedback()
+ sys.exit(0)
+ return args
+
+ @property
+ def cachedir(self):
+ """Path to workflow's cache directory.
+
+ The cache directory is a subdirectory of Alfred's own cache directory
+ in ``~/Library/Caches``. The full path is:
+
+ ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/``
+
+ ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``.
+
+ :returns: full path to workflow's cache directory
+ :rtype: ``unicode``
+
+ """
+ if self.alfred_env.get('workflow_cache'):
+ dirpath = self.alfred_env.get('workflow_cache')
+
+ else:
+ dirpath = self._default_cachedir
+
+ return self._create(dirpath)
+
+ @property
+ def _default_cachedir(self):
+ """Alfred 2's default cache directory."""
+ return os.path.join(
+ os.path.expanduser(
+ '~/Library/Caches/com.runningwithcrayons.Alfred-2/'
+ 'Workflow Data/'),
+ self.bundleid)
+
+ @property
+ def datadir(self):
+ """Path to workflow's data directory.
+
+ The data directory is a subdirectory of Alfred's own data directory in
+ ``~/Library/Application Support``. The full path is:
+
+ ``~/Library/Application Support/Alfred 2/Workflow Data/``
+
+ :returns: full path to workflow data directory
+ :rtype: ``unicode``
+
+ """
+ if self.alfred_env.get('workflow_data'):
+ dirpath = self.alfred_env.get('workflow_data')
+
+ else:
+ dirpath = self._default_datadir
+
+ return self._create(dirpath)
+
+ @property
+ def _default_datadir(self):
+ """Alfred 2's default data directory."""
+ return os.path.join(os.path.expanduser(
+ '~/Library/Application Support/Alfred 2/Workflow Data/'),
+ self.bundleid)
+
+ @property
+ def workflowdir(self):
+ """Path to workflow's root directory (where ``info.plist`` is).
+
+ :returns: full path to workflow root directory
+ :rtype: ``unicode``
+
+ """
+ if not self._workflowdir:
+ # Try the working directory first, then the directory
+ # the library is in. CWD will be the workflow root if
+ # a workflow is being run in Alfred
+ candidates = [
+ os.path.abspath(os.getcwdu()),
+ os.path.dirname(os.path.abspath(os.path.dirname(__file__)))]
+
+ # climb the directory tree until we find `info.plist`
+ for dirpath in candidates:
+
+ # Ensure directory path is Unicode
+ dirpath = self.decode(dirpath)
+
+ while True:
+ if os.path.exists(os.path.join(dirpath, 'info.plist')):
+ self._workflowdir = dirpath
+ break
+
+ elif dirpath == '/':
+ # no `info.plist` found
+ break
+
+ # Check the parent directory
+ dirpath = os.path.dirname(dirpath)
+
+ # No need to check other candidates
+ if self._workflowdir:
+ break
+
+ if not self._workflowdir:
+ raise IOError("'info.plist' not found in directory tree")
+
+ return self._workflowdir
+
+ def cachefile(self, filename):
+ """Path to ``filename`` in workflow's cache directory.
+
+ Return absolute path to ``filename`` within your workflow's
+ :attr:`cache directory `.
+
+ :param filename: basename of file
+ :type filename: ``unicode``
+ :returns: full path to file within cache directory
+ :rtype: ``unicode``
+
+ """
+ return os.path.join(self.cachedir, filename)
+
+ def datafile(self, filename):
+ """Path to ``filename`` in workflow's data directory.
+
+ Return absolute path to ``filename`` within your workflow's
+ :attr:`data directory `.
+
+ :param filename: basename of file
+ :type filename: ``unicode``
+ :returns: full path to file within data directory
+ :rtype: ``unicode``
+
+ """
+ return os.path.join(self.datadir, filename)
+
+ def workflowfile(self, filename):
+ """Return full path to ``filename`` in workflow's root directory.
+
+ :param filename: basename of file
+ :type filename: ``unicode``
+ :returns: full path to file within data directory
+ :rtype: ``unicode``
+
+ """
+ return os.path.join(self.workflowdir, filename)
+
+ @property
+ def logfile(self):
+ """Path to logfile.
+
+ :returns: path to logfile within workflow's cache directory
+ :rtype: ``unicode``
+
+ """
+ return self.cachefile('%s.log' % self.bundleid)
+
+ @property
+ def logger(self):
+ """Logger that logs to both console and a log file.
+
+ If Alfred's debugger is open, log level will be ``DEBUG``,
+ else it will be ``INFO``.
+
+ Use :meth:`open_log` to open the log file in Console.
+
+ :returns: an initialised :class:`~logging.Logger`
+
+ """
+ if self._logger:
+ return self._logger
+
+ # Initialise new logger and optionally handlers
+ logger = logging.getLogger('')
+
+ # Only add one set of handlers
+ # Exclude from coverage, as pytest will have configured the
+ # root logger already
+ if not len(logger.handlers): # pragma: no cover
+
+ fmt = logging.Formatter(
+ '%(asctime)s %(filename)s:%(lineno)s'
+ ' %(levelname)-8s %(message)s',
+ datefmt='%H:%M:%S')
+
+ logfile = logging.handlers.RotatingFileHandler(
+ self.logfile,
+ maxBytes=1024 * 1024,
+ backupCount=1)
+ logfile.setFormatter(fmt)
+ logger.addHandler(logfile)
+
+ console = logging.StreamHandler()
+ console.setFormatter(fmt)
+ logger.addHandler(console)
+
+ if self.debugging:
+ logger.setLevel(logging.DEBUG)
+ else:
+ logger.setLevel(logging.INFO)
+
+ self._logger = logger
+
+ return self._logger
+
+ @logger.setter
+ def logger(self, logger):
+ """Set a custom logger.
+
+ :param logger: The logger to use
+ :type logger: `~logging.Logger` instance
+
+ """
+ self._logger = logger
+
+ @property
+ def settings_path(self):
+ """Path to settings file within workflow's data directory.
+
+ :returns: path to ``settings.json`` file
+ :rtype: ``unicode``
+
+ """
+ if not self._settings_path:
+ self._settings_path = self.datafile('settings.json')
+ return self._settings_path
+
+ @property
+ def settings(self):
+ """Return a dictionary subclass that saves itself when changed.
+
+ See :ref:`guide-settings` in the :ref:`user-manual` for more
+ information on how to use :attr:`settings` and **important
+ limitations** on what it can do.
+
+ :returns: :class:`~workflow.workflow.Settings` instance
+ initialised from the data in JSON file at
+ :attr:`settings_path` or if that doesn't exist, with the
+ ``default_settings`` :class:`dict` passed to
+ :class:`Workflow` on instantiation.
+ :rtype: :class:`~workflow.workflow.Settings` instance
+
+ """
+ if not self._settings:
+ self.logger.debug('reading settings from %s', self.settings_path)
+ self._settings = Settings(self.settings_path,
+ self._default_settings)
+ return self._settings
+
+ @property
+ def cache_serializer(self):
+ """Name of default cache serializer.
+
+ .. versionadded:: 1.8
+
+ This serializer is used by :meth:`cache_data()` and
+ :meth:`cached_data()`
+
+ See :class:`SerializerManager` for details.
+
+ :returns: serializer name
+ :rtype: ``unicode``
+
+ """
+ return self._cache_serializer
+
+ @cache_serializer.setter
+ def cache_serializer(self, serializer_name):
+ """Set the default cache serialization format.
+
+ .. versionadded:: 1.8
+
+ This serializer is used by :meth:`cache_data()` and
+ :meth:`cached_data()`
+
+ The specified serializer must already by registered with the
+ :class:`SerializerManager` at `~workflow.workflow.manager`,
+ otherwise a :class:`ValueError` will be raised.
+
+ :param serializer_name: Name of default serializer to use.
+ :type serializer_name:
+
+ """
+ if manager.serializer(serializer_name) is None:
+ raise ValueError(
+ 'Unknown serializer : `{0}`. Register your serializer '
+ 'with `manager` first.'.format(serializer_name))
+
+ self.logger.debug('default cache serializer: %s', serializer_name)
+
+ self._cache_serializer = serializer_name
+
+ @property
+ def data_serializer(self):
+ """Name of default data serializer.
+
+ .. versionadded:: 1.8
+
+ This serializer is used by :meth:`store_data()` and
+ :meth:`stored_data()`
+
+ See :class:`SerializerManager` for details.
+
+ :returns: serializer name
+ :rtype: ``unicode``
+
+ """
+ return self._data_serializer
+
+ @data_serializer.setter
+ def data_serializer(self, serializer_name):
+ """Set the default cache serialization format.
+
+ .. versionadded:: 1.8
+
+ This serializer is used by :meth:`store_data()` and
+ :meth:`stored_data()`
+
+ The specified serializer must already by registered with the
+ :class:`SerializerManager` at `~workflow.workflow.manager`,
+ otherwise a :class:`ValueError` will be raised.
+
+ :param serializer_name: Name of serializer to use by default.
+
+ """
+ if manager.serializer(serializer_name) is None:
+ raise ValueError(
+ 'Unknown serializer : `{0}`. Register your serializer '
+ 'with `manager` first.'.format(serializer_name))
+
+ self.logger.debug('default data serializer: %s', serializer_name)
+
+ self._data_serializer = serializer_name
+
+ def stored_data(self, name):
+ """Retrieve data from data directory.
+
+ Returns ``None`` if there are no data stored under ``name``.
+
+ .. versionadded:: 1.8
+
+ :param name: name of datastore
+
+ """
+ metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
+
+ if not os.path.exists(metadata_path):
+ self.logger.debug('no data stored for `%s`', name)
+ return None
+
+ with open(metadata_path, 'rb') as file_obj:
+ serializer_name = file_obj.read().strip()
+
+ serializer = manager.serializer(serializer_name)
+
+ if serializer is None:
+ raise ValueError(
+ 'Unknown serializer `{0}`. Register a corresponding '
+ 'serializer with `manager.register()` '
+ 'to load this data.'.format(serializer_name))
+
+ self.logger.debug('data `%s` stored as `%s`', name, serializer_name)
+
+ filename = '{0}.{1}'.format(name, serializer_name)
+ data_path = self.datafile(filename)
+
+ if not os.path.exists(data_path):
+ self.logger.debug('no data stored: %s', name)
+ if os.path.exists(metadata_path):
+ os.unlink(metadata_path)
+
+ return None
+
+ with open(data_path, 'rb') as file_obj:
+ data = serializer.load(file_obj)
+
+ self.logger.debug('stored data loaded: %s', data_path)
+
+ return data
+
+ def store_data(self, name, data, serializer=None):
+ """Save data to data directory.
+
+ .. versionadded:: 1.8
+
+ If ``data`` is ``None``, the datastore will be deleted.
+
+ Note that the datastore does NOT support mutliple threads.
+
+ :param name: name of datastore
+ :param data: object(s) to store. **Note:** some serializers
+ can only handled certain types of data.
+ :param serializer: name of serializer to use. If no serializer
+ is specified, the default will be used. See
+ :class:`SerializerManager` for more information.
+ :returns: data in datastore or ``None``
+
+ """
+ # Ensure deletion is not interrupted by SIGTERM
+ @uninterruptible
+ def delete_paths(paths):
+ """Clear one or more data stores"""
+ for path in paths:
+ if os.path.exists(path):
+ os.unlink(path)
+ self.logger.debug('deleted data file: %s', path)
+
+ serializer_name = serializer or self.data_serializer
+
+ # In order for `stored_data()` to be able to load data stored with
+ # an arbitrary serializer, yet still have meaningful file extensions,
+ # the format (i.e. extension) is saved to an accompanying file
+ metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
+ filename = '{0}.{1}'.format(name, serializer_name)
+ data_path = self.datafile(filename)
+
+ if data_path == self.settings_path:
+ raise ValueError(
+ 'Cannot save data to' +
+ '`{0}` with format `{1}`. '.format(name, serializer_name) +
+ "This would overwrite Alfred-Workflow's settings file.")
+
+ serializer = manager.serializer(serializer_name)
+
+ if serializer is None:
+ raise ValueError(
+ 'Invalid serializer `{0}`. Register your serializer with '
+ '`manager.register()` first.'.format(serializer_name))
+
+ if data is None: # Delete cached data
+ delete_paths((metadata_path, data_path))
+ return
+
+ # Ensure write is not interrupted by SIGTERM
+ @uninterruptible
+ def _store():
+ # Save file extension
+ with atomic_writer(metadata_path, 'wb') as file_obj:
+ file_obj.write(serializer_name)
+
+ with atomic_writer(data_path, 'wb') as file_obj:
+ serializer.dump(data, file_obj)
+
+ _store()
+
+ self.logger.debug('saved data: %s', data_path)
+
+ def cached_data(self, name, data_func=None, max_age=60):
+ """Return cached data if younger than ``max_age`` seconds.
+
+ Retrieve data from cache or re-generate and re-cache data if
+ stale/non-existant. If ``max_age`` is 0, return cached data no
+ matter how old.
+
+ :param name: name of datastore
+ :param data_func: function to (re-)generate data.
+ :type data_func: ``callable``
+ :param max_age: maximum age of cached data in seconds
+ :type max_age: ``int``
+ :returns: cached data, return value of ``data_func`` or ``None``
+ if ``data_func`` is not set
+
+ """
+ serializer = manager.serializer(self.cache_serializer)
+
+ cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
+ age = self.cached_data_age(name)
+
+ if (age < max_age or max_age == 0) and os.path.exists(cache_path):
+
+ with open(cache_path, 'rb') as file_obj:
+ self.logger.debug('loading cached data: %s', cache_path)
+ return serializer.load(file_obj)
+
+ if not data_func:
+ return None
+
+ data = data_func()
+ self.cache_data(name, data)
+
+ return data
+
+ def cache_data(self, name, data):
+ """Save ``data`` to cache under ``name``.
+
+ If ``data`` is ``None``, the corresponding cache file will be
+ deleted.
+
+ :param name: name of datastore
+ :param data: data to store. This may be any object supported by
+ the cache serializer
+
+ """
+ serializer = manager.serializer(self.cache_serializer)
+
+ cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
+
+ if data is None:
+ if os.path.exists(cache_path):
+ os.unlink(cache_path)
+ self.logger.debug('deleted cache file: %s', cache_path)
+ return
+
+ with atomic_writer(cache_path, 'wb') as file_obj:
+ serializer.dump(data, file_obj)
+
+ self.logger.debug('cached data: %s', cache_path)
+
+ def cached_data_fresh(self, name, max_age):
+ """Whether cache `name` is less than `max_age` seconds old.
+
+ :param name: name of datastore
+ :param max_age: maximum age of data in seconds
+ :type max_age: ``int``
+ :returns: ``True`` if data is less than ``max_age`` old, else
+ ``False``
+
+ """
+ age = self.cached_data_age(name)
+
+ if not age:
+ return False
+
+ return age < max_age
+
+ def cached_data_age(self, name):
+ """Return age in seconds of cache `name` or 0 if cache doesn't exist.
+
+ :param name: name of datastore
+ :type name: ``unicode``
+ :returns: age of datastore in seconds
+ :rtype: ``int``
+
+ """
+ cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
+
+ if not os.path.exists(cache_path):
+ return 0
+
+ return time.time() - os.stat(cache_path).st_mtime
+
+ def filter(self, query, items, key=lambda x: x, ascending=False,
+ include_score=False, min_score=0, max_results=0,
+ match_on=MATCH_ALL, fold_diacritics=True):
+ """Fuzzy search filter. Returns list of ``items`` that match ``query``.
+
+ ``query`` is case-insensitive. Any item that does not contain the
+ entirety of ``query`` is rejected.
+
+ If ``query`` is an empty string or contains only whitespace,
+ all items will match.
+
+ :param query: query to test items against
+ :type query: ``unicode``
+ :param items: iterable of items to test
+ :type items: ``list`` or ``tuple``
+ :param key: function to get comparison key from ``items``.
+ Must return a ``unicode`` string. The default simply returns
+ the item.
+ :type key: ``callable``
+ :param ascending: set to ``True`` to get worst matches first
+ :type ascending: ``Boolean``
+ :param include_score: Useful for debugging the scoring algorithm.
+ If ``True``, results will be a list of tuples
+ ``(item, score, rule)``.
+ :type include_score: ``Boolean``
+ :param min_score: If non-zero, ignore results with a score lower
+ than this.
+ :type min_score: ``int``
+ :param max_results: If non-zero, prune results list to this length.
+ :type max_results: ``int``
+ :param match_on: Filter option flags. Bitwise-combined list of
+ ``MATCH_*`` constants (see below).
+ :type match_on: ``int``
+ :param fold_diacritics: Convert search keys to ASCII-only
+ characters if ``query`` only contains ASCII characters.
+ :type fold_diacritics: ``Boolean``
+ :returns: list of ``items`` matching ``query`` or list of
+ ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``.
+ ``rule`` is the ``MATCH_*`` rule that matched the item.
+ :rtype: ``list``
+
+ **Matching rules**
+
+ By default, :meth:`filter` uses all of the following flags (i.e.
+ :const:`MATCH_ALL`). The tests are always run in the given order:
+
+ 1. :const:`MATCH_STARTSWITH`
+ Item search key starts with ``query`` (case-insensitive).
+ 2. :const:`MATCH_CAPITALS`
+ The list of capital letters in item search key starts with
+ ``query`` (``query`` may be lower-case). E.g., ``of``
+ would match ``OmniFocus``, ``gc`` would match ``Google Chrome``.
+ 3. :const:`MATCH_ATOM`
+ Search key is split into "atoms" on non-word characters
+ (.,-,' etc.). Matches if ``query`` is one of these atoms
+ (case-insensitive).
+ 4. :const:`MATCH_INITIALS_STARTSWITH`
+ Initials are the first characters of the above-described
+ "atoms" (case-insensitive).
+ 5. :const:`MATCH_INITIALS_CONTAIN`
+ ``query`` is a substring of the above-described initials.
+ 6. :const:`MATCH_INITIALS`
+ Combination of (4) and (5).
+ 7. :const:`MATCH_SUBSTRING`
+ ``query`` is a substring of item search key (case-insensitive).
+ 8. :const:`MATCH_ALLCHARS`
+ All characters in ``query`` appear in item search key in
+ the same order (case-insensitive).
+ 9. :const:`MATCH_ALL`
+ Combination of all the above.
+
+
+ :const:`MATCH_ALLCHARS` is considerably slower than the other
+ tests and provides much less accurate results.
+
+ **Examples:**
+
+ To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst
+ matches and is expensive to run), use
+ ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``.
+
+ To match only on capitals, use ``match_on=MATCH_CAPITALS``.
+
+ To match only on startswith and substring, use
+ ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``.
+
+ **Diacritic folding**
+
+ .. versionadded:: 1.3
+
+ If ``fold_diacritics`` is ``True`` (the default), and ``query``
+ contains only ASCII characters, non-ASCII characters in search keys
+ will be converted to ASCII equivalents (e.g. **ü** -> **u**,
+ **ß** -> **ss**, **é** -> **e**).
+
+ See :const:`ASCII_REPLACEMENTS` for all replacements.
+
+ If ``query`` contains non-ASCII characters, search keys will not be
+ altered.
+
+ """
+ if not query:
+ return items
+
+ # Remove preceding/trailing spaces
+ query = query.strip()
+
+ if not query:
+ return items
+
+ # Use user override if there is one
+ fold_diacritics = self.settings.get('__workflow_diacritic_folding',
+ fold_diacritics)
+
+ results = []
+
+ for item in items:
+ skip = False
+ score = 0
+ words = [s.strip() for s in query.split(' ')]
+ value = key(item).strip()
+ if value == '':
+ continue
+ for word in words:
+ if word == '':
+ continue
+ s, rule = self._filter_item(value, word, match_on,
+ fold_diacritics)
+
+ if not s: # Skip items that don't match part of the query
+ skip = True
+ score += s
+
+ if skip:
+ continue
+
+ if score:
+ # use "reversed" `score` (i.e. highest becomes lowest) and
+ # `value` as sort key. This means items with the same score
+ # will be sorted in alphabetical not reverse alphabetical order
+ results.append(((100.0 / score, value.lower(), score),
+ (item, score, rule)))
+
+ # sort on keys, then discard the keys
+ results.sort(reverse=ascending)
+ results = [t[1] for t in results]
+
+ if min_score:
+ results = [r for r in results if r[1] > min_score]
+
+ if max_results and len(results) > max_results:
+ results = results[:max_results]
+
+ # return list of ``(item, score, rule)``
+ if include_score:
+ return results
+ # just return list of items
+ return [t[0] for t in results]
+
+ def _filter_item(self, value, query, match_on, fold_diacritics):
+ """Filter ``value`` against ``query`` using rules ``match_on``.
+
+ :returns: ``(score, rule)``
+
+ """
+ query = query.lower()
+
+ if not isascii(query):
+ fold_diacritics = False
+
+ if fold_diacritics:
+ value = self.fold_to_ascii(value)
+
+ # pre-filter any items that do not contain all characters
+ # of ``query`` to save on running several more expensive tests
+ if not set(query) <= set(value.lower()):
+
+ return (0, None)
+
+ # item starts with query
+ if match_on & MATCH_STARTSWITH and value.lower().startswith(query):
+ score = 100.0 - (len(value) / len(query))
+
+ return (score, MATCH_STARTSWITH)
+
+ # query matches capitalised letters in item,
+ # e.g. of = OmniFocus
+ if match_on & MATCH_CAPITALS:
+ initials = ''.join([c for c in value if c in INITIALS])
+ if initials.lower().startswith(query):
+ score = 100.0 - (len(initials) / len(query))
+
+ return (score, MATCH_CAPITALS)
+
+ # split the item into "atoms", i.e. words separated by
+ # spaces or other non-word characters
+ if (match_on & MATCH_ATOM or
+ match_on & MATCH_INITIALS_CONTAIN or
+ match_on & MATCH_INITIALS_STARTSWITH):
+ atoms = [s.lower() for s in split_on_delimiters(value)]
+ # print('atoms : %s --> %s' % (value, atoms))
+ # initials of the atoms
+ initials = ''.join([s[0] for s in atoms if s])
+
+ if match_on & MATCH_ATOM:
+ # is `query` one of the atoms in item?
+ # similar to substring, but scores more highly, as it's
+ # a word within the item
+ if query in atoms:
+ score = 100.0 - (len(value) / len(query))
+
+ return (score, MATCH_ATOM)
+
+ # `query` matches start (or all) of the initials of the
+ # atoms, e.g. ``himym`` matches "How I Met Your Mother"
+ # *and* "how i met your mother" (the ``capitals`` rule only
+ # matches the former)
+ if (match_on & MATCH_INITIALS_STARTSWITH and
+ initials.startswith(query)):
+ score = 100.0 - (len(initials) / len(query))
+
+ return (score, MATCH_INITIALS_STARTSWITH)
+
+ # `query` is a substring of initials, e.g. ``doh`` matches
+ # "The Dukes of Hazzard"
+ elif (match_on & MATCH_INITIALS_CONTAIN and
+ query in initials):
+ score = 95.0 - (len(initials) / len(query))
+
+ return (score, MATCH_INITIALS_CONTAIN)
+
+ # `query` is a substring of item
+ if match_on & MATCH_SUBSTRING and query in value.lower():
+ score = 90.0 - (len(value) / len(query))
+
+ return (score, MATCH_SUBSTRING)
+
+ # finally, assign a score based on how close together the
+ # characters in `query` are in item.
+ if match_on & MATCH_ALLCHARS:
+ search = self._search_for_query(query)
+ match = search(value)
+ if match:
+ score = 100.0 / ((1 + match.start()) *
+ (match.end() - match.start() + 1))
+
+ return (score, MATCH_ALLCHARS)
+
+ # Nothing matched
+ return (0, None)
+
+ def _search_for_query(self, query):
+ if query in self._search_pattern_cache:
+ return self._search_pattern_cache[query]
+
+ # Build pattern: include all characters
+ pattern = []
+ for c in query:
+ # pattern.append('[^{0}]*{0}'.format(re.escape(c)))
+ pattern.append('.*?{0}'.format(re.escape(c)))
+ pattern = ''.join(pattern)
+ search = re.compile(pattern, re.IGNORECASE).search
+
+ self._search_pattern_cache[query] = search
+ return search
+
+ def run(self, func, text_errors=False):
+ """Call ``func`` to run your workflow.
+
+ :param func: Callable to call with ``self`` (i.e. the :class:`Workflow`
+ instance) as first argument.
+ :param text_errors: Emit error messages in plain text, not in
+ Alfred's XML/JSON feedback format. Use this when you're not
+ running Alfred-Workflow in a Script Filter and would like
+ to pass the error message to, say, a notification.
+ :type text_errors: ``Boolean``
+
+ ``func`` will be called with :class:`Workflow` instance as first
+ argument.
+
+ ``func`` should be the main entry point to your workflow.
+
+ Any exceptions raised will be logged and an error message will be
+ output to Alfred.
+
+ """
+ start = time.time()
+
+ # Write to debugger to ensure "real" output starts on a new line
+ print('.', file=sys.stderr)
+
+ # Call workflow's entry function/method within a try-except block
+ # to catch any errors and display an error message in Alfred
+ try:
+ if self.version:
+ self.logger.debug('---------- %s (%s) ----------',
+ self.name, self.version)
+ else:
+ self.logger.debug('---------- %s ----------', self.name)
+
+ # Run update check if configured for self-updates.
+ # This call has to go in the `run` try-except block, as it will
+ # initialise `self.settings`, which will raise an exception
+ # if `settings.json` isn't valid.
+ if self._update_settings:
+ self.check_update()
+
+ # Run workflow's entry function/method
+ func(self)
+
+ # Set last version run to current version after a successful
+ # run
+ self.set_last_version()
+
+ except Exception as err:
+ self.logger.exception(err)
+ if self.help_url:
+ self.logger.info('for assistance, see: %s', self.help_url)
+
+ if not sys.stdout.isatty(): # Show error in Alfred
+ if text_errors:
+ print(unicode(err).encode('utf-8'), end='')
+ else:
+ self._items = []
+ if self._name:
+ name = self._name
+ elif self._bundleid: # pragma: no cover
+ name = self._bundleid
+ else: # pragma: no cover
+ name = os.path.dirname(__file__)
+ self.add_item("Error in workflow '%s'" % name,
+ unicode(err),
+ icon=ICON_ERROR)
+ self.send_feedback()
+ return 1
+
+ finally:
+ self.logger.debug('---------- finished in %0.3fs ----------',
+ time.time() - start)
+
+ return 0
+
+ # Alfred feedback methods ------------------------------------------
+
+ def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None,
+ autocomplete=None, valid=False, uid=None, icon=None,
+ icontype=None, type=None, largetext=None, copytext=None,
+ quicklookurl=None):
+ """Add an item to be output to Alfred.
+
+ :param title: Title shown in Alfred
+ :type title: ``unicode``
+ :param subtitle: Subtitle shown in Alfred
+ :type subtitle: ``unicode``
+ :param modifier_subtitles: Subtitles shown when modifier
+ (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase
+ keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn``
+ :type modifier_subtitles: ``dict``
+ :param arg: Argument passed by Alfred as ``{query}`` when item is
+ actioned
+ :type arg: ``unicode``
+ :param autocomplete: Text expanded in Alfred when item is TABbed
+ :type autocomplete: ``unicode``
+ :param valid: Whether or not item can be actioned
+ :type valid: ``Boolean``
+ :param uid: Used by Alfred to remember/sort items
+ :type uid: ``unicode``
+ :param icon: Filename of icon to use
+ :type icon: ``unicode``
+ :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'``
+ or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype
+ such as ``'public.folder'``. Use ``'fileicon'`` when you wish to
+ use the icon of the file specified as ``icon``, e.g.
+ ``icon='/Applications/Safari.app', icontype='fileicon'``.
+ Leave as `None` if ``icon`` points to an actual
+ icon file.
+ :type icontype: ``unicode``
+ :param type: Result type. Currently only ``'file'`` is supported
+ (by Alfred). This will tell Alfred to enable file actions for
+ this item.
+ :type type: ``unicode``
+ :param largetext: Text to be displayed in Alfred's large text box
+ if user presses CMD+L on item.
+ :type largetext: ``unicode``
+ :param copytext: Text to be copied to pasteboard if user presses
+ CMD+C on item.
+ :type copytext: ``unicode``
+ :param quicklookurl: URL to be displayed using Alfred's Quick Look
+ feature (tapping ``SHIFT`` or ``⌘+Y`` on a result).
+ :type quicklookurl: ``unicode``
+ :returns: :class:`Item` instance
+
+ See :ref:`icons` for a list of the supported system icons.
+
+ .. note::
+
+ Although this method returns an :class:`Item` instance, you don't
+ need to hold onto it or worry about it. All generated :class:`Item`
+ instances are also collected internally and sent to Alfred when
+ :meth:`send_feedback` is called.
+
+ The generated :class:`Item` is only returned in case you want to
+ edit it or do something with it other than send it to Alfred.
+
+ """
+ item = self.item_class(title, subtitle, modifier_subtitles, arg,
+ autocomplete, valid, uid, icon, icontype, type,
+ largetext, copytext, quicklookurl)
+ self._items.append(item)
+ return item
+
+ def send_feedback(self):
+ """Print stored items to console/Alfred as XML."""
+ root = ET.Element('items')
+ for item in self._items:
+ root.append(item.elem)
+ sys.stdout.write('\n')
+ sys.stdout.write(ET.tostring(root).encode('utf-8'))
+ sys.stdout.flush()
+
+ ####################################################################
+ # Updating methods
+ ####################################################################
+
+ @property
+ def first_run(self):
+ """Return ``True`` if it's the first time this version has run.
+
+ .. versionadded:: 1.9.10
+
+ Raises a :class:`ValueError` if :attr:`version` isn't set.
+
+ """
+ if not self.version:
+ raise ValueError('No workflow version set')
+
+ if not self.last_version_run:
+ return True
+
+ return self.version != self.last_version_run
+
+ @property
+ def last_version_run(self):
+ """Return version of last version to run (or ``None``).
+
+ .. versionadded:: 1.9.10
+
+ :returns: :class:`~workflow.update.Version` instance
+ or ``None``
+
+ """
+ if self._last_version_run is UNSET:
+
+ version = self.settings.get('__workflow_last_version')
+ if version:
+ from update import Version
+ version = Version(version)
+
+ self._last_version_run = version
+
+ self.logger.debug('last run version: %s', self._last_version_run)
+
+ return self._last_version_run
+
+ def set_last_version(self, version=None):
+ """Set :attr:`last_version_run` to current version.
+
+ .. versionadded:: 1.9.10
+
+ :param version: version to store (default is current version)
+ :type version: :class:`~workflow.update.Version` instance
+ or ``unicode``
+ :returns: ``True`` if version is saved, else ``False``
+
+ """
+ if not version:
+ if not self.version:
+ self.logger.warning(
+ "Can't save last version: workflow has no version")
+ return False
+
+ version = self.version
+
+ if isinstance(version, basestring):
+ from update import Version
+ version = Version(version)
+
+ self.settings['__workflow_last_version'] = str(version)
+
+ self.logger.debug('set last run version: %s', version)
+
+ return True
+
+ @property
+ def update_available(self):
+ """Whether an update is available.
+
+ .. versionadded:: 1.9
+
+ See :ref:`guide-updates` in the :ref:`user-manual` for detailed
+ information on how to enable your workflow to update itself.
+
+ :returns: ``True`` if an update is available, else ``False``
+
+ """
+ # Create a new workflow object to ensure standard serialiser
+ # is used (update.py is called without the user's settings)
+ update_data = Workflow().cached_data('__workflow_update_status',
+ max_age=0)
+
+ self.logger.debug('update_data: %r', update_data)
+
+ if not update_data or not update_data.get('available'):
+ return False
+
+ return update_data['available']
+
+ @property
+ def prereleases(self):
+ """Whether workflow should update to pre-release versions.
+
+ .. versionadded:: 1.16
+
+ :returns: ``True`` if pre-releases are enabled with the :ref:`magic
+ argument ` or the ``update_settings`` dict, else
+ ``False``.
+
+ """
+ if self._update_settings.get('prereleases'):
+ return True
+
+ return self.settings.get('__workflow_prereleases') or False
+
+ def check_update(self, force=False):
+ """Call update script if it's time to check for a new release.
+
+ .. versionadded:: 1.9
+
+ The update script will be run in the background, so it won't
+ interfere in the execution of your workflow.
+
+ See :ref:`guide-updates` in the :ref:`user-manual` for detailed
+ information on how to enable your workflow to update itself.
+
+ :param force: Force update check
+ :type force: ``Boolean``
+
+ """
+ frequency = self._update_settings.get('frequency',
+ DEFAULT_UPDATE_FREQUENCY)
+
+ if not force and not self.settings.get('__workflow_autoupdate', True):
+ self.logger.debug('Auto update turned off by user')
+ return
+
+ # Check for new version if it's time
+ if (force or not self.cached_data_fresh(
+ '__workflow_update_status', frequency * 86400)):
+
+ github_slug = self._update_settings['github_slug']
+ # version = self._update_settings['version']
+ version = str(self.version)
+
+ from background import run_in_background
+
+ # update.py is adjacent to this file
+ update_script = os.path.join(os.path.dirname(__file__),
+ b'update.py')
+
+ cmd = ['/usr/bin/python', update_script, 'check', github_slug,
+ version]
+
+ if self.prereleases:
+ cmd.append('--prereleases')
+
+ self.logger.info('checking for update ...')
+
+ run_in_background('__workflow_update_check', cmd)
+
+ else:
+ self.logger.debug('update check not due')
+
+ def start_update(self):
+ """Check for update and download and install new workflow file.
+
+ .. versionadded:: 1.9
+
+ See :ref:`guide-updates` in the :ref:`user-manual` for detailed
+ information on how to enable your workflow to update itself.
+
+ :returns: ``True`` if an update is available and will be
+ installed, else ``False``
+
+ """
+ import update
+
+ github_slug = self._update_settings['github_slug']
+ # version = self._update_settings['version']
+ version = str(self.version)
+
+ if not update.check_update(github_slug, version, self.prereleases):
+ return False
+
+ from background import run_in_background
+
+ # update.py is adjacent to this file
+ update_script = os.path.join(os.path.dirname(__file__),
+ b'update.py')
+
+ cmd = ['/usr/bin/python', update_script, 'install', github_slug,
+ version]
+
+ if self.prereleases:
+ cmd.append('--prereleases')
+
+ self.logger.debug('downloading update ...')
+ run_in_background('__workflow_update_install', cmd)
+
+ return True
+
+ ####################################################################
+ # Keychain password storage methods
+ ####################################################################
+
+ def save_password(self, account, password, service=None):
+ """Save account credentials.
+
+ If the account exists, the old password will first be deleted
+ (Keychain throws an error otherwise).
+
+ If something goes wrong, a :class:`KeychainError` exception will
+ be raised.
+
+ :param account: name of the account the password is for, e.g.
+ "Pinboard"
+ :type account: ``unicode``
+ :param password: the password to secure
+ :type password: ``unicode``
+ :param service: Name of the service. By default, this is the
+ workflow's bundle ID
+ :type service: ``unicode``
+
+ """
+ if not service:
+ service = self.bundleid
+
+ try:
+ self._call_security('add-generic-password', service, account,
+ '-w', password)
+ self.logger.debug('saved password : %s:%s', service, account)
+
+ except PasswordExists:
+ self.logger.debug('password exists : %s:%s', service, account)
+ current_password = self.get_password(account, service)
+
+ if current_password == password:
+ self.logger.debug('password unchanged')
+
+ else:
+ self.delete_password(account, service)
+ self._call_security('add-generic-password', service,
+ account, '-w', password)
+ self.logger.debug('save_password : %s:%s', service, account)
+
+ def get_password(self, account, service=None):
+ """Retrieve the password saved at ``service/account``.
+
+ Raise :class:`PasswordNotFound` exception if password doesn't exist.
+
+ :param account: name of the account the password is for, e.g.
+ "Pinboard"
+ :type account: ``unicode``
+ :param service: Name of the service. By default, this is the workflow's
+ bundle ID
+ :type service: ``unicode``
+ :returns: account password
+ :rtype: ``unicode``
+
+ """
+ if not service:
+ service = self.bundleid
+
+ output = self._call_security('find-generic-password', service,
+ account, '-g')
+
+ # Parsing of `security` output is adapted from python-keyring
+ # by Jason R. Coombs
+ # https://pypi.python.org/pypi/keyring
+ m = re.search(
+ r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?',
+ output)
+
+ if m:
+ groups = m.groupdict()
+ h = groups.get('hex')
+ password = groups.get('pw')
+ if h:
+ password = unicode(binascii.unhexlify(h), 'utf-8')
+
+ self.logger.debug('got password : %s:%s', service, account)
+
+ return password
+
+ def delete_password(self, account, service=None):
+ """Delete the password stored at ``service/account``.
+
+ Raise :class:`PasswordNotFound` if account is unknown.
+
+ :param account: name of the account the password is for, e.g.
+ "Pinboard"
+ :type account: ``unicode``
+ :param service: Name of the service. By default, this is the workflow's
+ bundle ID
+ :type service: ``unicode``
+
+ """
+ if not service:
+ service = self.bundleid
+
+ self._call_security('delete-generic-password', service, account)
+
+ self.logger.debug('deleted password : %s:%s', service, account)
+
+ ####################################################################
+ # Methods for workflow:* magic args
+ ####################################################################
+
+ def _register_default_magic(self):
+ """Register the built-in magic arguments."""
+ # TODO: refactor & simplify
+ # Wrap callback and message with callable
+ def callback(func, msg):
+ def wrapper():
+ func()
+ return msg
+
+ return wrapper
+
+ self.magic_arguments['delcache'] = callback(self.clear_cache,
+ 'Deleted workflow cache')
+ self.magic_arguments['deldata'] = callback(self.clear_data,
+ 'Deleted workflow data')
+ self.magic_arguments['delsettings'] = callback(
+ self.clear_settings, 'Deleted workflow settings')
+ self.magic_arguments['reset'] = callback(self.reset,
+ 'Reset workflow')
+ self.magic_arguments['openlog'] = callback(self.open_log,
+ 'Opening workflow log file')
+ self.magic_arguments['opencache'] = callback(
+ self.open_cachedir, 'Opening workflow cache directory')
+ self.magic_arguments['opendata'] = callback(
+ self.open_datadir, 'Opening workflow data directory')
+ self.magic_arguments['openworkflow'] = callback(
+ self.open_workflowdir, 'Opening workflow directory')
+ self.magic_arguments['openterm'] = callback(
+ self.open_terminal, 'Opening workflow root directory in Terminal')
+
+ # Diacritic folding
+ def fold_on():
+ self.settings['__workflow_diacritic_folding'] = True
+ return 'Diacritics will always be folded'
+
+ def fold_off():
+ self.settings['__workflow_diacritic_folding'] = False
+ return 'Diacritics will never be folded'
+
+ def fold_default():
+ if '__workflow_diacritic_folding' in self.settings:
+ del self.settings['__workflow_diacritic_folding']
+ return 'Diacritics folding reset'
+
+ self.magic_arguments['foldingon'] = fold_on
+ self.magic_arguments['foldingoff'] = fold_off
+ self.magic_arguments['foldingdefault'] = fold_default
+
+ # Updates
+ def update_on():
+ self.settings['__workflow_autoupdate'] = True
+ return 'Auto update turned on'
+
+ def update_off():
+ self.settings['__workflow_autoupdate'] = False
+ return 'Auto update turned off'
+
+ def prereleases_on():
+ self.settings['__workflow_prereleases'] = True
+ return 'Prerelease updates turned on'
+
+ def prereleases_off():
+ self.settings['__workflow_prereleases'] = False
+ return 'Prerelease updates turned off'
+
+ def do_update():
+ if self.start_update():
+ return 'Downloading and installing update ...'
+ else:
+ return 'No update available'
+
+ self.magic_arguments['autoupdate'] = update_on
+ self.magic_arguments['noautoupdate'] = update_off
+ self.magic_arguments['prereleases'] = prereleases_on
+ self.magic_arguments['noprereleases'] = prereleases_off
+ self.magic_arguments['update'] = do_update
+
+ # Help
+ def do_help():
+ if self.help_url:
+ self.open_help()
+ return 'Opening workflow help URL in browser'
+ else:
+ return 'Workflow has no help URL'
+
+ def show_version():
+ if self.version:
+ return 'Version: {0}'.format(self.version)
+ else:
+ return 'This workflow has no version number'
+
+ def list_magic():
+ """Display all available magic args in Alfred."""
+ isatty = sys.stderr.isatty()
+ for name in sorted(self.magic_arguments.keys()):
+ if name == 'magic':
+ continue
+ arg = self.magic_prefix + name
+ self.logger.debug(arg)
+
+ if not isatty:
+ self.add_item(arg, icon=ICON_INFO)
+
+ if not isatty:
+ self.send_feedback()
+
+ self.magic_arguments['help'] = do_help
+ self.magic_arguments['magic'] = list_magic
+ self.magic_arguments['version'] = show_version
+
+ def clear_cache(self, filter_func=lambda f: True):
+ """Delete all files in workflow's :attr:`cachedir`.
+
+ :param filter_func: Callable to determine whether a file should be
+ deleted or not. ``filter_func`` is called with the filename
+ of each file in the data directory. If it returns ``True``,
+ the file will be deleted.
+ By default, *all* files will be deleted.
+ :type filter_func: ``callable``
+ """
+ self._delete_directory_contents(self.cachedir, filter_func)
+
+ def clear_data(self, filter_func=lambda f: True):
+ """Delete all files in workflow's :attr:`datadir`.
+
+ :param filter_func: Callable to determine whether a file should be
+ deleted or not. ``filter_func`` is called with the filename
+ of each file in the data directory. If it returns ``True``,
+ the file will be deleted.
+ By default, *all* files will be deleted.
+ :type filter_func: ``callable``
+ """
+ self._delete_directory_contents(self.datadir, filter_func)
+
+ def clear_settings(self):
+ """Delete workflow's :attr:`settings_path`."""
+ if os.path.exists(self.settings_path):
+ os.unlink(self.settings_path)
+ self.logger.debug('deleted : %r', self.settings_path)
+
+ def reset(self):
+ """Delete workflow settings, cache and data.
+
+ File :attr:`settings ` and directories
+ :attr:`cache ` and :attr:`data ` are deleted.
+
+ """
+ self.clear_cache()
+ self.clear_data()
+ self.clear_settings()
+
+ def open_log(self):
+ """Open :attr:`logfile` in default app (usually Console.app)."""
+ subprocess.call(['open', self.logfile])
+
+ def open_cachedir(self):
+ """Open the workflow's :attr:`cachedir` in Finder."""
+ subprocess.call(['open', self.cachedir])
+
+ def open_datadir(self):
+ """Open the workflow's :attr:`datadir` in Finder."""
+ subprocess.call(['open', self.datadir])
+
+ def open_workflowdir(self):
+ """Open the workflow's :attr:`workflowdir` in Finder."""
+ subprocess.call(['open', self.workflowdir])
+
+ def open_terminal(self):
+ """Open a Terminal window at workflow's :attr:`workflowdir`."""
+ subprocess.call(['open', '-a', 'Terminal',
+ self.workflowdir])
+
+ def open_help(self):
+ """Open :attr:`help_url` in default browser."""
+ subprocess.call(['open', self.help_url])
+
+ return 'Opening workflow help URL in browser'
+
+ ####################################################################
+ # Helper methods
+ ####################################################################
+
+ def decode(self, text, encoding=None, normalization=None):
+ """Return ``text`` as normalised unicode.
+
+ If ``encoding`` and/or ``normalization`` is ``None``, the
+ ``input_encoding``and ``normalization`` parameters passed to
+ :class:`Workflow` are used.
+
+ :param text: string
+ :type text: encoded or Unicode string. If ``text`` is already a
+ Unicode string, it will only be normalised.
+ :param encoding: The text encoding to use to decode ``text`` to
+ Unicode.
+ :type encoding: ``unicode`` or ``None``
+ :param normalization: The nomalisation form to apply to ``text``.
+ :type normalization: ``unicode`` or ``None``
+ :returns: decoded and normalised ``unicode``
+
+ :class:`Workflow` uses "NFC" normalisation by default. This is the
+ standard for Python and will work well with data from the web (via
+ :mod:`~workflow.web` or :mod:`json`).
+
+ macOS, on the other hand, uses "NFD" normalisation (nearly), so data
+ coming from the system (e.g. via :mod:`subprocess` or
+ :func:`os.listdir`/:mod:`os.path`) may not match. You should either
+ normalise this data, too, or change the default normalisation used by
+ :class:`Workflow`.
+
+ """
+ encoding = encoding or self._input_encoding
+ normalization = normalization or self._normalizsation
+ if not isinstance(text, unicode):
+ text = unicode(text, encoding)
+ return unicodedata.normalize(normalization, text)
+
+ def fold_to_ascii(self, text):
+ """Convert non-ASCII characters to closest ASCII equivalent.
+
+ .. versionadded:: 1.3
+
+ .. note:: This only works for a subset of European languages.
+
+ :param text: text to convert
+ :type text: ``unicode``
+ :returns: text containing only ASCII characters
+ :rtype: ``unicode``
+
+ """
+ if isascii(text):
+ return text
+ text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text])
+ return unicode(unicodedata.normalize('NFKD',
+ text).encode('ascii', 'ignore'))
+
+ def dumbify_punctuation(self, text):
+ """Convert non-ASCII punctuation to closest ASCII equivalent.
+
+ This method replaces "smart" quotes and n- or m-dashes with their
+ workaday ASCII equivalents. This method is currently not used
+ internally, but exists as a helper method for workflow authors.
+
+ .. versionadded: 1.9.7
+
+ :param text: text to convert
+ :type text: ``unicode``
+ :returns: text with only ASCII punctuation
+ :rtype: ``unicode``
+
+ """
+ if isascii(text):
+ return text
+
+ text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text])
+ return text
+
+ def _delete_directory_contents(self, dirpath, filter_func):
+ """Delete all files in a directory.
+
+ :param dirpath: path to directory to clear
+ :type dirpath: ``unicode`` or ``str``
+ :param filter_func function to determine whether a file shall be
+ deleted or not.
+ :type filter_func ``callable``
+
+ """
+ if os.path.exists(dirpath):
+ for filename in os.listdir(dirpath):
+ if not filter_func(filename):
+ continue
+ path = os.path.join(dirpath, filename)
+ if os.path.isdir(path):
+ shutil.rmtree(path)
+ else:
+ os.unlink(path)
+ self.logger.debug('deleted : %r', path)
+
+ def _load_info_plist(self):
+ """Load workflow info from ``info.plist``."""
+ # info.plist should be in the directory above this one
+ self._info = plistlib.readPlist(self.workflowfile('info.plist'))
+ self._info_loaded = True
+
+ def _create(self, dirpath):
+ """Create directory `dirpath` if it doesn't exist.
+
+ :param dirpath: path to directory
+ :type dirpath: ``unicode``
+ :returns: ``dirpath`` argument
+ :rtype: ``unicode``
+
+ """
+ if not os.path.exists(dirpath):
+ os.makedirs(dirpath)
+ return dirpath
+
+ def _call_security(self, action, service, account, *args):
+ """Call ``security`` CLI program that provides access to keychains.
+
+ May raise `PasswordNotFound`, `PasswordExists` or `KeychainError`
+ exceptions (the first two are subclasses of `KeychainError`).
+
+ :param action: The ``security`` action to call, e.g.
+ ``add-generic-password``
+ :type action: ``unicode``
+ :param service: Name of the service.
+ :type service: ``unicode``
+ :param account: name of the account the password is for, e.g.
+ "Pinboard"
+ :type account: ``unicode``
+ :param password: the password to secure
+ :type password: ``unicode``
+ :param *args: list of command line arguments to be passed to
+ ``security``
+ :type *args: `list` or `tuple`
+ :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a
+ ``unicode`` string.
+ :rtype: `tuple` (`int`, ``unicode``)
+
+ """
+ cmd = ['security', action, '-s', service, '-a', account] + list(args)
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT)
+ stdout, _ = p.communicate()
+ if p.returncode == 44: # password does not exist
+ raise PasswordNotFound()
+ elif p.returncode == 45: # password already exists
+ raise PasswordExists()
+ elif p.returncode > 0:
+ err = KeychainError('Unknown Keychain error : %s' % stdout)
+ err.retcode = p.returncode
+ raise err
+ return stdout.strip().decode('utf-8')
diff --git a/workflow/workflow.pyc b/workflow/workflow.pyc
new file mode 100644
index 0000000..6c6fcf1
Binary files /dev/null and b/workflow/workflow.pyc differ
diff --git a/workflow/workflow3.py b/workflow/workflow3.py
new file mode 100644
index 0000000..a6c07c9
--- /dev/null
+++ b/workflow/workflow3.py
@@ -0,0 +1,720 @@
+# encoding: utf-8
+#
+# Copyright (c) 2016 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2016-06-25
+#
+
+"""An Alfred 3-only version of :class:`~workflow.Workflow`.
+
+:class:`~workflow.Workflow3` supports Alfred 3's new features, such as
+setting :ref:`workflow-variables` and
+:class:`the more advanced modifiers ` supported by Alfred 3.
+
+In order for the feedback mechanism to work correctly, it's important
+to create :class:`Item3` and :class:`Modifier` objects via the
+:meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods
+respectively. If you instantiate :class:`Item3` or :class:`Modifier`
+objects directly, the current :class:`Workflow3` object won't be aware
+of them, and they won't be sent to Alfred when you call
+:meth:`Workflow3.send_feedback()`.
+
+"""
+
+from __future__ import print_function, unicode_literals, absolute_import
+
+import json
+import os
+import sys
+
+from .workflow import ICON_WARNING, Workflow
+
+
+class Variables(dict):
+ """Workflow variables for Run Script actions.
+
+ .. versionadded: 1.26
+
+ This class allows you to set workflow variables from
+ Run Script actions.
+
+ It is a subclass of :class:`dict`.
+
+ >>> v = Variables(username='deanishe', password='hunter2')
+ >>> v.arg = u'output value'
+ >>> print(v)
+
+ See :ref:`variables-run-script` in the User Guide for more
+ information.
+
+ Args:
+ arg (unicode, optional): Main output/``{query}``.
+ **variables: Workflow variables to set.
+
+
+ Attributes:
+ arg (unicode): Output value (``{query}``).
+ config (dict): Configuration for downstream workflow element.
+
+ """
+
+ def __init__(self, arg=None, **variables):
+ """Create a new `Variables` object."""
+ self.arg = arg
+ self.config = {}
+ super(Variables, self).__init__(**variables)
+
+ @property
+ def obj(self):
+ """Return ``alfredworkflow`` `dict`."""
+ o = {}
+ if self:
+ d2 = {}
+ for k, v in self.items():
+ d2[k] = v
+ o['variables'] = d2
+
+ if self.config:
+ o['config'] = self.config
+
+ if self.arg is not None:
+ o['arg'] = self.arg
+
+ return {'alfredworkflow': o}
+
+ def __unicode__(self):
+ """Convert to ``alfredworkflow`` JSON object.
+
+ Returns:
+ unicode: ``alfredworkflow`` JSON object
+
+ """
+ if not self and not self.config:
+ if self.arg:
+ return self.arg
+ else:
+ return u''
+
+ return json.dumps(self.obj)
+
+ def __str__(self):
+ """Convert to ``alfredworkflow`` JSON object.
+
+ Returns:
+ str: UTF-8 encoded ``alfredworkflow`` JSON object
+
+ """
+ return unicode(self).encode('utf-8')
+
+
+class Modifier(object):
+ """Modify :class:`Item3` arg/icon/variables when modifier key is pressed.
+
+ Don't use this class directly (as it won't be associated with any
+ :class:`Item3`), but rather use :meth:`Item3.add_modifier()`
+ to add modifiers to results.
+
+ >>> it = wf.add_item('Title', 'Subtitle', valid=True)
+ >>> it.setvar('name', 'default')
+ >>> m = it.add_modifier('cmd')
+ >>> m.setvar('name', 'alternate')
+
+ See :ref:`workflow-variables` in the User Guide for more information
+ and :ref:`example usage `.
+
+ Args:
+ key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
+ subtitle (unicode, optional): Override default subtitle.
+ arg (unicode, optional): Argument to pass for this modifier.
+ valid (bool, optional): Override item's validity.
+ icon (unicode, optional): Filepath/UTI of icon to use
+ icontype (unicode, optional): Type of icon. See
+ :meth:`Workflow.add_item() `
+ for valid values.
+
+ Attributes:
+ arg (unicode): Arg to pass to following action.
+ config (dict): Configuration for a downstream element, such as
+ a File Filter.
+ icon (unicode): Filepath/UTI of icon.
+ icontype (unicode): Type of icon. See
+ :meth:`Workflow.add_item() `
+ for valid values.
+ key (unicode): Modifier key (see above).
+ subtitle (unicode): Override item subtitle.
+ valid (bool): Override item validity.
+ variables (dict): Workflow variables set by this modifier.
+
+ """
+
+ def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None,
+ icontype=None):
+ """Create a new :class:`Modifier`.
+
+ Don't use this class directly (as it won't be associated with any
+ :class:`Item3`), but rather use :meth:`Item3.add_modifier()`
+ to add modifiers to results.
+
+ Args:
+ key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
+ subtitle (unicode, optional): Override default subtitle.
+ arg (unicode, optional): Argument to pass for this modifier.
+ valid (bool, optional): Override item's validity.
+ icon (unicode, optional): Filepath/UTI of icon to use
+ icontype (unicode, optional): Type of icon. See
+ :meth:`Workflow.add_item() `
+ for valid values.
+
+ """
+ self.key = key
+ self.subtitle = subtitle
+ self.arg = arg
+ self.valid = valid
+ self.icon = icon
+ self.icontype = icontype
+
+ self.config = {}
+ self.variables = {}
+
+ def setvar(self, name, value):
+ """Set a workflow variable for this Item.
+
+ Args:
+ name (unicode): Name of variable.
+ value (unicode): Value of variable.
+
+ """
+ self.variables[name] = value
+
+ def getvar(self, name, default=None):
+ """Return value of workflow variable for ``name`` or ``default``.
+
+ Args:
+ name (unicode): Variable name.
+ default (None, optional): Value to return if variable is unset.
+
+ Returns:
+ unicode or ``default``: Value of variable if set or ``default``.
+
+ """
+ return self.variables.get(name, default)
+
+ @property
+ def obj(self):
+ """Modifier formatted for JSON serialization for Alfred 3.
+
+ Returns:
+ dict: Modifier for serializing to JSON.
+
+ """
+ o = {}
+
+ if self.subtitle is not None:
+ o['subtitle'] = self.subtitle
+
+ if self.arg is not None:
+ o['arg'] = self.arg
+
+ if self.valid is not None:
+ o['valid'] = self.valid
+
+ if self.variables:
+ o['variables'] = self.variables
+
+ if self.config:
+ o['config'] = self.config
+
+ icon = self._icon()
+ if icon:
+ o['icon'] = icon
+
+ return o
+
+ def _icon(self):
+ """Return `icon` object for item.
+
+ Returns:
+ dict: Mapping for item `icon` (may be empty).
+
+ """
+ icon = {}
+ if self.icon is not None:
+ icon['path'] = self.icon
+
+ if self.icontype is not None:
+ icon['type'] = self.icontype
+
+ return icon
+
+
+class Item3(object):
+ """Represents a feedback item for Alfred 3.
+
+ Generates Alfred-compliant JSON for a single item.
+
+ Don't use this class directly (as it then won't be associated with
+ any :class:`Workflow3 ` object), but rather use
+ :meth:`Workflow3.add_item() `.
+ See :meth:`~workflow.Workflow3.add_item` for details of arguments.
+
+ """
+
+ def __init__(self, title, subtitle='', arg=None, autocomplete=None,
+ match=None, valid=False, uid=None, icon=None, icontype=None,
+ type=None, largetext=None, copytext=None, quicklookurl=None):
+ """Create a new :class:`Item3` object.
+
+ Use same arguments as for
+ :class:`Workflow.Item `.
+
+ Argument ``subtitle_modifiers`` is not supported.
+
+ """
+ self.title = title
+ self.subtitle = subtitle
+ self.arg = arg
+ self.autocomplete = autocomplete
+ self.match = match
+ self.valid = valid
+ self.uid = uid
+ self.icon = icon
+ self.icontype = icontype
+ self.type = type
+ self.quicklookurl = quicklookurl
+ self.largetext = largetext
+ self.copytext = copytext
+
+ self.modifiers = {}
+
+ self.config = {}
+ self.variables = {}
+
+ def setvar(self, name, value):
+ """Set a workflow variable for this Item.
+
+ Args:
+ name (unicode): Name of variable.
+ value (unicode): Value of variable.
+
+ """
+ self.variables[name] = value
+
+ def getvar(self, name, default=None):
+ """Return value of workflow variable for ``name`` or ``default``.
+
+ Args:
+ name (unicode): Variable name.
+ default (None, optional): Value to return if variable is unset.
+
+ Returns:
+ unicode or ``default``: Value of variable if set or ``default``.
+
+ """
+ return self.variables.get(name, default)
+
+ def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None,
+ icontype=None):
+ """Add alternative values for a modifier key.
+
+ Args:
+ key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"``
+ subtitle (unicode, optional): Override item subtitle.
+ arg (unicode, optional): Input for following action.
+ valid (bool, optional): Override item validity.
+ icon (unicode, optional): Filepath/UTI of icon.
+ icontype (unicode, optional): Type of icon. See
+ :meth:`Workflow.add_item() `
+ for valid values.
+
+ Returns:
+ Modifier: Configured :class:`Modifier`.
+
+ """
+ mod = Modifier(key, subtitle, arg, valid, icon, icontype)
+
+ # Add Item variables to Modifier
+ mod.variables.update(self.variables)
+
+ self.modifiers[key] = mod
+
+ return mod
+
+ @property
+ def obj(self):
+ """Item formatted for JSON serialization.
+
+ Returns:
+ dict: Data suitable for Alfred 3 feedback.
+
+ """
+ # Required values
+ o = {
+ 'title': self.title,
+ 'subtitle': self.subtitle,
+ 'valid': self.valid,
+ }
+
+ # Optional values
+ if self.arg is not None:
+ o['arg'] = self.arg
+
+ if self.autocomplete is not None:
+ o['autocomplete'] = self.autocomplete
+
+ if self.match is not None:
+ o['match'] = self.match
+
+ if self.uid is not None:
+ o['uid'] = self.uid
+
+ if self.type is not None:
+ o['type'] = self.type
+
+ if self.quicklookurl is not None:
+ o['quicklookurl'] = self.quicklookurl
+
+ if self.variables:
+ o['variables'] = self.variables
+
+ if self.config:
+ o['config'] = self.config
+
+ # Largetype and copytext
+ text = self._text()
+ if text:
+ o['text'] = text
+
+ icon = self._icon()
+ if icon:
+ o['icon'] = icon
+
+ # Modifiers
+ mods = self._modifiers()
+ if mods:
+ o['mods'] = mods
+
+ return o
+
+ def _icon(self):
+ """Return `icon` object for item.
+
+ Returns:
+ dict: Mapping for item `icon` (may be empty).
+
+ """
+ icon = {}
+ if self.icon is not None:
+ icon['path'] = self.icon
+
+ if self.icontype is not None:
+ icon['type'] = self.icontype
+
+ return icon
+
+ def _text(self):
+ """Return `largetext` and `copytext` object for item.
+
+ Returns:
+ dict: `text` mapping (may be empty)
+
+ """
+ text = {}
+ if self.largetext is not None:
+ text['largetype'] = self.largetext
+
+ if self.copytext is not None:
+ text['copy'] = self.copytext
+
+ return text
+
+ def _modifiers(self):
+ """Build `mods` dictionary for JSON feedback.
+
+ Returns:
+ dict: Modifier mapping or `None`.
+
+ """
+ if self.modifiers:
+ mods = {}
+ for k, mod in self.modifiers.items():
+ mods[k] = mod.obj
+
+ return mods
+
+ return None
+
+
+class Workflow3(Workflow):
+ """Workflow class that generates Alfred 3 feedback.
+
+ It is a subclass of :class:`~workflow.Workflow` and most of its
+ methods are documented there.
+
+ Attributes:
+ item_class (class): Class used to generate feedback items.
+ variables (dict): Top level workflow variables.
+
+ """
+
+ item_class = Item3
+
+ def __init__(self, **kwargs):
+ """Create a new :class:`Workflow3` object.
+
+ See :class:`~workflow.Workflow` for documentation.
+
+ """
+ Workflow.__init__(self, **kwargs)
+ self.variables = {}
+ self._rerun = 0
+ # Get session ID from environment if present
+ self._session_id = os.getenv('_WF_SESSION_ID') or None
+ if self._session_id:
+ self.setvar('_WF_SESSION_ID', self._session_id)
+
+ @property
+ def _default_cachedir(self):
+ """Alfred 3's default cache directory."""
+ return os.path.join(
+ os.path.expanduser(
+ '~/Library/Caches/com.runningwithcrayons.Alfred-3/'
+ 'Workflow Data/'),
+ self.bundleid)
+
+ @property
+ def _default_datadir(self):
+ """Alfred 3's default data directory."""
+ return os.path.join(os.path.expanduser(
+ '~/Library/Application Support/Alfred 3/Workflow Data/'),
+ self.bundleid)
+
+ @property
+ def rerun(self):
+ """How often (in seconds) Alfred should re-run the Script Filter."""
+ return self._rerun
+
+ @rerun.setter
+ def rerun(self, seconds):
+ """Interval at which Alfred should re-run the Script Filter.
+
+ Args:
+ seconds (int): Interval between runs.
+ """
+ self._rerun = seconds
+
+ @property
+ def session_id(self):
+ """A unique session ID every time the user uses the workflow.
+
+ .. versionadded:: 1.25
+
+ The session ID persists while the user is using this workflow.
+ It expires when the user runs a different workflow or closes
+ Alfred.
+
+ """
+ if not self._session_id:
+ from uuid import uuid4
+ self._session_id = uuid4().hex
+ self.setvar('_WF_SESSION_ID', self._session_id)
+
+ return self._session_id
+
+ def setvar(self, name, value, persist=False):
+ """Set a "global" workflow variable.
+
+ .. versionchanged:: 1.33
+
+ These variables are always passed to downstream workflow objects.
+
+ If you have set :attr:`rerun`, these variables are also passed
+ back to the script when Alfred runs it again.
+
+ Args:
+ name (unicode): Name of variable.
+ value (unicode): Value of variable.
+ persist (bool, optional): Also save variable to ``info.plist``?
+
+ """
+ self.variables[name] = value
+ if persist:
+ from .util import set_config
+ set_config(name, value, self.bundleid)
+ self.logger.debug('saved variable %r with value %r to info.plist',
+ name, value)
+
+ def getvar(self, name, default=None):
+ """Return value of workflow variable for ``name`` or ``default``.
+
+ Args:
+ name (unicode): Variable name.
+ default (None, optional): Value to return if variable is unset.
+
+ Returns:
+ unicode or ``default``: Value of variable if set or ``default``.
+
+ """
+ return self.variables.get(name, default)
+
+ def add_item(self, title, subtitle='', arg=None, autocomplete=None,
+ valid=False, uid=None, icon=None, icontype=None, type=None,
+ largetext=None, copytext=None, quicklookurl=None, match=None):
+ """Add an item to be output to Alfred.
+
+ Args:
+ match (unicode, optional): If you have "Alfred filters results"
+ turned on for your Script Filter, Alfred (version 3.5 and
+ above) will filter against this field, not ``title``.
+
+ See :meth:`Workflow.add_item() ` for
+ the main documentation and other parameters.
+
+ The key difference is that this method does not support the
+ ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
+ method instead on the returned item instead.
+
+ Returns:
+ Item3: Alfred feedback item.
+
+ """
+ item = self.item_class(title, subtitle, arg, autocomplete,
+ match, valid, uid, icon, icontype, type,
+ largetext, copytext, quicklookurl)
+
+ # Add variables to child item
+ item.variables.update(self.variables)
+
+ self._items.append(item)
+ return item
+
+ @property
+ def _session_prefix(self):
+ """Filename prefix for current session."""
+ return '_wfsess-{0}-'.format(self.session_id)
+
+ def _mk_session_name(self, name):
+ """New cache name/key based on session ID."""
+ return self._session_prefix + name
+
+ def cache_data(self, name, data, session=False):
+ """Cache API with session-scoped expiry.
+
+ .. versionadded:: 1.25
+
+ Args:
+ name (str): Cache key
+ data (object): Data to cache
+ session (bool, optional): Whether to scope the cache
+ to the current session.
+
+ ``name`` and ``data`` are the same as for the
+ :meth:`~workflow.Workflow.cache_data` method on
+ :class:`~workflow.Workflow`.
+
+ If ``session`` is ``True``, then ``name`` is prefixed
+ with :attr:`session_id`.
+
+ """
+ if session:
+ name = self._mk_session_name(name)
+
+ return super(Workflow3, self).cache_data(name, data)
+
+ def cached_data(self, name, data_func=None, max_age=60, session=False):
+ """Cache API with session-scoped expiry.
+
+ .. versionadded:: 1.25
+
+ Args:
+ name (str): Cache key
+ data_func (callable): Callable that returns fresh data. It
+ is called if the cache has expired or doesn't exist.
+ max_age (int): Maximum allowable age of cache in seconds.
+ session (bool, optional): Whether to scope the cache
+ to the current session.
+
+ ``name``, ``data_func`` and ``max_age`` are the same as for the
+ :meth:`~workflow.Workflow.cached_data` method on
+ :class:`~workflow.Workflow`.
+
+ If ``session`` is ``True``, then ``name`` is prefixed
+ with :attr:`session_id`.
+
+ """
+ if session:
+ name = self._mk_session_name(name)
+
+ return super(Workflow3, self).cached_data(name, data_func, max_age)
+
+ def clear_session_cache(self, current=False):
+ """Remove session data from the cache.
+
+ .. versionadded:: 1.25
+ .. versionchanged:: 1.27
+
+ By default, data belonging to the current session won't be
+ deleted. Set ``current=True`` to also clear current session.
+
+ Args:
+ current (bool, optional): If ``True``, also remove data for
+ current session.
+
+ """
+ def _is_session_file(filename):
+ if current:
+ return filename.startswith('_wfsess-')
+ return filename.startswith('_wfsess-') \
+ and not filename.startswith(self._session_prefix)
+
+ self.clear_cache(_is_session_file)
+
+ @property
+ def obj(self):
+ """Feedback formatted for JSON serialization.
+
+ Returns:
+ dict: Data suitable for Alfred 3 feedback.
+
+ """
+ items = []
+ for item in self._items:
+ items.append(item.obj)
+
+ o = {'items': items}
+ if self.variables:
+ o['variables'] = self.variables
+ if self.rerun:
+ o['rerun'] = self.rerun
+ return o
+
+ def warn_empty(self, title, subtitle=u'', icon=None):
+ """Add a warning to feedback if there are no items.
+
+ .. versionadded:: 1.31
+
+ Add a "warning" item to Alfred feedback if no other items
+ have been added. This is a handy shortcut to prevent Alfred
+ from showing its fallback searches, which is does if no
+ items are returned.
+
+ Args:
+ title (unicode): Title of feedback item.
+ subtitle (unicode, optional): Subtitle of feedback item.
+ icon (str, optional): Icon for feedback item. If not
+ specified, ``ICON_WARNING`` is used.
+
+ Returns:
+ Item3: Newly-created item.
+ """
+ if len(self._items):
+ return
+
+ icon = icon or ICON_WARNING
+ return self.add_item(title, subtitle, icon=icon)
+
+ def send_feedback(self):
+ """Print stored items to console/Alfred as JSON."""
+ json.dump(self.obj, sys.stdout)
+ sys.stdout.flush()
diff --git a/workflow/workflow3.pyc b/workflow/workflow3.pyc
new file mode 100644
index 0000000..5ae3425
Binary files /dev/null and b/workflow/workflow3.pyc differ
diff --git a/workflow/workflow4.pyc b/workflow/workflow4.pyc
new file mode 100644
index 0000000..5ae3425
Binary files /dev/null and b/workflow/workflow4.pyc differ