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.