diff --git a/info.plist b/info.plist
index 8e2be10..7788f20 100644
--- a/info.plist
+++ b/info.plist
@@ -8,6 +8,19 @@
Productivity
connections
+ 2AD95D17-931E-4ACD-BF8D-3AFB554DDDB4
+
+
+ destinationuid
+ B992DF40-31A6-43F3-BA6B-6A6AB9F02812
+ modifiers
+ 0
+ modifiersubtext
+
+ vitoclose
+
+
+
2B9F3B61-0101-4A38-817C-B7A8A80E934E
@@ -17,6 +30,8 @@
0
modifiersubtext
+ vitoclose
+
destinationuid
@@ -25,6 +40,8 @@
1048576
modifiersubtext
+ vitoclose
+
2E604D0E-A789-4B5F-B5F8-DFE9A4E5F80B
@@ -36,6 +53,8 @@
0
modifiersubtext
+ vitoclose
+
310448B8-B685-469A-8C78-DA99E122316B
@@ -47,6 +66,8 @@
0
modifiersubtext
+ vitoclose
+
9346E14B-660C-4679-BF03-D20C6E5A60BB
@@ -58,6 +79,8 @@
0
modifiersubtext
+ vitoclose
+
E39428C3-EE89-4D16-836B-1D31A4974660
@@ -69,6 +92,8 @@
0
modifiersubtext
+ vitoclose
+
@@ -89,17 +114,21 @@
clipboardtext
{query}
+ transient
+
type
alfred.workflow.output.clipboard
uid
B992DF40-31A6-43F3-BA6B-6A6AB9F02812
version
- 0
+ 1
config
+ alfredfiltersresults
+
argumenttype
1
escaping
@@ -114,8 +143,16 @@
0
queuemode
1
+ runningsubtext
+
script
python main.py parse {query}
+ scriptargtype
+ 0
+ scriptfile
+
+ subtext
+
title
Example: moment 1364302555 start of hour +3 day
type
@@ -128,11 +165,13 @@
uid
E39428C3-EE89-4D16-836B-1D31A4974660
version
- 0
+ 1
config
+ alfredfiltersresults
+
argumenttype
2
escaping
@@ -147,8 +186,16 @@
0
queuemode
2
+ runningsubtext
+
script
python main.py parse
+ scriptargtype
+ 0
+ scriptfile
+
+ subtext
+
title
current time
type
@@ -161,48 +208,60 @@
uid
310448B8-B685-469A-8C78-DA99E122316B
version
- 0
+ 1
config
- concurrently
+ lastpathcomponent
- escaping
- 102
- script
- python main.py addFormat "{query}"
- type
- 0
+ onlyshowifquerypopulated
+
+ removeextension
+
+ text
+
+ title
+ Moment format has been added.
type
- alfred.workflow.action.script
+ alfred.workflow.output.notification
uid
- 9346E14B-660C-4679-BF03-D20C6E5A60BB
+ 1498E894-B5BA-4724-8CD6-118110D985CB
version
0
config
+ alfredfiltersresults
+
argumenttype
1
escaping
- 102
+ 36
keyword
- format-moment
+ m
queuedelaycustom
- 3
+ 1
queuedelayimmediatelyinitially
-
+
queuedelaymode
0
queuemode
1
+ runningsubtext
+
script
- python main.py format {query}
+ python main.py parse {query}
+ scriptargtype
+ 0
+ scriptfile
+
+ subtext
+
title
- manage your time formats
+ Example: m 1364302555 start of hour +3 day
type
0
withspace
@@ -211,9 +270,32 @@
type
alfred.workflow.input.scriptfilter
uid
- 2B9F3B61-0101-4A38-817C-B7A8A80E934E
+ 2AD95D17-931E-4ACD-BF8D-3AFB554DDDB4
version
- 0
+ 1
+
+
+ config
+
+ concurrently
+
+ escaping
+ 102
+ script
+ python main.py addFormat "{query}"
+ scriptargtype
+ 0
+ scriptfile
+
+ type
+ 0
+
+ type
+ alfred.workflow.action.script
+ uid
+ 9346E14B-660C-4679-BF03-D20C6E5A60BB
+ version
+ 1
config
@@ -222,19 +304,17 @@
onlyshowifquerypopulated
- output
- 0
removeextension
- sticky
-
+ text
+
title
- Moment format has been added.
+ Moment format has been deleted.
type
alfred.workflow.output.notification
uid
- 1498E894-B5BA-4724-8CD6-118110D985CB
+ F62CA940-5FB0-452A-8ECD-B6F3146B5182
version
0
@@ -247,6 +327,10 @@
102
script
python main.py delFormat "{query}"
+ scriptargtype
+ 0
+ scriptfile
+
type
0
@@ -255,30 +339,50 @@
uid
2E604D0E-A789-4B5F-B5F8-DFE9A4E5F80B
version
- 0
+ 1
config
- lastpathcomponent
+ alfredfiltersresults
- onlyshowifquerypopulated
-
- output
+ argumenttype
+ 1
+ escaping
+ 102
+ keyword
+ format-moment
+ queuedelaycustom
+ 3
+ queuedelayimmediatelyinitially
+
+ queuedelaymode
0
- removeextension
-
- sticky
-
+ queuemode
+ 1
+ runningsubtext
+
+ script
+ python main.py format {query}
+ scriptargtype
+ 0
+ scriptfile
+
+ subtext
+
title
- Moment format has been deleted.
+ manage your time formats
+ type
+ 0
+ withspace
+
type
- alfred.workflow.output.notification
+ alfred.workflow.input.scriptfilter
uid
- F62CA940-5FB0-452A-8ECD-B6F3146B5182
+ 2B9F3B61-0101-4A38-817C-B7A8A80E934E
version
- 0
+ 1
readme
@@ -295,43 +399,66 @@ https://github.com/nikipore/alfred-python
1498E894-B5BA-4724-8CD6-118110D985CB
+ xpos
+ 450
+ ypos
+ 250
+
+ 2AD95D17-931E-4ACD-BF8D-3AFB554DDDB4
+
+ xpos
+ 50
ypos
- 320
+ 250
2B9F3B61-0101-4A38-817C-B7A8A80E934E
+ xpos
+ 50
ypos
- 320
+ 480
2E604D0E-A789-4B5F-B5F8-DFE9A4E5F80B
+ xpos
+ 250
ypos
- 440
+ 480
310448B8-B685-469A-8C78-DA99E122316B
+ xpos
+ 50
ypos
- 200
+ 130
9346E14B-660C-4679-BF03-D20C6E5A60BB
+ xpos
+ 250
ypos
- 320
+ 360
B992DF40-31A6-43F3-BA6B-6A6AB9F02812
+ xpos
+ 450
ypos
- 80
+ 10
E39428C3-EE89-4D16-836B-1D31A4974660
+ xpos
+ 50
ypos
- 80
+ 10
F62CA940-5FB0-452A-8ECD-B6F3146B5182
+ xpos
+ 450
ypos
- 440
+ 370
webaddress
diff --git a/version b/version
index e8ea05d..c813fe1 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-1.2.4
+1.2.5
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
index 5de1a96..2c4f8c0 100644
--- a/workflow/__init__.py
+++ b/workflow/__init__.py
@@ -8,22 +8,13 @@
# Created on 2014-02-15
#
-"""
-A Python helper library for `Alfred 2 `_ Workflow
-authors.
-"""
+"""A helper library for `Alfred `_ workflows."""
import os
-__title__ = 'Alfred-Workflow'
-__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
-__author__ = 'Dean Jackson'
-__licence__ = 'MIT'
-__copyright__ = 'Copyright 2014 Dean Jackson'
-
-
# Workflow objects
from .workflow import Workflow, manager
+from .workflow3 import Variables, Workflow3
# Exceptions
from .workflow import PasswordNotFound, KeychainError
@@ -68,8 +59,17 @@
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',
diff --git a/workflow/background.py b/workflow/background.py
index b3327ca..a382000 100644
--- a/workflow/background.py
+++ b/workflow/background.py
@@ -9,7 +9,12 @@
#
"""
-Run background tasks
+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
@@ -33,8 +38,12 @@ def wf():
return _wf
+def _log():
+ return wf().logger
+
+
def _arg_cache(name):
- """Return path to pickle cache file for arguments
+ """Return path to pickle cache file for arguments.
:param name: name of task
:type name: ``unicode``
@@ -42,12 +51,11 @@ def _arg_cache(name):
:rtype: ``unicode`` filepath
"""
-
- return wf().cachefile('{0}.argcache'.format(name))
+ return wf().cachefile(name + '.argcache')
def _pid_file(name):
- """Return path to PID file for ``name``
+ """Return path to PID file for ``name``.
:param name: name of task
:type name: ``unicode``
@@ -55,19 +63,18 @@ def _pid_file(name):
:rtype: ``unicode`` filepath
"""
-
- return wf().cachefile('{0}.pid'.format(name))
+ return wf().cachefile(name + '.pid')
def _process_exists(pid):
- """Check if a process with PID ``pid`` exists
+ """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
@@ -76,13 +83,12 @@ def _process_exists(pid):
def is_running(name):
- """
- Test whether task is running under ``name``
+ """Test whether task ``name`` is currently running.
:param name: name of task
- :type name: ``unicode``
+ :type name: unicode
:returns: ``True`` if task with name ``name`` is running, else ``False``
- :rtype: ``Boolean``
+ :rtype: bool
"""
pidfile = _pid_file(name)
@@ -113,34 +119,30 @@ def _background(stdin='/dev/null', stdout='/dev/null',
:type stderr: filepath
"""
+ def _fork_and_exit_parent(errmsg):
+ try:
+ pid = os.fork()
+ if pid > 0:
+ os._exit(0)
+ except OSError as err:
+ _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror)
+ raise err
# Do first fork.
- try:
- pid = os.fork()
- if pid > 0:
- sys.exit(0) # Exit first parent.
- except OSError as e:
- wf().logger.critical("fork #1 failed: ({0:d}) {1}".format(
- e.errno, e.strerror))
- sys.exit(1)
+ _fork_and_exit_parent('fork #1 failed')
+
# Decouple from parent environment.
os.chdir(wf().workflowdir)
- os.umask(0)
os.setsid()
+
# Do second fork.
- try:
- pid = os.fork()
- if pid > 0:
- sys.exit(0) # Exit second parent.
- except OSError as e:
- wf().logger.critical("fork #2 failed: ({0:d}) {1}".format(
- e.errno, e.strerror))
- sys.exit(1)
+ _fork_and_exit_parent('fork #2 failed')
+
# Now I am a daemon!
# Redirect standard file descriptors.
- si = file(stdin, 'r', 0)
- so = file(stdout, 'a+', 0)
- se = file(stderr, 'a+', 0)
+ 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'):
@@ -150,15 +152,14 @@ def _background(stdin='/dev/null', stdout='/dev/null',
def run_in_background(name, args, **kwargs):
- """Pickle arguments to cache file, then call this script again via
- :func:`subprocess.call`.
+ r"""Cache arguments then call this script again via :func:`subprocess.call`.
:param name: name of task
- :type name: ``unicode``
+ :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``
+ :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
@@ -175,9 +176,8 @@ def run_in_background(name, args, **kwargs):
return immediately and will not run the specified command.
"""
-
if is_running(name):
- wf().logger.info('Task `{0}` is already running'.format(name))
+ _log().info('[%s] job already running', name)
return
argcache = _arg_cache(name)
@@ -185,30 +185,31 @@ def run_in_background(name, args, **kwargs):
# Cache arguments
with open(argcache, 'wb') as file_obj:
pickle.dump({'args': args, 'kwargs': kwargs}, file_obj)
- wf().logger.debug('Command arguments cached to `{0}`'.format(argcache))
+ _log().debug('[%s] command cached: %s', name, argcache)
# Call this script
cmd = ['/usr/bin/python', __file__, name]
- wf().logger.debug('Calling {0!r} ...'.format(cmd))
+ _log().debug('[%s] passing job to background runner: %r', name, cmd)
retcode = subprocess.call(cmd)
if retcode: # pragma: no cover
- wf().logger.error('Failed to call task in background')
+ _log().error('[%s] background runner failed with %d', retcode)
else:
- wf().logger.debug('Executing task `{0}` in background...'.format(name))
+ _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
+ :meth:`subprocess.call` with cached arguments.
"""
-
+ log = wf.logger
name = wf.args[0]
argcache = _arg_cache(name)
if not os.path.exists(argcache):
- wf.logger.critical('No arg cache found : {0!r}'.format(argcache))
+ log.critical('[%s] command cache not found: %r', name, argcache)
return 1
# Load cached arguments
@@ -229,23 +230,21 @@ def main(wf): # pragma: no cover
# Write PID to file
with open(pidfile, 'wb') as file_obj:
- file_obj.write('{0}'.format(os.getpid()))
+ file_obj.write(str(os.getpid()))
# Run the command
try:
- wf.logger.debug('Task `{0}` running'.format(name))
- wf.logger.debug('cmd : {0!r}'.format(args))
+ log.debug('[%s] running command: %r', name, args)
retcode = subprocess.call(args, **kwargs)
if retcode:
- wf.logger.error('Command failed with [{0}] : {1!r}'.format(
- retcode, args))
+ log.error('[%s] command failed with status %d', name, retcode)
finally:
if os.path.exists(pidfile):
os.unlink(pidfile)
- wf.logger.debug('Task `{0}` finished'.format(name))
+ log.debug('[%s] job complete', name)
if __name__ == '__main__': # pragma: no cover
diff --git a/workflow/notify.py b/workflow/notify.py
index 24fdcee..4542c78 100644
--- a/workflow/notify.py
+++ b/workflow/notify.py
@@ -11,7 +11,7 @@
# TODO: Exclude this module from test and code coverage in py2.6
"""
-Post notifications via the OS X Notification Center. This feature
+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.
@@ -60,10 +60,10 @@
def wf():
- """Return `Workflow` object for this module.
+ """Return Workflow object for this module.
Returns:
- workflow.Workflow: `Workflow` object for current workflow.
+ workflow.Workflow: Workflow object for current workflow.
"""
global _wf
if _wf is None:
@@ -87,7 +87,7 @@ def notifier_program():
"""Return path to notifier applet executable.
Returns:
- unicode: Path to Notify.app `applet` executable.
+ unicode: Path to Notify.app ``applet`` executable.
"""
return wf().datafile('Notify.app/Contents/MacOS/applet')
@@ -96,13 +96,13 @@ def notifier_icon_path():
"""Return path to icon file in installed Notify.app.
Returns:
- unicode: Path to `applet.icns` within the app bundle.
+ 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.
+ """Extract ``Notify.app`` from the workflow to data directory.
Changes the bundle ID of the installed app and gives it the
workflow's icon.
@@ -111,13 +111,13 @@ def install_notifier():
destdir = wf().datadir
app_path = os.path.join(destdir, 'Notify.app')
n = notifier_program()
- log().debug("Installing Notify.app to %r ...", destdir)
+ 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 {0!r}.".format(destdir))
+ assert os.path.exists(n), \
+ 'Notify.app could not be installed in %s' % destdir
# Replace applet icon
icon = notifier_icon_path()
@@ -132,7 +132,7 @@ def install_notifier():
# 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: nocover
+ if sys.version_info >= (2, 7): # pragma: no cover
from AppKit import NSWorkspace, NSImage
ws = NSWorkspace.sharedWorkspace()
@@ -144,29 +144,29 @@ def install_notifier():
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 {0!r}'.format(bundle_id))
+ 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.
+ """Coerce ``sound`` to valid sound name.
- Returns `None` for invalid sounds. Sound names can be found
- in `System Preferences > Sound > Sound Effects`.
+ 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`.
+ 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 OS X 10.11
+ # Title-case is correct for all system sounds as of macOS 10.11
return sound.title()
return None
@@ -180,10 +180,10 @@ def notify(title='', text='', sound=None):
sound (str, optional): Name of sound to play.
Raises:
- ValueError: Raised if both `title` and `text` are empty.
+ ValueError: Raised if both ``title`` and ``text`` are empty.
Returns:
- bool: `True` if notification was posted, else `False`.
+ bool: ``True`` if notification was posted, else ``False``.
"""
if title == text == '':
raise ValueError('Empty notification')
@@ -210,7 +210,7 @@ def notify(title='', text='', sound=None):
def convert_image(inpath, outpath, size):
- """Convert an image file using `sips`.
+ """Convert an image file using ``sips``.
Args:
inpath (str): Path of source file.
@@ -218,11 +218,11 @@ def convert_image(inpath, outpath, size):
size (int): Width and height of destination image in pixels.
Raises:
- RuntimeError: Raised if `sips` exits with non-zero status.
+ RuntimeError: Raised if ``sips`` exits with non-zero status.
"""
cmd = [
b'sips',
- b'-z', b'{0}'.format(size), b'{0}'.format(size),
+ b'-z', str(size), str(size),
inpath,
b'--out', outpath]
# log().debug(cmd)
@@ -230,14 +230,14 @@ def convert_image(inpath, outpath, size):
retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
if retcode != 0:
- raise RuntimeError('sips exited with {0}'.format(retcode))
+ raise RuntimeError('sips exited with %d' % retcode)
def png_to_icns(png_path, icns_path):
- """Convert PNG file to ICNS using `iconutil`.
+ """Convert PNG file to ICNS using ``iconutil``.
Create an iconset from the source PNG file. Generate PNG files
- in each size required by OS X, then call `iconutil` to turn
+ in each size required by macOS, then call ``iconutil`` to turn
them into a single ICNS file.
Args:
@@ -245,15 +245,15 @@ def png_to_icns(png_path, icns_path):
icns_path (str): Path to destination ICNS file.
Raises:
- RuntimeError: Raised if `iconutil` or `sips` fail.
+ 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 path already exists : {0!r}".format(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
@@ -261,7 +261,7 @@ def png_to_icns(png_path, icns_path):
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)))
+ 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'))
@@ -280,47 +280,17 @@ def png_to_icns(png_path, icns_path):
retcode = subprocess.call(cmd)
if retcode != 0:
- raise RuntimeError("iconset exited with {0}".format(retcode))
+ raise RuntimeError('iconset exited with %d' % retcode)
- assert os.path.exists(icns_path), (
- "Generated ICNS file not found : {0!r}".format(icns_path))
+ assert os.path.exists(icns_path), \
+ 'generated ICNS file not found: ' + repr(icns_path)
finally:
try:
shutil.rmtree(tempdir)
- except OSError: # pragma: nocover
+ except OSError: # pragma: no cover
pass
-# def notify_native(title='', text='', sound=''):
-# """Post notification via the native API (via pyobjc).
-
-# At least one of `title` or `text` must be specified.
-
-# This method will *always* show the Python launcher icon (i.e. the
-# rocket with the snakes on it).
-
-# Args:
-# title (str, optional): Notification title.
-# text (str, optional): Notification body text.
-# sound (str, optional): Name of sound to play.
-
-# """
-
-# if title == text == '':
-# raise ValueError('Empty notification')
-
-# import Foundation
-
-# sound = sound or Foundation.NSUserNotificationDefaultSoundName
-
-# n = Foundation.NSUserNotification.alloc().init()
-# n.setTitle_(title)
-# n.setInformativeText_(text)
-# n.setSoundName_(sound)
-# nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter()
-# nc.deliverNotification_(n)
-
-
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
@@ -329,21 +299,20 @@ def png_to_icns(png_path, icns_path):
from unicodedata import normalize
- def uni(s):
+ def ustr(s):
"""Coerce `s` to normalised Unicode."""
- ustr = s.decode('utf-8')
- return normalize('NFD', ustr)
+ 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=uni,
+ help="Notification title.", type=ustr,
default='')
- p.add_argument('-s', '--sound', type=uni,
+ p.add_argument('-s', '--sound', type=ustr,
help="Optional notification sound.", default='')
- p.add_argument('text', type=uni,
+ p.add_argument('text', type=ustr,
help="Notification body text.", default='', nargs='?')
o = p.parse_args()
@@ -357,21 +326,20 @@ def uni(s):
if o.png:
icns = os.path.join(
os.path.dirname(o.png),
- b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0],
- '.icns'))
+ os.path.splitext(os.path.basename(o.png))[0] + '.icns')
- print('Converting {0!r} to {1!r} ...'.format(o.png, 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 : {0}".format(icns))
+ 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)
+ print('ERROR: empty notification.', file=sys.stderr)
sys.exit(1)
else:
notify(o.title, o.text, o.sound)
diff --git a/workflow/update.py b/workflow/update.py
index 224a06e..37569bb 100644
--- a/workflow/update.py
+++ b/workflow/update.py
@@ -9,8 +9,7 @@
# Created on 2014-08-16
#
-"""
-Self-updating from GitHub
+"""Self-updating from GitHub.
.. versionadded:: 1.9
@@ -42,6 +41,7 @@
def wf():
+ """Lazy `Workflow` object."""
global _wf
if _wf is None:
_wf = workflow.Workflow()
@@ -49,16 +49,37 @@ def wf():
class Version(object):
- """Mostly semantic versioning
+ """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
@@ -73,7 +94,7 @@ def _parse(self, vstr):
else:
m = self.match_version(vstr)
if not m:
- raise ValueError('Invalid version number: {0}'.format(vstr))
+ raise ValueError('invalid version number: {0}'.format(vstr))
version, suffix = m.groups()
parts = self._parse_dotted_string(version)
@@ -83,7 +104,7 @@ def _parse(self, vstr):
if len(parts):
self.patch = parts.pop(0)
if not len(parts) == 0:
- raise ValueError('Invalid version (too long) : {0}'.format(vstr))
+ raise ValueError('invalid version (too long) : {0}'.format(vstr))
if suffix:
# Build info
@@ -94,14 +115,13 @@ def _parse(self, vstr):
if suffix:
if not suffix.startswith('-'):
raise ValueError(
- 'Invalid suffix : `{0}`. Must start with `-`'.format(
- suffix))
+ '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"""
+ """Parse string ``s`` into list of ints and strings."""
parsed = []
parts = s.split('.')
for p in parts:
@@ -112,14 +132,13 @@ def _parse_dotted_string(self, s):
@property
def tuple(self):
- """Return version number as a tuple of major, minor, patch, pre-release
- """
-
+ """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))
+ raise ValueError('not a Version instance: {0!r}'.format(other))
t = self.tuple[:3]
o = other.tuple[:3]
if t < o:
@@ -135,57 +154,62 @@ def __lt__(self, other):
return False
def __eq__(self, other):
+ """Implement comparison."""
if not isinstance(other, Version):
- raise ValueError('Not a Version instance: {0!r}'.format(other))
+ 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))
+ 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))
+ 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}'.format(self.suffix)
+ vstr = '{0}-{1}'.format(vstr, self.suffix)
if self.build:
- vstr += '+{0}'.format(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
+ """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]
- filename = url.split("/")[-1]
-
- if (not url.endswith('.alfredworkflow') or
- not filename.endswith('.alfredworkflow')):
- raise ValueError('Attachment `{0}` not a workflow'.format(filename))
+ 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 `{0}` to `{1}` ...'.format(
- url, local_path))
+ 'downloading updated workflow from `%s` to `%s` ...', url, local_path)
response = web.get(url)
@@ -196,25 +220,80 @@ def download_workflow(url):
def build_api_url(slug):
- """Generate releases URL from GitHub 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))
+ raise ValueError('invalid GitHub slug: {0}'.format(slug))
return RELEASES_BASE.format(slug)
-def get_valid_releases(github_slug):
- """Return list of all valid releases
+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/...'}``
+ ``{'version': '1.1', 'download_url': 'http://github.com/...',
+ 'prerelease': False }``
A valid release is one that contains one ``.alfredworkflow`` file.
@@ -223,55 +302,42 @@ def get_valid_releases(github_slug):
``v`` will be stripped.
"""
-
api_url = build_api_url(github_slug)
releases = []
- wf().logger.debug('Retrieving releases list from `{0}` ...'.format(
- api_url))
+ wf().logger.debug('retrieving releases list: %s', api_url)
def retrieve_releases():
wf().logger.info(
- 'Retrieving releases for `{0}` ...'.format(github_slug))
+ 'retrieving releases: %s', github_slug)
return web.get(api_url).json()
slug = github_slug.replace('/', '-')
- for release in wf().cached_data('gh-releases-{0}'.format(slug),
- retrieve_releases):
- version = release['tag_name']
- download_urls = []
- for asset in release.get('assets', []):
- url = asset.get('browser_download_url')
- if not url or not url.endswith('.alfredworkflow'):
- continue
- download_urls.append(url)
-
- # Validate release
- if release['prerelease']:
- wf().logger.warning(
- 'Invalid release {0} : pre-release detected'.format(version))
- continue
- if not download_urls:
- wf().logger.warning(
- 'Invalid release {0} : No workflow file'.format(version))
+ 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
- if len(download_urls) > 1:
- wf().logger.warning(
- 'Invalid release {0} : multiple workflow files'.format(version))
+
+ elif release['prerelease'] and not prereleases:
+ wf().logger.debug('ignoring prerelease: %s', release['version'])
continue
- wf().logger.debug('Release `{0}` : {1}'.format(version, url))
- releases.append({'version': version, 'download_url': download_urls[0]})
+ wf().logger.debug('release: %r', release)
+
+ releases.append(release)
return releases
-def check_update(github_slug, current_version):
- """Check whether a newer release is available on GitHub
+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``
@@ -279,14 +345,12 @@ def check_update(github_slug, current_version):
be cached.
"""
-
- releases = get_valid_releases(github_slug)
-
- wf().logger.info('{0} releases for {1}'.format(len(releases),
- github_slug))
+ releases = get_valid_releases(github_slug, prereleases)
if not len(releases):
- raise ValueError('No valid releases for {0}'.format(github_slug))
+ raise ValueError('no valid releases for %s', github_slug)
+
+ wf().logger.info('%d releases for %s', len(releases), github_slug)
# GitHub returns releases newest-first
latest_release = releases[0]
@@ -294,7 +358,7 @@ def check_update(github_slug, current_version):
# (latest_version, download_url) = get_latest_release(releases)
vr = Version(latest_release['version'])
vl = Version(current_version)
- wf().logger.debug('Latest : {0!r} Installed : {1!r}'.format(vr, vl))
+ wf().logger.debug('latest=%r, installed=%r', vr, vl)
if vr > vl:
wf().cache_data('__workflow_update_status', {
@@ -305,36 +369,25 @@ def check_update(github_slug, current_version):
return True
- wf().cache_data('__workflow_update_status', {
- 'available': False
- })
+ wf().cache_data('__workflow_update_status', {'available': False})
return False
-def install_update(github_slug, current_version):
- """If a newer release is available, download and install it
-
- :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.
- :type current_version: ``unicode``
-
- If an update is available, it will be downloaded and installed.
+def install_update():
+ """If a newer release is available, download and install it.
:returns: ``True`` if an update is installed, else ``False``
"""
- # TODO: `github_slug` and `current_version` are both unusued.
-
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')
+ wf().logger.info('no update available')
return False
local_file = download_workflow(update_data['download_url'])
- wf().logger.info('Installing updated workflow ...')
+ wf().logger.info('installing updated workflow ...')
subprocess.call(['open', local_file])
update_data['available'] = False
@@ -345,19 +398,29 @@ def install_update(github_slug, current_version):
if __name__ == '__main__': # pragma: nocover
import sys
- def show_help():
- print('Usage : update.py (check|install) github_slug version')
- sys.exit(1)
+ def show_help(status=0):
+ """Print help message."""
+ print('Usage : update.py (check|install) '
+ '[--prereleases] ')
+ sys.exit(status)
- if len(sys.argv) != 4:
+ argv = sys.argv[:]
+ if '-h' in argv or '--help' in argv:
show_help()
- action, github_slug, version = sys.argv[1:]
+ prereleases = '--prereleases' in argv
- if action not in ('check', 'install'):
- show_help()
+ if prereleases:
+ argv.remove('--prereleases')
+
+ if len(argv) != 4:
+ show_help(1)
+
+ action, github_slug, version = argv[1:]
if action == 'check':
- check_update(github_slug, version)
+ check_update(github_slug, version, prereleases)
elif action == 'install':
- install_update(github_slug, version)
+ install_update()
+ else:
+ show_help(1)
diff --git a/workflow/version b/workflow/version
index 4761f0e..ec8f6a3 100644
--- a/workflow/version
+++ b/workflow/version
@@ -1 +1 @@
-1.15.2
\ No newline at end of file
+1.27
\ No newline at end of file
diff --git a/workflow/web.py b/workflow/web.py
index 00f03f2..d64bb6f 100644
--- a/workflow/web.py
+++ b/workflow/web.py
@@ -7,11 +7,7 @@
# Created on 2014-02-15
#
-"""
-A lightweight HTTP library with a requests-like interface.
-"""
-
-from __future__ import print_function
+"""Lightweight HTTP library with a requests-like interface."""
import codecs
import json
@@ -28,7 +24,7 @@
import zlib
-USER_AGENT = u'Alfred-Workflow/1.15 (+http://www.deanishe.net/alfred-workflow)'
+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
@@ -79,10 +75,12 @@
def str_dict(dic):
- """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`
+ """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
- :param dic: :class:`dict` of Unicode strings
- :returns: :class:`dict`
+ :param dic: Mapping of Unicode strings
+ :type dic: dict
+ :returns: Dictionary containing only UTF-8 strings
+ :rtype: dict
"""
if isinstance(dic, CaseInsensitiveDictionary):
@@ -99,7 +97,7 @@ def str_dict(dic):
class NoRedirectHandler(urllib2.HTTPRedirectHandler):
- """Prevent redirections"""
+ """Prevent redirections."""
def redirect_request(self, *args):
return None
@@ -107,9 +105,10 @@ def redirect_request(self, *args):
# Adapted from https://gist.github.com/babakness/3901174
class CaseInsensitiveDictionary(dict):
- """
- Dictionary that enables case insensitive searching while preserving
- case sensitivity when keys are listed, ie, via keys() or items() methods.
+ """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
@@ -118,7 +117,7 @@ class CaseInsensitiveDictionary(dict):
"""
def __init__(self, initval=None):
-
+ """Create new case-insensitive dictionary."""
if isinstance(initval, dict):
for key, value in initval.iteritems():
self.__setitem__(key, value)
@@ -174,7 +173,7 @@ class Response(object):
"""
Returned by :func:`request` / :func:`get` / :func:`post` functions.
- A simplified version of the ``Response`` object in the ``requests`` library.
+ Simplified version of the ``Response`` object in the ``requests`` library.
>>> r = request('http://www.google.com')
>>> r.status_code
@@ -189,14 +188,16 @@ class Response(object):
"""
- def __init__(self, request):
+ 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
@@ -205,6 +206,7 @@ def __init__(self, request):
self.reason = None
self.headers = CaseInsensitiveDictionary()
self._content = None
+ self._content_loaded = False
self._gzipped = False
# Execute query
@@ -242,24 +244,40 @@ def __init__(self, request):
'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: :class:`list` / :class:`dict`
+ :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``
+ """Text encoding of document or ``None``.
- :returns: :class:`str` or ``None``
+ :returns: Text encoding if found.
+ :rtype: str or ``None``
"""
-
if not self._encoding:
self._encoding = self._get_encoding()
@@ -267,13 +285,12 @@ def encoding(self):
@property
def content(self):
- """Raw content of response (i.e. bytes)
+ """Raw content of response (i.e. bytes).
:returns: Body of HTTP response
- :rtype: :class:`str`
+ :rtype: str
"""
-
if not self._content:
# Decompress gzipped content
@@ -284,6 +301,8 @@ def content(self):
else:
self._content = self.raw.read()
+ self._content_loaded = True
+
return self._content
@property
@@ -294,10 +313,9 @@ def text(self):
itself, the encoded response body will be returned instead.
:returns: Body of HTTP response
- :rtype: :class:`unicode` or :class:`str`
+ :rtype: unicode or str
"""
-
if self.encoding:
return unicodedata.normalize('NFC', unicode(self.content,
self.encoding))
@@ -309,12 +327,20 @@ def iter_content(self, chunk_size=4096, decode_unicode=False):
.. versionadded:: 1.6
:param chunk_size: Number of bytes to read into memory
- :type chunk_size: ``int``
+ :type chunk_size: int
:param decode_unicode: Decode to Unicode using detected encoding
- :type decode_unicode: ``Boolean``
+ :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):
@@ -326,8 +352,8 @@ def decode_stream(iterator, r):
yield data
data = decoder.decode(b'', final=True)
- if data:
- yield data # pragma: nocover
+ if data: # pragma: no cover
+ yield data
def generate():
@@ -352,19 +378,20 @@ def generate():
return chunks
def save_to_path(self, filepath):
- """Save retrieved data to file at ``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)
@@ -374,7 +401,6 @@ def raise_for_status(self):
error will be instance of :class:`urllib2.HTTPError`
"""
-
if self.error is not None:
raise self.error
return
@@ -383,10 +409,9 @@ def _get_encoding(self):
"""Get encoding from HTTP headers or content.
:returns: encoding or `None`
- :rtype: ``unicode`` or ``None``
+ :rtype: unicode or ``None``
"""
-
headers = self.raw.info()
encoding = None
@@ -399,20 +424,21 @@ def _get_encoding(self):
encoding = param[8:]
break
- # 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)
+ 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:
@@ -430,31 +456,35 @@ def _get_encoding(self):
def request(method, url, params=None, data=None, headers=None, cookies=None,
- files=None, auth=None, timeout=60, allow_redirects=False):
+ 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``
+ :type method: unicode
:param url: URL to open
- :type url: ``unicode``
+ :type url: unicode
:param params: mapping of URL parameters
- :type params: :class:`dict`
+ :type params: dict
:param data: mapping of form data ``{'field_name': 'value'}`` or
:class:`str`
- :type data: :class:`dict` or :class:`str`
+ :type data: dict or str
:param headers: HTTP headers
- :type headers: :class:`dict`
+ :type headers: dict
:param cookies: cookies to send to server
- :type cookies: :class:`dict`
+ :type cookies: dict
:param files: files to upload (see below).
- :type files: :class:`dict`
+ :type files: dict
:param auth: username, password
- :type auth: ``tuple``
+ :type auth: tuple
:param timeout: connection timeout limit in seconds
- :type timeout: ``int``
+ :type timeout: int
:param allow_redirects: follow redirections
- :type allow_redirects: ``Boolean``
- :returns: :class:`Response` object
+ :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::
@@ -470,9 +500,7 @@ def request(method, url, params=None, data=None, headers=None, cookies=None,
will be used.
"""
-
# TODO: cookies
- # TODO: any way to force GET or POST?
socket.setdefaulttimeout(timeout)
# Default handlers
@@ -508,6 +536,10 @@ def request(method, url, params=None, data=None, headers=None, cookies=None,
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 = {}
@@ -536,41 +568,42 @@ def request(method, url, params=None, data=None, headers=None, cookies=None,
url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
req = urllib2.Request(url, data, headers)
- return Response(req)
+ return Response(req, stream)
def get(url, params=None, headers=None, cookies=None, auth=None,
- timeout=60, allow_redirects=True):
+ 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)
+ 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):
+ 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)
+ 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: :class:`dict`
+ :type fields: dict
:param files: dictionary of fieldnames/files elements for file data.
See below for details.
- :type files: :class:`dict` of :class:`dicts`
- :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers
+ :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::
@@ -581,17 +614,18 @@ def encode_multipart_formdata(fields, files):
}
- ``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.
+ - ``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/string
+ :type filename: unicode/str
:returns: mime-type, e.g. ``text/html``
- :rtype: :class::class:`str`
+ :rtype: str
"""
diff --git a/workflow/workflow.py b/workflow/workflow.py
index c03fa17..0d2dc4e 100644
--- a/workflow/workflow.py
+++ b/workflow/workflow.py
@@ -7,8 +7,12 @@
# Created on 2014-02-15
#
-"""
-The :class:`Workflow` object is the main interface to this library.
+"""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.
@@ -17,6 +21,7 @@
from __future__ import print_function, unicode_literals
+import atexit
import binascii
from contextlib import contextmanager
import cPickle
@@ -51,7 +56,7 @@
# Standard system icons
####################################################################
-# These icons are default OS X icons. They are super-high quality, and
+# 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
@@ -451,12 +456,16 @@ class KeychainError(Exception):
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):
- """Raised by method :meth:`Workflow.get_password` when ``account``
+ """Password not in Keychain.
+
+ Raised by method :meth:`Workflow.get_password` when ``account``
is unknown to the Keychain.
+
"""
@@ -466,6 +475,7 @@ class PasswordExists(KeychainError):
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).
+
"""
@@ -480,8 +490,8 @@ def isascii(text):
:type text: ``unicode``
:returns: ``True`` if ``text`` contains only ASCII characters
:rtype: ``Boolean``
- """
+ """
try:
text.encode('ascii')
except UnicodeEncodeError:
@@ -499,13 +509,13 @@ class SerializerManager(object):
.. versionadded:: 1.8
A configured instance of this class is available at
- ``workflow.manager``.
+ :attr:`workflow.manager`.
Use :meth:`register()` to register new (or replace
existing) serializers, which you can specify by name when calling
- :class:`Workflow` data storage methods.
+ :class:`~workflow.Workflow` data storage methods.
- See :ref:`manual-serialization` and :ref:`manual-persistent-data`
+ See :ref:`guide-serialization` and :ref:`guide-persistent-data`
for further information.
"""
@@ -529,7 +539,6 @@ def register(self, name, serializer):
methods
"""
-
# Basic validation
getattr(serializer, 'load')
getattr(serializer, 'dump')
@@ -545,7 +554,6 @@ def serializer(self, name):
is registered.
"""
-
return self._serializers.get(name)
def unregister(self, name):
@@ -559,7 +567,6 @@ def unregister(self, name):
:returns: serializer object
"""
-
if name not in self._serializers:
raise ValueError('No such serializer registered : {0}'.format(
name))
@@ -598,7 +605,6 @@ def load(cls, file_obj):
:rtype: object
"""
-
return json.load(file_obj)
@classmethod
@@ -613,7 +619,6 @@ def dump(cls, obj, file_obj):
:type file_obj: ``file`` object
"""
-
return json.dump(obj, file_obj, indent=2, encoding='utf-8')
@@ -639,7 +644,6 @@ def load(cls, file_obj):
:rtype: object
"""
-
return cPickle.load(file_obj)
@classmethod
@@ -654,7 +658,6 @@ def dump(cls, obj, file_obj):
:type file_obj: ``file`` object
"""
-
return cPickle.dump(obj, file_obj, protocol=-1)
@@ -679,7 +682,6 @@ def load(cls, file_obj):
:rtype: object
"""
-
return pickle.load(file_obj)
@classmethod
@@ -694,7 +696,6 @@ def dump(cls, obj, file_obj):
:type file_obj: ``file`` object
"""
-
return pickle.dump(obj, file_obj, protocol=-1)
@@ -719,11 +720,8 @@ class Item(object):
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):
- """Arguments the same as for :meth:`Workflow.add_item`.
-
- """
-
+ copytext=None, quicklookurl=None):
+ """Same arguments as :meth:`Workflow.add_item`."""
self.title = title
self.subtitle = subtitle
self.modifier_subtitles = modifier_subtitles or {}
@@ -736,6 +734,7 @@ def __init__(self, title, subtitle='', modifier_subtitles=None,
self.type = type
self.largetext = largetext
self.copytext = copytext
+ self.quicklookurl = quicklookurl
@property
def elem(self):
@@ -745,7 +744,6 @@ def elem(self):
instance for this :class:`Item` instance.
"""
-
# Attributes on - element
attr = {}
if self.valid:
@@ -795,11 +793,34 @@ def elem(self):
ET.SubElement(root, 'text',
{'type': 'copy'}).text = self.copytext
+ if self.quicklookurl:
+ ET.SubElement(root, 'quicklookurl').text = self.quicklookurl
+
return root
class LockFile(object):
- """Context manager to create lock files."""
+ """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 (int, 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.
+
+ """
def __init__(self, protected_path, timeout=0, delay=0.05):
"""Create new :class:`LockFile` object."""
@@ -807,6 +828,7 @@ def __init__(self, protected_path, timeout=0, delay=0.05):
self.timeout = timeout
self.delay = delay
self._locked = False
+ atexit.register(self.release)
@property
def locked(self):
@@ -820,21 +842,25 @@ def acquire(self, blocking=True):
``False``.
Otherwise, check every `self.delay` seconds until it acquires
- lock or exceeds `self.timeout` and raises an exception.
+ lock or exceeds `self.timeout` and raises an `~AcquisitionError`.
"""
start = time.time()
while True:
+
+ self._validate_lockfile()
+
try:
fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
with os.fdopen(fd, 'w') as fd:
- fd.write('{0}'.format(os.getpid()))
+ fd.write(str(os.getpid()))
break
except OSError as err:
if err.errno != errno.EEXIST: # pragma: no cover
raise
+
if self.timeout and (time.time() - start) >= self.timeout:
- raise AcquisitionError('Lock acquisition timed out.')
+ raise AcquisitionError('lock acquisition timed out')
if not blocking:
return False
time.sleep(self.delay)
@@ -842,10 +868,36 @@ def acquire(self, blocking=True):
self._locked = True
return True
+ def _validate_lockfile(self):
+ """Check existence and validity of lockfile.
+
+ If the lockfile exists, but contains an invalid PID
+ or the PID of a non-existant process, it is removed.
+
+ """
+ try:
+ with open(self.lockfile) as fp:
+ s = fp.read()
+ except Exception:
+ return
+
+ try:
+ pid = int(s)
+ except ValueError:
+ return self.release()
+
+ from background import _process_exists
+ if not _process_exists(pid):
+ self.release()
+
def release(self):
"""Release the lock by deleting `self.lockfile`."""
self._locked = False
- os.unlink(self.lockfile)
+ try:
+ os.unlink(self.lockfile)
+ except (OSError, IOError) as err: # pragma: no cover
+ if err.errno != 2:
+ raise err
def __enter__(self):
"""Acquire lock."""
@@ -866,18 +918,17 @@ def __del__(self):
def atomic_writer(file_path, mode):
"""Atomic file writer.
- :param file_path: path of file to write to.
- :type file_path: ``unicode``
- :param mode: sames as for `func:open`
- :type mode: string
-
.. 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 file_path: path of file to write to.
+ :type file_path: ``unicode``
+ :param mode: sames as for :func:`open`
+ :type mode: string
+ """
temp_suffix = '.aw.temp'
temp_file_path = file_path + temp_suffix
with open(temp_file_path, mode) as file_obj:
@@ -892,11 +943,13 @@ def atomic_writer(file_path, mode):
class uninterruptible(object):
- """Decorator that postpones SIGTERM until wrapped function is complete.
+ """Decorator that postpones SIGTERM until wrapped function returns.
.. versionadded:: 1.12
- Since version 2.7, Alfred allows Script Filters to be killed. If
+ .. 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.
@@ -908,13 +961,10 @@ class uninterruptible(object):
Alfred-Workflow uses this internally to ensure its settings, data
and cache writes complete.
- .. important::
-
- This decorator is NOT thread-safe.
-
"""
def __init__(self, func, class_name=''):
+ """Decorate `func`."""
self.func = func
self._caught_signal = None
@@ -923,6 +973,7 @@ def signal_handler(self, signum, frame):
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)
@@ -942,6 +993,7 @@ def __call__(self, *args, **kwargs):
sys.exit(0)
def __get__(self, obj=None, klass=None):
+ """Decorator API."""
return self.__class__(self.func.__get__(obj, klass),
klass.__name__)
@@ -965,7 +1017,7 @@ class Settings(dict):
"""
def __init__(self, filepath, defaults=None):
-
+ """Create new :class:`Settings` object."""
super(Settings, self).__init__()
self._filepath = filepath
self._nosave = False
@@ -978,8 +1030,7 @@ def __init__(self, filepath, defaults=None):
self.save() # save default settings
def _load(self):
- """Load cached settings from JSON file `self._filepath`"""
-
+ """Load cached settings from JSON file `self._filepath`."""
self._nosave = True
d = {}
with open(self._filepath, 'rb') as file_obj:
@@ -989,8 +1040,9 @@ def _load(self):
self._original = deepcopy(d)
self._nosave = False
+ @uninterruptible
def save(self):
- """Save settings to JSON file specified in ``self._filepath``
+ """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``
@@ -1009,11 +1061,13 @@ def save(self):
# 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()
@@ -1030,36 +1084,48 @@ def setdefault(self, key, value=None):
class Workflow(object):
- """Create new :class:`Workflow` instance.
-
- :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.
- This must be a :class:`dict` that contains ``github_slug`` and
- ``version`` keys. ``github_slug`` is of the form ``username/repo``
- and ``version`` **must** correspond to the tag of a release.
- See :ref:`updates` for more information.
- :type update_settings: :class:`dict`
- :param input_encoding: encoding of command line arguments
- :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`
+ """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`
"""
@@ -1071,7 +1137,7 @@ 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
@@ -1082,11 +1148,10 @@ def __init__(self, default_settings=None, update_settings=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'
- # info.plist should be in the directory above this one
- self._info_plist = self.workflowfile('info.plist')
self._info = None
self._info_loaded = False
self._logger = None
@@ -1124,9 +1189,15 @@ def __init__(self, default_settings=None, update_settings=None,
# 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):
- """Alfred's environmental variables minus the ``alfred_`` prefix.
+ """Dict of Alfred's environmental variables minus ``alfred_`` prefix.
.. versionadded:: 1.7
@@ -1135,29 +1206,33 @@ def alfred_env(self):
============================ =========================================
Variable Description
============================ =========================================
- alfred_preferences Path to Alfred.alfredpreferences
+ debug Set to ``1`` if Alfred's debugger is
+ open, otherwise unset.
+ preferences Path to Alfred.alfredpreferences
(where your workflows and settings are
stored).
- alfred_preferences_localhash Machine-specific preferences are stored
+ preferences_localhash Machine-specific preferences are stored
in ``Alfred.alfredpreferences/preferences/local/``
- (see ``alfred_preferences`` above for
+ (see ``preferences`` above for
the path to ``Alfred.alfredpreferences``)
- alfred_theme ID of selected theme
- alfred_theme_background Background colour of selected theme in
+ theme ID of selected theme
+ theme_background Background colour of selected theme in
format ``rgba(r,g,b,a)``
- alfred_theme_subtext Show result subtext.
+ theme_subtext Show result subtext.
``0`` = Always,
``1`` = Alternative actions only,
``2`` = Selected result only,
``3`` = Never
- alfred_version Alfred version number, e.g. ``'2.4'``
- alfred_version_build Alfred build number, e.g. ``277``
- alfred_workflow_bundleid Bundle ID, e.g.
+ 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``
- alfred_workflow_cache Path to workflow's cache directory
- alfred_workflow_data Path to workflow's data directory
- alfred_workflow_name Name of current workflow
- alfred_workflow_uid UID of workflow
+ 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
@@ -1167,13 +1242,13 @@ def alfred_env(self):
``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',
@@ -1185,12 +1260,14 @@ def alfred_env(self):
'alfred_workflow_cache',
'alfred_workflow_data',
'alfred_workflow_name',
- 'alfred_workflow_uid'):
+ 'alfred_workflow_uid',
+ 'alfred_workflow_version'):
value = os.getenv(key)
if isinstance(value, str):
- if key in ('alfred_version_build', 'alfred_theme_subtext'):
+ if key in ('alfred_debug', 'alfred_version_build',
+ 'alfred_theme_subtext'):
value = int(value)
else:
value = self.decode(value)
@@ -1204,7 +1281,6 @@ def alfred_env(self):
@property
def info(self):
""":class:`dict` of ``info.plist`` contents."""
-
if not self._info_loaded:
self._load_info_plist()
return self._info
@@ -1217,7 +1293,6 @@ def bundleid(self):
:rtype: ``unicode``
"""
-
if not self._bundleid:
if self.alfred_env.get('workflow_bundleid'):
self._bundleid = self.alfred_env.get('workflow_bundleid')
@@ -1226,6 +1301,21 @@ def bundleid(self):
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``.
@@ -1234,7 +1324,6 @@ def name(self):
:rtype: ``unicode``
"""
-
if not self._name:
if self.alfred_env.get('workflow_name'):
self._name = self.decode(self.alfred_env.get('workflow_name'))
@@ -1245,29 +1334,33 @@ def name(self):
@property
def version(self):
- """Return the version of the workflow
+ """Return the version of the workflow.
.. versionadded:: 1.9.10
- Get the version from the ``update_settings`` dict passed on
- instantiation or the ``version`` file located in the workflow's
- root directory. Return ``None`` if neither exist or
- :class:`ValueError` if the version number is invalid (i.e. not
- semantic).
+ 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
- # First check `update_settings`
- if self._update_settings:
+ # 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')
- # Fallback to `version` file
+ # `version` file
if not version:
filepath = self.workflowfile('version')
@@ -1275,6 +1368,10 @@ def version(self):
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)
@@ -1303,7 +1400,6 @@ def args(self):
See :ref:`Magic arguments ` for details.
"""
-
msg = None
args = [self.decode(arg) for arg in sys.argv[1:]]
@@ -1326,28 +1422,34 @@ def args(self):
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:
+ The cache directory is a subdirectory of Alfred's own cache directory
+ in ``~/Library/Caches``. The full path is:
- ``~/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/``
+ ``~/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 = os.path.join(
- os.path.expanduser(
- '~/Library/Caches/com.runningwithcrayons.Alfred-2/'
- 'Workflow Data/'),
- self.bundleid)
+ 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.
@@ -1361,17 +1463,21 @@ def datadir(self):
:rtype: ``unicode``
"""
-
if self.alfred_env.get('workflow_data'):
dirpath = self.alfred_env.get('workflow_data')
else:
- dirpath = os.path.join(os.path.expanduser(
- '~/Library/Application Support/Alfred 2/Workflow Data/'),
- self.bundleid)
+ 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).
@@ -1380,7 +1486,6 @@ def workflowdir(self):
: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
@@ -1417,7 +1522,9 @@ def workflowdir(self):
return self._workflowdir
def cachefile(self, filename):
- """Return full path to ``filename`` within your workflow's
+ """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
@@ -1426,11 +1533,12 @@ def cachefile(self, filename):
:rtype: ``unicode``
"""
-
return os.path.join(self.cachedir, filename)
def datafile(self, filename):
- """Return full path to ``filename`` within your workflow's
+ """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
@@ -1439,12 +1547,10 @@ def datafile(self, filename):
:rtype: ``unicode``
"""
-
return os.path.join(self.datadir, filename)
def workflowfile(self, filename):
- """Return full path to ``filename`` in workflow's root dir
- (where ``info.plist`` is).
+ """Return full path to ``filename`` in workflow's root directory.
:param filename: basename of file
:type filename: ``unicode``
@@ -1452,31 +1558,30 @@ def workflowfile(self, filename):
:rtype: ``unicode``
"""
-
return os.path.join(self.workflowdir, filename)
@property
def logfile(self):
- """Return path to logfile
+ """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):
- """Create and return a logger that logs to both console and
- a log file.
+ """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
@@ -1484,25 +1589,28 @@ def logger(self):
logger = logging.getLogger('workflow')
if not len(logger.handlers): # Only add one set of handlers
- logfile = logging.handlers.RotatingFileHandler(
- self.logfile,
- maxBytes=1024*1024,
- backupCount=1)
-
- console = logging.StreamHandler()
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)
- console.setFormatter(fmt)
-
logger.addHandler(logfile)
+
+ console = logging.StreamHandler()
+ console.setFormatter(fmt)
logger.addHandler(console)
- logger.setLevel(logging.DEBUG)
+ if self.debugging:
+ logger.setLevel(logging.DEBUG)
+ else:
+ logger.setLevel(logging.INFO)
+
self._logger = logger
return self._logger
@@ -1515,7 +1623,6 @@ def logger(self, logger):
:type logger: `~logging.Logger` instance
"""
-
self._logger = logger
@property
@@ -1526,7 +1633,6 @@ def settings_path(self):
:rtype: ``unicode``
"""
-
if not self._settings_path:
self._settings_path = self.datafile('settings.json')
return self._settings_path
@@ -1535,7 +1641,7 @@ def settings_path(self):
def settings(self):
"""Return a dictionary subclass that saves itself when changed.
- See :ref:`manual-settings` in the :ref:`user-manual` for more
+ 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.
@@ -1547,10 +1653,8 @@ def settings(self):
:rtype: :class:`~workflow.workflow.Settings` instance
"""
-
if not self._settings:
- self.logger.debug('Reading settings from `{0}` ...'.format(
- self.settings_path))
+ self.logger.debug('reading settings from %s', self.settings_path)
self._settings = Settings(self.settings_path,
self._default_settings)
return self._settings
@@ -1570,7 +1674,6 @@ def cache_serializer(self):
:rtype: ``unicode``
"""
-
return self._cache_serializer
@cache_serializer.setter
@@ -1590,14 +1693,12 @@ def cache_serializer(self, serializer_name):
: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 set to `{0}`'.format(serializer_name))
+ self.logger.debug('default cache serializer: %s', serializer_name)
self._cache_serializer = serializer_name
@@ -1616,7 +1717,6 @@ def data_serializer(self):
:rtype: ``unicode``
"""
-
return self._data_serializer
@data_serializer.setter
@@ -1635,31 +1735,29 @@ def data_serializer(self, serializer_name):
: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 set to `{0}`'.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.
+ """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 `{0}`'.format(name))
+ self.logger.debug('no data stored for `%s`', name)
return None
with open(metadata_path, 'rb') as file_obj:
@@ -1673,14 +1771,13 @@ def stored_data(self, name):
'serializer with `manager.register()` '
'to load this data.'.format(serializer_name))
- self.logger.debug('Data `{0}` stored in `{1}` format'.format(
- name, 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 for `{0}`'.format(name))
+ self.logger.debug('no data stored: %s', name)
if os.path.exists(metadata_path):
os.unlink(metadata_path)
@@ -1689,7 +1786,7 @@ def stored_data(self, name):
with open(data_path, 'rb') as file_obj:
data = serializer.load(file_obj)
- self.logger.debug('Stored data loaded from : {0}'.format(data_path))
+ self.logger.debug('stored data loaded: %s', data_path)
return data
@@ -1711,7 +1808,6 @@ def store_data(self, name, data, serializer=None):
:returns: data in datastore or ``None``
"""
-
# Ensure deletion is not interrupted by SIGTERM
@uninterruptible
def delete_paths(paths):
@@ -1719,7 +1815,7 @@ def delete_paths(paths):
for path in paths:
if os.path.exists(path):
os.unlink(path)
- self.logger.debug('Deleted data file : {0}'.format(path))
+ self.logger.debug('deleted data file: %s', path)
serializer_name = serializer or self.data_serializer
@@ -1759,10 +1855,12 @@ def _store():
_store()
- self.logger.debug('Stored data saved at : {0}'.format(data_path))
+ self.logger.debug('saved data: %s', data_path)
def cached_data(self, name, data_func=None, max_age=60):
- """Retrieve data from cache or re-generate and re-cache data if
+ """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.
@@ -1775,7 +1873,6 @@ def cached_data(self, name, data_func=None, max_age=60):
if ``data_func`` is not set
"""
-
serializer = manager.serializer(self.cache_serializer)
cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
@@ -1784,8 +1881,7 @@ def cached_data(self, name, data_func=None, max_age=60):
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 from : %s',
- cache_path)
+ self.logger.debug('loading cached data: %s', cache_path)
return serializer.load(file_obj)
if not data_func:
@@ -1807,7 +1903,6 @@ def cache_data(self, name, data):
the cache serializer
"""
-
serializer = manager.serializer(self.cache_serializer)
cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
@@ -1815,16 +1910,16 @@ def cache_data(self, name, data):
if data is None:
if os.path.exists(cache_path):
os.unlink(cache_path)
- self.logger.debug('Deleted cache file : %s', 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 saved at : %s', cache_path)
+ self.logger.debug('cached data: %s', cache_path)
def cached_data_fresh(self, name, max_age):
- """Is data cached at `name` less than `max_age` old?
+ """Whether cache `name` is less than `max_age` seconds old.
:param name: name of datastore
:param max_age: maximum age of data in seconds
@@ -1833,7 +1928,6 @@ def cached_data_fresh(self, name, max_age):
``False``
"""
-
age = self.cached_data_age(name)
if not age:
@@ -1842,8 +1936,7 @@ def cached_data_fresh(self, name, max_age):
return age < max_age
def cached_data_age(self, name):
- """Return age of data cached at `name` in seconds or 0 if
- cache doesn't exist
+ """Return age in seconds of cache `name` or 0 if cache doesn't exist.
:param name: name of datastore
:type name: ``unicode``
@@ -1851,7 +1944,6 @@ def cached_data_age(self, name):
:rtype: ``int``
"""
-
cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
if not os.path.exists(cache_path):
@@ -1907,26 +1999,30 @@ def filter(self, query, items, key=lambda x: x, ascending=False,
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 startswith
- ``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` : Match if ``query`` is a substring
- of item search key (case-insensitive).
- 8. :const:`MATCH_ALLCHARS` : Matches if all characters in
- ``query`` appear in item search key in the same 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).
- 9. :const:`MATCH_ALL` : Combination of all the above.
+ 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
@@ -1958,7 +2054,6 @@ def filter(self, query, items, key=lambda x: x, ascending=False,
altered.
"""
-
if not query:
raise ValueError('Empty `query`')
@@ -2018,12 +2113,11 @@ def filter(self, query, items, key=lambda x: x, ascending=False,
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``
+ """Filter ``value`` against ``query`` using rules ``match_on``.
:returns: ``(score, rule)``
"""
-
query = query.lower()
if not isascii(query):
@@ -2125,11 +2219,16 @@ def _search_for_query(self, query):
self._search_pattern_cache[query] = search
return search
- def run(self, func):
- """Call ``func`` to run your workflow
+ 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.
@@ -2140,14 +2239,14 @@ def run(self, func):
output to Alfred.
"""
-
start = time.time()
# 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('Workflow version : {0}'.format(self.version))
+ self.logger.debug('workflow version: %s', self.version)
# Run update check if configured for self-updates.
# This call has to go in the `run` try-except block, as it will
@@ -2167,31 +2266,38 @@ def run(self, func):
except Exception as err:
self.logger.exception(err)
if self.help_url:
- self.logger.info(
- 'For assistance, see: {0}'.format(self.help_url))
+ self.logger.info('for assistance, see: %s', self.help_url)
+
if not sys.stdout.isatty(): # Show error in Alfred
- self._items = []
- if self._name:
- name = self._name
- elif self._bundleid:
- 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()
+ if text_errors:
+ print(unicode(err).encode('utf-8'), end='')
+ else:
+ self._items = []
+ if self._name:
+ name = self._name
+ elif self._bundleid:
+ 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('Workflow finished in {0:0.3f} seconds.'.format(
- time.time() - start))
+ self.logger.debug('workflow finished in %0.3f seconds',
+ 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):
- """Add an item to be output to Alfred
+ 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``
@@ -2230,12 +2336,11 @@ def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None,
: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 the :ref:`script-filter-results` section of the documentation
- for a detailed description of what the various parameters do and how
- they interact with one another.
-
See :ref:`icons` for a list of the supported system icons.
.. note::
@@ -2249,10 +2354,9 @@ def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None,
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)
+ largetext, copytext, quicklookurl)
self._items.append(item)
return item
@@ -2278,7 +2382,6 @@ def first_run(self):
Raises a :class:`ValueError` if :attr:`version` isn't set.
"""
-
if not self.version:
raise ValueError('No workflow version set')
@@ -2289,7 +2392,7 @@ def first_run(self):
@property
def last_version_run(self):
- """Return version of last version to run (or ``None``)
+ """Return version of last version to run (or ``None``).
.. versionadded:: 1.9.10
@@ -2297,7 +2400,6 @@ def last_version_run(self):
or ``None``
"""
-
if self._last_version_run is UNSET:
version = self.settings.get('__workflow_last_version')
@@ -2307,13 +2409,12 @@ def last_version_run(self):
self._last_version_run = version
- self.logger.debug('Last run version : {0}'.format(
- self._last_version_run))
+ 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
+ """Set :attr:`last_version_run` to current version.
.. versionadded:: 1.9.10
@@ -2323,7 +2424,6 @@ def set_last_version(self, version=None):
:returns: ``True`` if version is saved, else ``False``
"""
-
if not version:
if not self.version:
self.logger.warning(
@@ -2338,47 +2438,65 @@ def set_last_version(self, version=None):
self.settings['__workflow_last_version'] = str(version)
- self.logger.debug('Set last run version : {0}'.format(version))
+ self.logger.debug('set last run version: %s', version)
return True
@property
def update_available(self):
- """Is an update available?
+ """Whether an update is available.
.. versionadded:: 1.9
- See :ref:`manual-updates` in the :ref:`user-manual` for detailed
+ 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)
- update_data = self.cached_data('__workflow_update_status', max_age=0)
- self.logger.debug('update_data : {0}'.format(update_data))
+ 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
+ """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:`manual-updates` in the :ref:`user-manual` for detailed
+ 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)
@@ -2403,6 +2521,9 @@ def check_update(self, force=False):
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)
@@ -2411,25 +2532,24 @@ def check_update(self, force=False):
self.logger.debug('Update check not due')
def start_update(self):
- """Check for update and download and install new workflow file
+ """Check for update and download and install new workflow file.
.. versionadded:: 1.9
- See :ref:`manual-updates` in the :ref:`user-manual` for detailed
+ 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):
+ if not update.check_update(github_slug, version, self.prereleases):
return False
from background import run_in_background
@@ -2441,6 +2561,9 @@ def start_update(self):
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)
@@ -2491,8 +2614,9 @@ def save_password(self, account, password, service=None):
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.
+ """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"
@@ -2504,7 +2628,6 @@ def get_password(self, account, service=None):
:rtype: ``unicode``
"""
-
if not service:
service = self.bundleid
@@ -2530,8 +2653,9 @@ def get_password(self, account, service=None):
return password
def delete_password(self, account, service=None):
- """Delete the password stored at ``service/account``. Raises
- :class:`PasswordNotFound` if account is unknown.
+ """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"
@@ -2541,7 +2665,6 @@ def delete_password(self, account, service=None):
:type service: ``unicode``
"""
-
if not service:
service = self.bundleid
@@ -2554,9 +2677,8 @@ def delete_password(self, account, service=None):
####################################################################
def _register_default_magic(self):
- """Register the built-in magic arguments"""
+ """Register the built-in magic arguments."""
# TODO: refactor & simplify
-
# Wrap callback and message with callable
def callback(func, msg):
def wrapper():
@@ -2611,6 +2733,14 @@ 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 ...'
@@ -2619,6 +2749,8 @@ def do_update():
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
@@ -2636,7 +2768,7 @@ def show_version():
return 'This workflow has no version number'
def list_magic():
- """Display all available magic args in Alfred"""
+ """Display all available magic args in Alfred."""
isatty = sys.stderr.isatty()
for name in sorted(self.magic_arguments.keys()):
if name == 'magic':
@@ -2685,21 +2817,18 @@ def clear_settings(self):
self.logger.debug('Deleted : %r', self.settings_path)
def reset(self):
- """Delete :attr:`settings `, :attr:`cache `
- and :attr:`data `
+ """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 workflows :attr:`logfile` in standard
- application (usually Console.app).
-
- """
-
+ """Open :attr:`logfile` in default app (usually Console.app)."""
subprocess.call(['open', self.logfile])
def open_cachedir(self):
@@ -2716,12 +2845,11 @@ def open_workflowdir(self):
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"""
+ """Open :attr:`help_url` in default browser."""
subprocess.call(['open', self.help_url])
return 'Opening workflow help URL in browser'
@@ -2751,14 +2879,13 @@ def decode(self, text, encoding=None, normalization=None):
standard for Python and will work well with data from the web (via
:mod:`~workflow.web` or :mod:`json`).
- OS X, on the other hand, uses "NFD" normalisation (nearly), so data
+ 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):
@@ -2806,15 +2933,15 @@ def dumbify_punctuation(self, text):
return text
def _delete_directory_contents(self, dirpath, filter_func):
- """Delete all files in a directory
+ """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):
@@ -2827,15 +2954,13 @@ def _delete_directory_contents(self, dirpath, filter_func):
self.logger.debug('Deleted : %r', path)
def _load_info_plist(self):
- """Load workflow info from ``info.plist``
-
- """
-
- self._info = plistlib.readPlist(self._info_plist)
+ """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
+ """Create directory `dirpath` if it doesn't exist.
:param dirpath: path to directory
:type dirpath: ``unicode``
@@ -2843,14 +2968,12 @@ def _create(self, dirpath):
:rtype: ``unicode``
"""
-
if not os.path.exists(dirpath):
os.makedirs(dirpath)
return dirpath
def _call_security(self, action, service, account, *args):
- """Call the ``security`` CLI app that provides access to keychains.
-
+ """Call ``security`` CLI program that provides access to keychains.
May raise `PasswordNotFound`, `PasswordExists` or `KeychainError`
exceptions (the first two are subclasses of `KeychainError`).
@@ -2873,17 +2996,16 @@ def _call_security(self, action, service, account, *args):
:rtype: `tuple` (`int`, ``unicode``)
"""
-
cmd = ['security', action, '-s', service, '-a', account] + list(args)
p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
- retcode, output = p.wait(), p.stdout.read().strip().decode('utf-8')
- if retcode == 44: # password does not exist
+ stdout, _ = p.communicate()
+ if p.returncode == 44: # password does not exist
raise PasswordNotFound()
- elif retcode == 45: # password already exists
+ elif p.returncode == 45: # password already exists
raise PasswordExists()
- elif retcode > 0:
- err = KeychainError('Unknown Keychain error : %s' % output)
- err.retcode = retcode
+ elif p.returncode > 0:
+ err = KeychainError('Unknown Keychain error : %s' % stdout)
+ err.retcode = p.returncode
raise err
- return output
+ return stdout.strip().decode('utf-8')
diff --git a/workflow/workflow3.py b/workflow/workflow3.py
new file mode 100644
index 0000000..cfd580f
--- /dev/null
+++ b/workflow/workflow3.py
@@ -0,0 +1,676 @@
+# 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 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,
+ 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.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)
+
+ for k in self.variables:
+ mod.setvar(k, self.variables[k])
+
+ 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.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.
+
+ ``Workflow3`` 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
+ self._session_id = None
+
+ @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:
+ sid = os.getenv('_WF_SESSION_ID')
+ if not sid:
+ from uuid import uuid4
+ sid = uuid4().hex
+ self.setvar('_WF_SESSION_ID', sid)
+
+ self._session_id = sid
+
+ return self._session_id
+
+ def setvar(self, name, value):
+ """Set a "global" workflow variable.
+
+ 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.
+
+ """
+ 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_item(self, title, subtitle='', 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.
+
+ See :meth:`Workflow.add_item() ` for the
+ main documentation.
+
+ 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, valid, uid, icon, icontype, type,
+ largetext, copytext, quicklookurl)
+
+ 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 '{0}{1}'.format(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 send_feedback(self):
+ """Print stored items to console/Alfred as JSON."""
+ json.dump(self.obj, sys.stdout)
+ sys.stdout.flush()