diff --git a/README.md b/README.md
index 8e17804..ff142d1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# whyliam.workflows.youdao
-## 有道翻译 workflow v2.0.0
+## 有道翻译 workflow v2.0.1
默认快捷键 `yd`,查看翻译结果。
@@ -20,7 +20,7 @@
### 下载
-[点击下载](https://github.com/liszd/whyliam.workflows.youdao/releases/download/2.0.0/whyliam.workflows.youdao.alfredworkflow)
+[点击下载](https://github.com/liszd/whyliam.workflows.youdao/releases/download/2.0.1/whyliam.workflows.youdao.alfredworkflow)
### 安装
diff --git a/info.plist b/info.plist
index b9e1832..474d756 100755
--- a/info.plist
+++ b/info.plist
@@ -324,7 +324,7 @@
readme
- 有道翻译 Workflow v2.0.0
+ 有道翻译 Workflow v2.0.1
默认快捷键 yd, 查看翻译结果。
diff --git a/version b/version
index 359a5b9..10bf840 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-2.0.0
\ No newline at end of file
+2.0.1
\ No newline at end of file
diff --git a/whyliam.workflows.youdao.alfredworkflow b/whyliam.workflows.youdao.alfredworkflow
index a86d265..307df9f 100644
Binary files a/whyliam.workflows.youdao.alfredworkflow and b/whyliam.workflows.youdao.alfredworkflow differ
diff --git a/workflow/__init__.py b/workflow/__init__.py
index 632f1f5..3069e51 100755
--- a/workflow/__init__.py
+++ b/workflow/__init__.py
@@ -14,7 +14,7 @@
# Workflow objects
from .workflow import Workflow, manager
-from .workflow3 import Workflow3
+from .workflow3 import Variables, Workflow3
# Exceptions
from .workflow import PasswordNotFound, KeychainError
@@ -67,6 +67,7 @@
__copyright__ = 'Copyright 2014 Dean Jackson'
__all__ = [
+ 'Variables',
'Workflow',
'Workflow3',
'manager',
diff --git a/workflow/background.py b/workflow/background.py
index cf883ed..7bda3f5 100755
--- a/workflow/background.py
+++ b/workflow/background.py
@@ -108,33 +108,31 @@ 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:
+ wf().logger.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'):
diff --git a/workflow/update.py b/workflow/update.py
index 468d024..bb8e9da 100755
--- a/workflow/update.py
+++ b/workflow/update.py
@@ -203,8 +203,8 @@ def download_workflow(url):
"""
filename = url.split("/")[-1]
- if (not url.endswith('.alfredworkflow') or
- not filename.endswith('.alfredworkflow')):
+ if (not filename.endswith('.alfredworkflow') and
+ not filename.endswith('.alfred3workflow')):
raise ValueError('Attachment `{0}` not a workflow'.format(filename))
local_path = os.path.join(tempfile.gettempdir(), filename)
diff --git a/workflow/version b/workflow/version
index 614245e..c8d3893 100755
--- a/workflow/version
+++ b/workflow/version
@@ -1 +1 @@
-1.24
\ No newline at end of file
+1.26
\ No newline at end of file
diff --git a/workflow/workflow.py b/workflow/workflow.py
index d824b1a..4fd8db4 100755
--- a/workflow/workflow.py
+++ b/workflow/workflow.py
@@ -21,6 +21,7 @@
from __future__ import print_function, unicode_literals
+import atexit
import binascii
from contextlib import contextmanager
import cPickle
@@ -804,6 +805,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):
@@ -817,11 +819,14 @@ 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:
@@ -830,6 +835,7 @@ def acquire(self, blocking=True):
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:
@@ -839,10 +845,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."""
@@ -1942,26 +1974,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
@@ -2400,7 +2436,11 @@ def update_available(self):
:returns: ``True`` if an update is available, else ``False``
"""
- update_data = self.cached_data('__workflow_update_status', max_age=0)
+ # Create a new workflow object to ensure standard serialiser
+ # is used (update.py is called without the user's settings)
+ update_data = Workflow().cached_data('__workflow_update_status',
+ max_age=0)
+
self.logger.debug('update_data : {0}'.format(update_data))
if not update_data or not update_data.get('available'):
diff --git a/workflow/workflow3.py b/workflow/workflow3.py
index fcfb65a..4625a50 100755
--- a/workflow/workflow3.py
+++ b/workflow/workflow3.py
@@ -33,6 +33,79 @@
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)
+
+ Attributes:
+ arg (unicode): Output value (``{query}``).
+ config (dict): Configuration for downstream workflow element.
+
+ """
+
+ def __init__(self, arg=None, **variables):
+ """Create a new `Variables` object.
+
+ Args:
+ arg (unicode, optional): Main output/``{query}``.
+ **variables: Workflow variables to set.
+
+ """
+ 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 ``Item3`` values for when specified modifier keys are pressed.
@@ -213,7 +286,7 @@ def obj(self):
Returns:
dict: Data suitable for Alfred 3 feedback.
"""
- # Basic values
+ # Required values
o = {'title': self.title,
'subtitle': self.subtitle,
'valid': self.valid}
@@ -221,8 +294,13 @@ def obj(self):
icon = {}
# Optional values
- if self.arg is not None:
- o['arg'] = self.arg
+
+ # arg & variables
+ v = Variables(self.arg, **self.variables)
+ v.config = self.config
+ arg = unicode(v)
+ if arg:
+ o['arg'] = arg
if self.autocomplete is not None:
o['autocomplete'] = self.autocomplete
@@ -245,11 +323,6 @@ def obj(self):
if icon:
o['icon'] = icon
- # Variables and config
- js = self._vars_and_config()
- if js:
- o['arg'] = js
-
# Modifiers
mods = self._modifiers()
if mods:
@@ -287,27 +360,6 @@ def _text(self):
return text
- def _vars_and_config(self):
- """Build `arg` including workflow variables and configuration.
-
- Returns:
- str: JSON string value for `arg` (or `None`)
- """
- if self.variables or self.config:
- d = {}
- if self.variables:
- d['variables'] = self.variables
-
- if self.config:
- d['config'] = self.config
-
- if self.arg is not None:
- d['arg'] = self.arg
-
- return json.dumps({'alfredworkflow': d})
-
- return None
-
def _modifiers(self):
"""Build `mods` dictionary for JSON feedback.
@@ -342,6 +394,7 @@ def __init__(self, **kwargs):
Workflow.__init__(self, **kwargs)
self.variables = {}
self._rerun = 0
+ self._session_id = None
@property
def _default_cachedir(self):
@@ -373,6 +426,28 @@ def rerun(self, seconds):
"""
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.
@@ -421,6 +496,70 @@ def add_item(self, title, subtitle='', arg=None, autocomplete=None,
self._items.append(item)
return item
+ def _mk_session_name(self, name):
+ """New cache name/key based on session ID."""
+ return '_wfsess-{0}-{1}'.format(self.session_id, 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 as for the
+ :meth:`~workflow.workflow.Workflow.cache_data` on
+ :class:`~workflow.workflow.Workflow`.
+
+ If ``session`` is ``True``, the ``name`` variable 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 as for the
+ :meth:`~workflow.workflow.Workflow.cached_data` on
+ :class:`~workflow.workflow.Workflow`.
+
+ If ``session`` is ``True``, the ``name`` variable 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):
+ """Remove *all* session data from the cache.
+
+ .. versionadded:: 1.25
+ """
+ def _is_session_file(filename):
+ return filename.startswith('_wfsess-')
+
+ self.clear_cache(_is_session_file)
+
@property
def obj(self):
"""Feedback formatted for JSON serialization.