diff --git a/alfred-workflow-1.30.zip b/alfred-workflow-1.31.zip similarity index 59% rename from alfred-workflow-1.30.zip rename to alfred-workflow-1.31.zip index c4f5c7a5..9150c742 100644 Binary files a/alfred-workflow-1.30.zip and b/alfred-workflow-1.31.zip differ diff --git a/bin/testone b/bin/testone index 35467c8d..af08299b 100755 --- a/bin/testone +++ b/bin/testone @@ -11,29 +11,57 @@ Run test script(s) with coverage for one package. Usage: testone ... - testone -h|--help + testone -h Options: - -h, --help Show this message and exit. + -h Show this message and exit. Example: - extras/testone workflow.notify tests/test_notify.py + testone workflow.notify tests/test_notify.py EOS } -log() { - echo "$@" > /dev/stderr +if [ -t 1 ]; then + red='\033[0;31m' + green='\033[0;32m' + nc='\033[0m' +else + red= + green= + nc= +fi + +function log() { + echo "$@" } +function fail() { + printf "${red}$@${nc}\n" +} + +function success() { + printf "${green}$@${nc}\n" +} + + +while getopts ":h" opt; do + case $opt in + h) + usage + exit 0 + ;; + \?) + log "Invalid option: -$OPTARG" + exit 1 + ;; + esac +done +shift $((OPTIND-1)) -if [[ "$1" = "-h" ]] || [[ "$1" = "--help" ]]; then - usage - exit 0 -fi if [[ "$1" = "" ]] || [[ "$2" = "" ]]; then - log "ERROR: Missing argument(s)." + fail "missing argument(s)" log "" usage exit 1 @@ -42,8 +70,7 @@ fi package="$1" shift -# export PYTEST_ADDOPTS="--cov-report=term" - +# Run tests pytest --cov="${package}" "$@" # ret1=$? ret1=${PIPESTATUS[0]} @@ -51,8 +78,8 @@ ret1=${PIPESTATUS[0]} echo case "$ret1" in - 0) log "TESTS OK";; - *) log "TESTS FAILED";; + 0) success "TESTS OK";; + *) fail "TESTS FAILED";; esac log "" @@ -64,10 +91,12 @@ ret2=${PIPESTATUS[0]} echo case "$ret2" in - 0) log "COVERAGE OK" ;; - *) log "COVERAGE FAILED" ;; + 0) success "COVERAGE OK" ;; + *) fail "COVERAGE FAILED" ;; esac +coverage erase + if [[ "$ret1" -ne 0 ]]; then exit $ret1 fi diff --git a/docs/Alfred-Workflow.docset.zip b/docs/Alfred-Workflow.docset.zip index 7b15002e..f3090819 100644 Binary files a/docs/Alfred-Workflow.docset.zip and b/docs/Alfred-Workflow.docset.zip differ diff --git a/docs/api/index.rst b/docs/api/index.rst index 8f425f94..598ad8c0 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -28,3 +28,4 @@ See the :ref:`main index ` for a list of all classes and methods. .. include:: notify.rst.inc +.. include:: util.rst.inc diff --git a/docs/api/util.rst.inc b/docs/api/util.rst.inc new file mode 100644 index 00000000..8da7b68d --- /dev/null +++ b/docs/api/util.rst.inc @@ -0,0 +1,56 @@ + +.. _api-util: + +Utilities & helpers +------------------- + + +.. currentmodule:: workflow.util + +A collection of functions and classes for common workflow-related tasks, such +as running AppleScript or JXA code, or calling an External Trigger. + + +.. autofunction:: utf8ify + +.. autofunction:: applescriptify + +.. autofunction:: run_command + +.. autofunction:: run_applescript + +.. autofunction:: run_jxa + +.. autofunction:: run_trigger + +.. autoclass:: AppInfo + +.. autofunction:: appinfo + + +Other helpers +^^^^^^^^^^^^^ + +These utility classes and functions are used internally by Alfred-Workflow, +but may also be useful in your workflow. + +.. autoclass:: LockFile + :members: + +.. autoclass:: uninterruptible + :members: + +.. autofunction:: atomic_writer + + +.. _api-util-exceptions: + + +Exceptions +^^^^^^^^^^ + +The following exceptions, may be raised by utility functions. + +.. autoexception:: AcquisitionError + +.. autoexception:: subprocess.CalledProcessError diff --git a/docs/api/workflow.rst.inc b/docs/api/workflow.rst.inc index 1608442c..2e12c02b 100644 --- a/docs/api/workflow.rst.inc +++ b/docs/api/workflow.rst.inc @@ -73,31 +73,10 @@ Exceptions .. currentmodule:: workflow Alfred-Workflow defines the following exceptions, which may be raised -by the Keychain API or :class:`LockFile`. +by the Keychain API. .. autoexception:: KeychainError .. autoexception:: PasswordNotFound .. autoexception:: workflow.workflow.PasswordExists - -.. autoexception:: workflow.workflow.AcquisitionError - - -.. _api-helpers: - -Utilities & helpers -^^^^^^^^^^^^^^^^^^^ - -.. currentmodule:: workflow.workflow - -These utility objects and functions are used internally by Alfred-Workflow, -but may also be useful in your workflow. - -.. autoclass:: LockFile - :members: - -.. autoclass:: uninterruptible - :members: - -.. autofunction:: atomic_writer diff --git a/run-tests.sh b/run-tests.sh index 7229ac50..c8784193 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -55,6 +55,8 @@ case "$ret2" in *) fail "COVERAGE FAILED" ;; esac +coverage erase + if [[ "$ret1" -ne 0 ]]; then exit $ret1 fi diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..3e3699d3 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2017 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2017-12-17 +# + +""" +""" + +from __future__ import print_function, absolute_import + +# from collections import namedtuple +import os +import shutil +import subprocess +import tempfile + +import pytest + +from workflow.util import ( + AS_TRIGGER, + appinfo, + applescriptify, + run_applescript, + run_command, + run_jxa, + run_trigger, + unicodify, + utf8ify, +) + + +class MockCall(object): + """Captures calls to `subprocess.check_output`.""" + + def __init__(self): + self.cmd = None + self._check_output_orig = None + + def set_up(self): + self._check_output_orig = subprocess.check_output + subprocess.check_output = self._check_output + + def tear_down(self): + subprocess.check_output = self._check_output_orig + + def _check_output(self, cmd, **kwargs): + self.cmd = cmd + + def __enter__(self): + self.set_up() + return self + + def __exit__(self, *args): + self.tear_down() + + +@pytest.fixture(scope='function') +def testfile(request): + """Test filepath.""" + tempdir = tempfile.mkdtemp() + testfile = os.path.join(tempdir, 'testfile') + + def rm(): + shutil.rmtree(tempdir) + + request.addfinalizer(rm) + + return testfile + + +def test_unicodify(): + """Unicode decoding.""" + data = [ + # input, normalisation form, expected output + (u'Köln', None, u'Köln'), + ('Köln', None, u'Köln'), + (u'Köln', 'NFC', u'K\xf6ln'), + (u'Köln', 'NFD', u'Ko\u0308ln'), + ('UTF-8', None, u'UTF-8'), + ] + + for b, n, x in data: + s = unicodify(b, norm=n) + assert s == x + assert isinstance(s, unicode) + + +def test_utf8ify(): + """UTF-8 encoding.""" + data = [ + # input, expected output + (u'Köln', 'Köln'), + ('UTF-8', 'UTF-8'), + (10, '10'), + ([1, 2, 3], '[1, 2, 3]'), + ] + + for s, x in data: + r = utf8ify(s) + assert x == r + assert isinstance(x, str) + + +def test_applescript_escape(): + """Escape AppleScript strings.""" + data = [ + # input, expected output + (u'no change', u'no change'), + (u'has "quotes" in it', u'has " & quote & "quotes" & quote & " in it'), + ] + + for s, x in data: + r = applescriptify(s) + assert x == r + assert isinstance(x, unicode) + + +def test_run_command(): + """Run command.""" + data = [ + # command, expected output + ([u'echo', '-n', 1], '1'), + ([u'echo', '-n', u'Köln'], 'Köln'), + ] + + for cmd, x in data: + r = run_command(cmd) + assert r == x + + with pytest.raises(subprocess.CalledProcessError): + run_command(['/usr/bin/false']) + + +def test_run_applescript(testfile): + """Run AppleScript.""" + # Run script passed as text + out = run_applescript('return "1"') + assert out.strip() == '1' + + # Run script file + with open(testfile, 'wb') as fp: + fp.write('return "1"') + + out = run_applescript(testfile) + assert out.strip() == '1' + + # Test args + script = """ + on run(argv) + return first item of argv + end run + """ + out = run_applescript(script, 1) + assert out.strip() == '1' + + +def test_run_jxa(testfile): + """Run JXA.""" + script = """ + function run(argv) { + return "1" + } + """ + # Run script passed as text + out = run_jxa(script) + assert out.strip() == '1' + + # Run script file + with open(testfile, 'wb') as fp: + fp.write(script) + + out = run_jxa(testfile) + assert out.strip() == '1' + + # Test args + script = """ + function run(argv) { + return argv[0] + } + """ + out = run_jxa(script, 1) + assert out.strip() == '1' + + +def test_run_trigger(): + """Call External Trigger.""" + name = 'test' + bundleid = 'net.deanishe.alfred-workflow' + arg = 'test arg' + argclause = 'with argument "test arg"' + + # With bundle ID + script = AS_TRIGGER.format(name=name, bundleid=bundleid, arg='') + cmd = ['/usr/bin/osascript', '-l', 'AppleScript', '-e', script] + with MockCall() as m: + run_trigger(name, bundleid) + assert m.cmd == cmd + + # With arg + script = AS_TRIGGER.format(name=name, bundleid=bundleid, arg=argclause) + cmd = ['/usr/bin/osascript', '-l', 'AppleScript', '-e', script] + with MockCall() as m: + run_trigger(name, bundleid, arg) + assert m.cmd == cmd + + # With bundle ID from env + os.environ['alfred_workflow_bundleid'] = bundleid + try: + script = AS_TRIGGER.format(name=name, bundleid=bundleid, arg='') + cmd = ['/usr/bin/osascript', '-l', 'AppleScript', '-e', script] + with MockCall() as m: + run_trigger(name) + assert m.cmd == cmd + finally: + del os.environ['alfred_workflow_bundleid'] + + +def test_appinfo(): + """App info for Safari.""" + name = u'Safari' + bundleid = u'com.apple.Safari' + path = u'/Applications/Safari.app' + + info = appinfo(name) + assert info is not None + assert info.name == name + assert info.path == path + assert info.bundleid == bundleid + for s in info: + assert isinstance(s, unicode) + + # Non-existant app + info = appinfo("Big, Hairy Man's Special Breakfast Pants") + assert info is None + + +if __name__ == '__main__': # pragma: no cover + pytest.main([__file__]) diff --git a/tests/test_workflow_atomic.py b/tests/test_util_atomic.py similarity index 95% rename from tests/test_workflow_atomic.py rename to tests/test_util_atomic.py index d0b6bc0b..b8669099 100755 --- a/tests/test_workflow_atomic.py +++ b/tests/test_util_atomic.py @@ -8,7 +8,7 @@ # Created on 2017-05-06 # -"""Unit tests for :func:`~workflow.workflow.atomic_writer`.""" +"""Unit tests for :func:`~workflow.util.atomic_writer`.""" from __future__ import print_function @@ -19,7 +19,7 @@ from util import DEFAULT_SETTINGS -from workflow.workflow import atomic_writer +from workflow.util import atomic_writer def _settings(tempdir): diff --git a/tests/test_workflow_lockfile.py b/tests/test_util_lockfile.py similarity index 60% rename from tests/test_workflow_lockfile.py rename to tests/test_util_lockfile.py index 7cf42de6..887daccd 100755 --- a/tests/test_workflow_lockfile.py +++ b/tests/test_util_lockfile.py @@ -16,12 +16,14 @@ from multiprocessing import Pool import os import shutil -import subprocess +import sys import tempfile +import traceback import pytest -from workflow.workflow import AcquisitionError, LockFile, Settings +from workflow.util import AcquisitionError, LockFile +from workflow.workflow import Settings Paths = namedtuple('Paths', 'testfile lockfile') @@ -29,7 +31,7 @@ @pytest.fixture(scope='function') def paths(request): - """Ensure `info.plist` exists in the working directory.""" + """Test and lock file paths.""" tempdir = tempfile.mkdtemp() testfile = os.path.join(tempdir, 'myfile.txt') @@ -53,55 +55,55 @@ def test_lockfile_created(paths): assert not os.path.exists(paths.lockfile) -def test_lockfile_contains_pid(paths): - """Lockfile contains process PID.""" - assert not os.path.exists(paths.testfile) - assert not os.path.exists(paths.lockfile) +# def test_lockfile_contains_pid(paths): +# """Lockfile contains process PID.""" +# assert not os.path.exists(paths.testfile) +# assert not os.path.exists(paths.lockfile) - with LockFile(paths.testfile, timeout=0.2): - with open(paths.lockfile) as fp: - s = fp.read() +# with LockFile(paths.testfile, timeout=0.2): +# with open(paths.lockfile) as fp: +# s = fp.read() - assert s == str(os.getpid()) +# assert s == str(os.getpid()) -def test_invalid_lockfile_removed(paths): - """Invalid lockfile removed.""" - assert not os.path.exists(paths.testfile) - assert not os.path.exists(paths.lockfile) +# def test_invalid_lockfile_removed(paths): +# """Invalid lockfile removed.""" +# assert not os.path.exists(paths.testfile) +# assert not os.path.exists(paths.lockfile) - # create invalid lock file - with open(paths.lockfile, 'wb') as fp: - fp.write("dean woz 'ere!") +# # create invalid lock file +# with open(paths.lockfile, 'wb') as fp: +# fp.write("dean woz 'ere!") - # the above invalid lockfile should be removed and - # replaced with one containing this process's PID - with LockFile(paths.testfile, timeout=0.2): - with open(paths.lockfile) as fp: - s = fp.read() +# # the above invalid lockfile should be removed and +# # replaced with one containing this process's PID +# with LockFile(paths.testfile, timeout=0.2): +# with open(paths.lockfile) as fp: +# s = fp.read() - assert s == str(os.getpid()) +# assert s == str(os.getpid()) -def test_stale_lockfile_removed(paths): - """Stale lockfile removed.""" - assert not os.path.exists(paths.testfile) - assert not os.path.exists(paths.lockfile) +# def test_stale_lockfile_removed(paths): +# """Stale lockfile removed.""" +# assert not os.path.exists(paths.testfile) +# assert not os.path.exists(paths.lockfile) - p = subprocess.Popen('true') - pid = p.pid - p.wait() - # create invalid lock file - with open(paths.lockfile, 'wb') as fp: - fp.write(str(pid)) +# p = subprocess.Popen('true') +# pid = p.pid +# p.wait() +# # create invalid lock file +# with open(paths.lockfile, 'wb') as fp: +# fp.write(str(pid)) - # the above invalid lockfile should be removed and - # replaced with one containing this process's PID - with LockFile(paths.testfile, timeout=0.2): - with open(paths.lockfile) as fp: - s = fp.read() +# # the above invalid lockfile should be removed and +# # replaced with one containing this process's PID +# with LockFile(paths.testfile, timeout=0.2): +# with open(paths.lockfile) as fp: +# s = fp.read() - assert s == str(os.getpid()) +# assert s == str(os.getpid()) def test_sequential_access(paths): @@ -109,7 +111,7 @@ def test_sequential_access(paths): assert not os.path.exists(paths.testfile) assert not os.path.exists(paths.lockfile) - lock = LockFile(paths.testfile, 0.2) + lock = LockFile(paths.testfile, 0.1) with lock: assert lock.locked @@ -117,6 +119,8 @@ def test_sequential_access(paths): with pytest.raises(AcquisitionError): lock.acquire(True) + assert lock.release() is False # lock already released + assert not os.path.exists(paths.lockfile) @@ -157,9 +161,14 @@ def test_concurrent_access(paths): def _write_settings(args): """Write a new value to the Settings.""" paths, key, value = args - s = Settings(paths.testfile) - s[key] = value - print('Settings[{0}] = {1}'.format(key, value)) + try: + s = Settings(paths.testfile) + s[key] = value + print('Settings[{0}] = {1}'.format(key, value)) + except Exception as err: + print('error opening settings (%s): %s' % (key, traceback.format_exc()), + file=sys.stderr) + return err def test_concurrent_settings(paths): @@ -172,10 +181,13 @@ def test_concurrent_settings(paths): Settings(paths.testfile, defaults) data = [(paths, 'thread_{0}'.format(i), 'value_{0}'.format(i)) - for i in range(1, 6)] + for i in range(1, 10)] pool = Pool(5) - pool.map(_write_settings, data) + errs = pool.map(_write_settings, data) + errs = [e for e in errs if e is not None] + + assert errs == [] # Check settings file is still valid JSON # and that *something* was added to it. diff --git a/tests/test_workflow_uninterruptible.py b/tests/test_util_uninterruptible.py similarity index 97% rename from tests/test_workflow_uninterruptible.py rename to tests/test_util_uninterruptible.py index 662a6b73..b21a0cfd 100755 --- a/tests/test_workflow_uninterruptible.py +++ b/tests/test_util_uninterruptible.py @@ -17,7 +17,7 @@ import pytest -from workflow.workflow import uninterruptible +from workflow.util import uninterruptible class Target(object): diff --git a/tests/test_workflow3.py b/tests/test_workflow3.py index 64fab6cd..7d6de76c 100755 --- a/tests/test_workflow3.py +++ b/tests/test_workflow3.py @@ -19,7 +19,7 @@ import pytest -from workflow.workflow3 import Variables, Workflow3 +from workflow import ICON_WARNING, Variables, Workflow3 def test_required_optional(info3): @@ -31,7 +31,7 @@ def test_required_optional(info3): assert o['title'] == 'Title' assert o['valid'] is False assert o['subtitle'] == '' - assert set(o.keys()) == set(['title', 'valid', 'subtitle']) + assert set(o.keys()) == {'title', 'valid', 'subtitle'} def test_optional(info3): @@ -100,6 +100,33 @@ def test_feedback(info3): assert items[i]['title'] == 'Title {0:2d}'.format(i + 1) +def test_warn_empty(info3): + """Workflow3: Warn empty.""" + wf = Workflow3() + it = wf.warn_empty(u'My warning') + + assert it.title == u'My warning' + assert it.subtitle == u'' + assert it.valid is False + assert it.icon == ICON_WARNING + + o = wf.obj + + assert len(o['items']) == 1 + assert o['items'][0] == it.obj + + # Non-empty feedback + wf = Workflow3() + wf.add_item(u'Real item') + it = wf.warn_empty(u'Warning') + + assert it is None + + o = wf.obj + + assert len(o['items']) == 1 + + def test_arg_variables(info3): """Item3: Variables in arg.""" wf = Workflow3() @@ -256,7 +283,7 @@ def test_modifiers(info3): o = it.obj assert 'mods' in o - assert set(o['mods'].keys()) == set(['cmd']) + assert set(o['mods'].keys()) == {'cmd'} m = o['mods']['cmd'] assert m['valid'] is True @@ -300,7 +327,7 @@ def test_item_config(info3): o = it.obj assert 'config' in o - assert set(o['config'].keys()) == set(['var1']) + assert set(o['config'].keys()) == {'var1'} assert o['config']['var1'] == 'val1' assert 'mods' in o diff --git a/workflow/util.py b/workflow/util.py new file mode 100644 index 00000000..c2a28905 --- /dev/null +++ b/workflow/util.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2017 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2017-12-17 +# + +"""A selection of helper functions useful for building workflows.""" + +from __future__ import print_function, absolute_import + +import atexit +from collections import namedtuple +from contextlib import contextmanager +import errno +import fcntl +import functools +import os +import signal +import subprocess +import sys +from threading import Event +import time + +# AppleScript to call an External Trigger in Alfred +AS_TRIGGER = """ +tell application "Alfred 3" +run trigger "{name}" in workflow "{bundleid}" {arg} +end tell +""" + + +class AcquisitionError(Exception): + """Raised if a lock cannot be acquired.""" + + +AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) +"""Information about an installed application. + +Returned by :func:`appinfo`. All attributes are Unicode. + +.. py:attribute:: name + + Name of the application, e.g. ``u'Safari'``. + +.. py:attribute:: path + + Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. + +.. py:attribute:: bundleid + + Application's bundle ID, e.g. ``u'com.apple.Safari'``. +""" + + +def unicodify(s, encoding='utf-8', norm=None): + """Ensure string is Unicode. + + .. versionadded:: 1.31 + + Decode encoded strings using ``encoding`` and normalise Unicode + to form ``norm`` if specified. + + Args: + s (str): String to decode. May also be Unicode. + encoding (str, optional): Encoding to use on bytestrings. + norm (None, optional): Normalisation form to apply to Unicode string. + + Returns: + unicode: Decoded, optionally normalised, Unicode string. + + """ + if not isinstance(s, unicode): + s = unicode(s, encoding) + + if norm: + from unicodedata import normalize + s = normalize(norm, s) + + return s + + +def utf8ify(s): + """Ensure string is a bytestring. + + .. versionadded:: 1.31 + + Returns `str` objects unchanced, encodes `unicode` objects to + UTF-8, and calls :func:`str` on anything else. + + Args: + s (object): A Python object + + Returns: + str: UTF-8 string or string representation of s. + """ + if isinstance(s, str): + return s + + if isinstance(s, unicode): + return s.encode('utf-8') + + return str(s) + + +def applescriptify(s): + """Escape string for insertion into an AppleScript string. + + .. versionadded:: 1.31 + + Replaces ``"`` with `"& quote &"`. Use this function if you want + + to insert a string into an AppleScript script: + >>> script = 'tell application "Alfred 3" to search "{}"' + >>> query = 'g "python" test' + >>> script.format(applescriptify(query)) + 'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"' + + Args: + s (unicode): Unicode string to escape. + + Returns: + unicode: Escaped string + """ + return s.replace(u'"', u'" & quote & "') + + +def run_command(cmd, **kwargs): + """Run a command and return the output. + + .. versionadded:: 1.31 + + A thin wrapper around :func:`subprocess.check_output` that ensures + all arguments are encoded to UTF-8 first. + + Args: + cmd (list): Command arguments to pass to ``check_output``. + **kwargs: Keyword arguments to pass to ``check_output``. + + Returns: + str: Output returned by ``check_output``. + """ + cmd = [utf8ify(s) for s in cmd] + return subprocess.check_output(cmd, **kwargs) + + +def run_applescript(script, *args, **kwargs): + """Execute an AppleScript script and return its output. + + .. versionadded:: 1.31 + + Run AppleScript either by filepath or code. If ``script`` is a valid + filepath, that script will be run, otherwise ``script`` is treated + as code. + + Args: + script (str, optional): Filepath of script or code to run. + *args: Optional command-line arguments to pass to the script. + **kwargs: Pass ``lang`` to run a language other than AppleScript. + + Returns: + str: Output of run command. + + """ + cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')] + + if os.path.exists(script): + cmd += [script] + else: + cmd += ['-e', script] + + cmd.extend(args) + + return run_command(cmd) + + +def run_jxa(script, *args): + """Execute a JXA script and return its output. + + .. versionadded:: 1.31 + + Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. + + Args: + script (str): Filepath of script or code to run. + *args: Optional command-line arguments to pass to script. + + Returns: + str: Output of script. + """ + return run_applescript(script, *args, lang='JavaScript') + + +def run_trigger(name, bundleid=None, arg=None): + """Call an Alfred External Trigger. + + .. versionadded:: 1.31 + + If ``bundleid`` is not specified, reads the bundle ID of the current + workflow from Alfred's environment variables. + + Args: + name (str): Name of External Trigger to call. + bundleid (str, optional): Bundle ID of workflow trigger belongs to. + arg (str, optional): Argument to pass to trigger. + """ + if not bundleid: + bundleid = os.getenv('alfred_workflow_bundleid') + + if arg: + arg = 'with argument "{}"'.format(applescriptify(arg)) + else: + arg = '' + + script = AS_TRIGGER.format(name=name, bundleid=bundleid, + arg=arg) + + run_applescript(script) + + +def appinfo(name): + """Get information about an installed application. + + .. versionadded:: 1.31 + + Args: + name (str): Name of application to look up. + + Returns: + AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. + """ + cmd = ['/usr/bin/lsappinfo', 'info', name] + output = run_command(cmd).strip() + if not output: # Application isn't installed + return None + + path = bid = None + for line in output.split('\n'): + line = line.strip() + if '=' in line: + k, v = line.split('=', 1) + v = v.strip('"') + + if k == 'bundleID': + bid = v + elif k == 'bundle path': + path = v.rstrip('/') + + if bid and path: + return AppInfo(*[unicodify(s) for s in (name, path, bid)]) + + return None # pragma: no cover + + +@contextmanager +def atomic_writer(fpath, mode): + """Atomic file writer. + + .. versionadded:: 1.12 + + Context manager that ensures the file is only written if the write + succeeds. The data is first written to a temporary file. + + :param fpath: path of file to write to. + :type fpath: ``unicode`` + :param mode: sames as for :func:`open` + :type mode: string + + """ + suffix = '.{}.tmp'.format(os.getpid()) + temppath = fpath + suffix + with open(temppath, mode) as fp: + try: + yield fp + os.rename(temppath, fpath) + finally: + try: + os.remove(temppath) + except (OSError, IOError): + pass + + +class LockFile(object): + """Context manager to protect filepaths with lockfiles. + + .. versionadded:: 1.13 + + Creates a lockfile alongside ``protected_path``. Other ``LockFile`` + instances will refuse to lock the same path. + + >>> path = '/path/to/file' + >>> with LockFile(path): + >>> with open(path, 'wb') as fp: + >>> fp.write(data) + + Args: + protected_path (unicode): File to protect with a lockfile + timeout (float, optional): Raises an :class:`AcquisitionError` + if lock cannot be acquired within this number of seconds. + If ``timeout`` is 0 (the default), wait forever. + delay (float, optional): How often to check (in seconds) if + lock has been released. + + Attributes: + delay (float): How often to check (in seconds) whether the lock + can be acquired. + lockfile (unicode): Path of the lockfile. + timeout (float): How long to wait to acquire the lock. + + """ + + def __init__(self, protected_path, timeout=0.0, delay=0.05): + """Create new :class:`LockFile` object.""" + self.lockfile = protected_path + '.lock' + self._lockfile = None + self.timeout = timeout + self.delay = delay + self._lock = Event() + atexit.register(self.release) + + @property + def locked(self): + """``True`` if file is locked by this instance.""" + return self._lock.is_set() + + def acquire(self, blocking=True): + """Acquire the lock if possible. + + If the lock is in use and ``blocking`` is ``False``, return + ``False``. + + Otherwise, check every :attr:`delay` seconds until it acquires + lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. + + """ + if self.locked and not blocking: + return False + + start = time.time() + while True: + + # Raise error if we've been waiting too long to acquire the lock + if self.timeout and (time.time() - start) >= self.timeout: + raise AcquisitionError('lock acquisition timed out') + + # If already locked, wait then try again + if self.locked: + time.sleep(self.delay) + continue + + # Create in append mode so we don't lose any contents + if self._lockfile is None: + self._lockfile = open(self.lockfile, 'a') + + # Try to acquire the lock + try: + fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) + self._lock.set() + break + except IOError as err: # pragma: no cover + if err.errno not in (errno.EACCES, errno.EAGAIN): + raise + + # Don't try again + if not blocking: # pragma: no cover + return False + + # Wait, then try again + time.sleep(self.delay) + + return True + + def release(self): + """Release the lock by deleting `self.lockfile`.""" + if not self._lock.is_set(): + return False + + try: + fcntl.lockf(self._lockfile, fcntl.LOCK_UN) + except IOError: # pragma: no cover + pass + finally: + self._lock.clear() + self._lockfile = None + try: + os.unlink(self.lockfile) + except (IOError, OSError): # pragma: no cover + pass + + return True + + def __enter__(self): + """Acquire lock.""" + self.acquire() + return self + + def __exit__(self, typ, value, traceback): + """Release lock.""" + self.release() + + def __del__(self): + """Clear up `self.lockfile`.""" + self.release() # pragma: no cover + + +class uninterruptible(object): + """Decorator that postpones SIGTERM until wrapped function returns. + + .. versionadded:: 1.12 + + .. important:: This decorator is NOT thread-safe. + + As of version 2.7, Alfred allows Script Filters to be killed. If + your workflow is killed in the middle of critical code (e.g. + writing data to disk), this may corrupt your workflow's data. + + Use this decorator to wrap critical functions that *must* complete. + If the script is killed while a wrapped function is executing, + the SIGTERM will be caught and handled after your function has + finished executing. + + Alfred-Workflow uses this internally to ensure its settings, data + and cache writes complete. + + """ + + def __init__(self, func, class_name=''): + """Decorate `func`.""" + self.func = func + functools.update_wrapper(self, func) + self._caught_signal = None + + def signal_handler(self, signum, frame): + """Called when process receives SIGTERM.""" + self._caught_signal = (signum, frame) + + def __call__(self, *args, **kwargs): + """Trap ``SIGTERM`` and call wrapped function.""" + self._caught_signal = None + # Register handler for SIGTERM, then call `self.func` + self.old_signal_handler = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, self.signal_handler) + + self.func(*args, **kwargs) + + # Restore old signal handler + signal.signal(signal.SIGTERM, self.old_signal_handler) + + # Handle any signal caught during execution + if self._caught_signal is not None: + signum, frame = self._caught_signal + if callable(self.old_signal_handler): + self.old_signal_handler(signum, frame) + elif self.old_signal_handler == signal.SIG_DFL: + sys.exit(0) + + def __get__(self, obj=None, klass=None): + """Decorator API.""" + return self.__class__(self.func.__get__(obj, klass), + klass.__name__) diff --git a/workflow/version b/workflow/version index c1f588af..92443952 100644 --- a/workflow/version +++ b/workflow/version @@ -1 +1 @@ -1.30 \ No newline at end of file +1.31 \ No newline at end of file diff --git a/workflow/workflow.py b/workflow/workflow.py index 77300a88..c2c16169 100644 --- a/workflow/workflow.py +++ b/workflow/workflow.py @@ -21,13 +21,9 @@ from __future__ import print_function, unicode_literals -import atexit import binascii -from contextlib import contextmanager import cPickle from copy import deepcopy -import errno -import functools import json import logging import logging.handlers @@ -36,7 +32,6 @@ import plistlib import re import shutil -import signal import string import subprocess import sys @@ -48,6 +43,12 @@ except ImportError: # pragma: no cover import xml.etree.ElementTree as ET +from util import ( + AcquisitionError, # imported to maintain API + atomic_writer, + LockFile, + uninterruptible, +) #: Sentinel for properties that haven't been set yet (that might #: correctly have the value ``None``) @@ -444,12 +445,9 @@ #################################################################### -# Lockfile and Keychain access errors +# Keychain access errors #################################################################### -class AcquisitionError(Exception): - """Raised if a lock cannot be acquired.""" - class KeychainError(Exception): """Raised for unknown Keychain errors. @@ -800,212 +798,6 @@ def elem(self): return root -class LockFile(object): - """Context manager to protect filepaths with lockfiles. - - .. versionadded:: 1.13 - - Creates a lockfile alongside ``protected_path``. Other ``LockFile`` - instances will refuse to lock the same path. - - >>> path = '/path/to/file' - >>> with LockFile(path): - >>> with open(path, 'wb') as fp: - >>> fp.write(data) - - Args: - protected_path (unicode): File to protect with a lockfile - timeout (float, optional): Raises an :class:`AcquisitionError` - if lock cannot be acquired within this number of seconds. - If ``timeout`` is 0 (the default), wait forever. - delay (float, optional): How often to check (in seconds) if - lock has been released. - - Attributes: - delay (float): How often to check (in seconds) whether the lock - can be acquired. - lockfile (unicode): Path of the lockfile. - timeout (float): How long to wait to acquire the lock. - - """ - - def __init__(self, protected_path, timeout=0.0, delay=0.05): - """Create new :class:`LockFile` object.""" - self.lockfile = protected_path + '.lock' - self.timeout = timeout - self.delay = delay - self._locked = False - atexit.register(self.release) - - @property - def locked(self): - """``True`` if file is locked by this instance.""" - return self._locked - - def acquire(self, blocking=True): - """Acquire the lock if possible. - - If the lock is in use and ``blocking`` is ``False``, return - ``False``. - - Otherwise, check every :attr:`delay` seconds until it acquires - lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. - - """ - 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(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') - if not blocking: - return False - time.sleep(self.delay) - - 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 - try: - os.unlink(self.lockfile) - except (OSError, IOError) as err: # pragma: no cover - if err.errno != 2: - raise err - - def __enter__(self): - """Acquire lock.""" - self.acquire() - return self - - def __exit__(self, typ, value, traceback): - """Release lock.""" - self.release() - - def __del__(self): - """Clear up `self.lockfile`.""" - if self._locked: # pragma: no cover - self.release() - - -@contextmanager -def atomic_writer(file_path, mode): - """Atomic file writer. - - .. versionadded:: 1.12 - - Context manager that ensures the file is only written if the write - succeeds. The data is first written to a temporary file. - - :param 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 fp: - try: - yield fp - os.rename(temp_file_path, file_path) - finally: - try: - os.remove(temp_file_path) - except (OSError, IOError): - pass - - -class uninterruptible(object): - """Decorator that postpones SIGTERM until wrapped function returns. - - .. versionadded:: 1.12 - - .. important:: This decorator is NOT thread-safe. - - As of version 2.7, Alfred allows Script Filters to be killed. If - your workflow is killed in the middle of critical code (e.g. - writing data to disk), this may corrupt your workflow's data. - - Use this decorator to wrap critical functions that *must* complete. - If the script is killed while a wrapped function is executing, - the SIGTERM will be caught and handled after your function has - finished executing. - - Alfred-Workflow uses this internally to ensure its settings, data - and cache writes complete. - - """ - - def __init__(self, func, class_name=''): - """Decorate `func`.""" - self.func = func - functools.update_wrapper(self, func) - self._caught_signal = None - - def signal_handler(self, signum, frame): - """Called when process receives SIGTERM.""" - self._caught_signal = (signum, frame) - - def __call__(self, *args, **kwargs): - """Trap ``SIGTERM`` and call wrapped function.""" - self._caught_signal = None - # Register handler for SIGTERM, then call `self.func` - self.old_signal_handler = signal.getsignal(signal.SIGTERM) - signal.signal(signal.SIGTERM, self.signal_handler) - - self.func(*args, **kwargs) - - # Restore old signal handler - signal.signal(signal.SIGTERM, self.old_signal_handler) - - # Handle any signal caught during execution - if self._caught_signal is not None: - signum, frame = self._caught_signal - if callable(self.old_signal_handler): - self.old_signal_handler(signum, frame) - elif self.old_signal_handler == signal.SIG_DFL: - sys.exit(0) - - def __get__(self, obj=None, klass=None): - """Decorator API.""" - return self.__class__(self.func.__get__(obj, klass), - klass.__name__) - - class Settings(dict): """A dictionary that saves itself when changed. @@ -1039,13 +831,15 @@ def __init__(self, filepath, defaults=None): def _load(self): """Load cached settings from JSON file `self._filepath`.""" + data = {} + with LockFile(self._filepath, 0.5): + with open(self._filepath, 'rb') as fp: + data.update(json.load(fp)) + + self._original = deepcopy(data) + self._nosave = True - d = {} - with open(self._filepath, 'rb') as file_obj: - for key, value in json.load(file_obj, encoding='utf-8').items(): - d[key] = value - self.update(d) - self._original = deepcopy(d) + self.update(data) self._nosave = False @uninterruptible @@ -1058,13 +852,13 @@ def save(self): """ if self._nosave: return + data = {} data.update(self) - # for key, value in self.items(): - # data[key] = value - with LockFile(self._filepath): - with atomic_writer(self._filepath, 'wb') as file_obj: - json.dump(data, file_obj, sort_keys=True, indent=2, + + with LockFile(self._filepath, 0.5): + with atomic_writer(self._filepath, 'wb') as fp: + json.dump(data, fp, sort_keys=True, indent=2, encoding='utf-8') # dict methods @@ -1594,9 +1388,12 @@ def logger(self): return self._logger # Initialise new logger and optionally handlers - logger = logging.getLogger('workflow') + logger = logging.getLogger('') - if not len(logger.handlers): # Only add one set of handlers + # Only add one set of handlers + # Exclude from coverage, as pytest will have configured the + # root logger already + if not len(logger.handlers): # pragma: no cover fmt = logging.Formatter( '%(asctime)s %(filename)s:%(lineno)s' @@ -2247,6 +2044,9 @@ def run(self, func, text_errors=False): """ start = time.time() + # Write to debugger to ensure "real" output starts on a new line + print('.', file=sys.stderr) + # Call workflow's entry function/method within a try-except block # to catch any errors and display an error message in Alfred try: diff --git a/workflow/workflow3.py b/workflow/workflow3.py index c1edebba..3ffd95b1 100644 --- a/workflow/workflow3.py +++ b/workflow/workflow3.py @@ -29,7 +29,7 @@ import os import sys -from .workflow import Workflow +from .workflow import ICON_WARNING, Workflow class Variables(dict): @@ -681,6 +681,31 @@ def obj(self): o['rerun'] = self.rerun return o + def warn_empty(self, title, subtitle=u'', icon=None): + """Add a warning to feedback if there are no items. + + .. versionadded:: 1.31 + + Add a "warning" item to Alfred feedback if no other items + have been added. This is a handy shortcut to prevent Alfred + from showing its fallback searches, which is does if no + items are returned. + + Args: + title (unicode): Title of feedback item. + subtitle (unicode, optional): Subtitle of feedback item. + icon (str, optional): Icon for feedback item. If not + specified, ``ICON_WARNING`` is used. + + Returns: + Item3: Newly-created item. + """ + if len(self._items): + return + + icon = icon or ICON_WARNING + return self.add_item(title, subtitle, icon=icon) + def send_feedback(self): """Print stored items to console/Alfred as JSON.""" json.dump(self.obj, sys.stdout)