util.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. #!/usr/bin/env python
  2. # encoding: utf-8
  3. #
  4. # Copyright (c) 2017 Dean Jackson <[email protected]>
  5. #
  6. # MIT Licence. See http://opensource.org/licenses/MIT
  7. #
  8. # Created on 2017-12-17
  9. #
  10. """A selection of helper functions useful for building workflows."""
  11. from __future__ import print_function, absolute_import
  12. import atexit
  13. from collections import namedtuple
  14. from contextlib import contextmanager
  15. import errno
  16. import fcntl
  17. import functools
  18. import json
  19. import os
  20. import signal
  21. import subprocess
  22. import sys
  23. from threading import Event
  24. import time
  25. # JXA scripts to call Alfred's API via the Scripting Bridge
  26. # {app} is automatically replaced with "Alfred 3" or
  27. # "com.runningwithcrayons.Alfred" depending on version.
  28. #
  29. # Open Alfred in search (regular) mode
  30. JXA_SEARCH = 'Application({app}).search({arg});'
  31. # Open Alfred's File Actions on an argument
  32. JXA_ACTION = 'Application({app}).action({arg});'
  33. # Open Alfred's navigation mode at path
  34. JXA_BROWSE = 'Application({app}).browse({arg});'
  35. # Set the specified theme
  36. JXA_SET_THEME = 'Application({app}).setTheme({arg});'
  37. # Call an External Trigger
  38. JXA_TRIGGER = 'Application({app}).runTrigger({arg}, {opts});'
  39. # Save a variable to the workflow configuration sheet/info.plist
  40. JXA_SET_CONFIG = 'Application({app}).setConfiguration({arg}, {opts});'
  41. # Delete a variable from the workflow configuration sheet/info.plist
  42. JXA_UNSET_CONFIG = 'Application({app}).removeConfiguration({arg}, {opts});'
  43. # Tell Alfred to reload a workflow from disk
  44. JXA_RELOAD_WORKFLOW = 'Application({app}).reloadWorkflow({arg});'
  45. class AcquisitionError(Exception):
  46. """Raised if a lock cannot be acquired."""
  47. AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid'])
  48. """Information about an installed application.
  49. Returned by :func:`appinfo`. All attributes are Unicode.
  50. .. py:attribute:: name
  51. Name of the application, e.g. ``u'Safari'``.
  52. .. py:attribute:: path
  53. Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
  54. .. py:attribute:: bundleid
  55. Application's bundle ID, e.g. ``u'com.apple.Safari'``.
  56. """
  57. def jxa_app_name():
  58. """Return name of application to call currently running Alfred.
  59. .. versionadded: 1.37
  60. Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending
  61. on which version of Alfred is running.
  62. This name is suitable for use with ``Application(name)`` in JXA.
  63. Returns:
  64. unicode: Application name or ID.
  65. """
  66. if os.getenv('alfred_version', '').startswith('3'):
  67. # Alfred 3
  68. return u'Alfred 3'
  69. # Alfred 4+
  70. return u'com.runningwithcrayons.Alfred'
  71. def unicodify(s, encoding='utf-8', norm=None):
  72. """Ensure string is Unicode.
  73. .. versionadded:: 1.31
  74. Decode encoded strings using ``encoding`` and normalise Unicode
  75. to form ``norm`` if specified.
  76. Args:
  77. s (str): String to decode. May also be Unicode.
  78. encoding (str, optional): Encoding to use on bytestrings.
  79. norm (None, optional): Normalisation form to apply to Unicode string.
  80. Returns:
  81. unicode: Decoded, optionally normalised, Unicode string.
  82. """
  83. if not isinstance(s, unicode):
  84. s = unicode(s, encoding)
  85. if norm:
  86. from unicodedata import normalize
  87. s = normalize(norm, s)
  88. return s
  89. def utf8ify(s):
  90. """Ensure string is a bytestring.
  91. .. versionadded:: 1.31
  92. Returns `str` objects unchanced, encodes `unicode` objects to
  93. UTF-8, and calls :func:`str` on anything else.
  94. Args:
  95. s (object): A Python object
  96. Returns:
  97. str: UTF-8 string or string representation of s.
  98. """
  99. if isinstance(s, str):
  100. return s
  101. if isinstance(s, unicode):
  102. return s.encode('utf-8')
  103. return str(s)
  104. def applescriptify(s):
  105. """Escape string for insertion into an AppleScript string.
  106. .. versionadded:: 1.31
  107. Replaces ``"`` with `"& quote &"`. Use this function if you want
  108. to insert a string into an AppleScript script:
  109. >>> applescriptify('g "python" test')
  110. 'g " & quote & "python" & quote & "test'
  111. Args:
  112. s (unicode): Unicode string to escape.
  113. Returns:
  114. unicode: Escaped string.
  115. """
  116. return s.replace(u'"', u'" & quote & "')
  117. def run_command(cmd, **kwargs):
  118. """Run a command and return the output.
  119. .. versionadded:: 1.31
  120. A thin wrapper around :func:`subprocess.check_output` that ensures
  121. all arguments are encoded to UTF-8 first.
  122. Args:
  123. cmd (list): Command arguments to pass to :func:`~subprocess.check_output`.
  124. **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`.
  125. Returns:
  126. str: Output returned by :func:`~subprocess.check_output`.
  127. """
  128. cmd = [utf8ify(s) for s in cmd]
  129. return subprocess.check_output(cmd, **kwargs)
  130. def run_applescript(script, *args, **kwargs):
  131. """Execute an AppleScript script and return its output.
  132. .. versionadded:: 1.31
  133. Run AppleScript either by filepath or code. If ``script`` is a valid
  134. filepath, that script will be run, otherwise ``script`` is treated
  135. as code.
  136. Args:
  137. script (str, optional): Filepath of script or code to run.
  138. *args: Optional command-line arguments to pass to the script.
  139. **kwargs: Pass ``lang`` to run a language other than AppleScript.
  140. Any other keyword arguments are passed to :func:`run_command`.
  141. Returns:
  142. str: Output of run command.
  143. """
  144. lang = 'AppleScript'
  145. if 'lang' in kwargs:
  146. lang = kwargs['lang']
  147. del kwargs['lang']
  148. cmd = ['/usr/bin/osascript', '-l', lang]
  149. if os.path.exists(script):
  150. cmd += [script]
  151. else:
  152. cmd += ['-e', script]
  153. cmd.extend(args)
  154. return run_command(cmd, **kwargs)
  155. def run_jxa(script, *args):
  156. """Execute a JXA script and return its output.
  157. .. versionadded:: 1.31
  158. Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
  159. Args:
  160. script (str): Filepath of script or code to run.
  161. *args: Optional command-line arguments to pass to script.
  162. Returns:
  163. str: Output of script.
  164. """
  165. return run_applescript(script, *args, lang='JavaScript')
  166. def run_trigger(name, bundleid=None, arg=None):
  167. """Call an Alfred External Trigger.
  168. .. versionadded:: 1.31
  169. If ``bundleid`` is not specified, the bundle ID of the calling
  170. workflow is used.
  171. Args:
  172. name (str): Name of External Trigger to call.
  173. bundleid (str, optional): Bundle ID of workflow trigger belongs to.
  174. arg (str, optional): Argument to pass to trigger.
  175. """
  176. bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
  177. appname = jxa_app_name()
  178. opts = {'inWorkflow': bundleid}
  179. if arg:
  180. opts['withArgument'] = arg
  181. script = JXA_TRIGGER.format(app=json.dumps(appname),
  182. arg=json.dumps(name),
  183. opts=json.dumps(opts, sort_keys=True))
  184. run_applescript(script, lang='JavaScript')
  185. def set_theme(theme_name):
  186. """Change Alfred's theme.
  187. .. versionadded:: 1.39.0
  188. Args:
  189. theme_name (unicode): Name of theme Alfred should use.
  190. """
  191. appname = jxa_app_name()
  192. script = JXA_SET_THEME.format(app=json.dumps(appname),
  193. arg=json.dumps(theme_name))
  194. run_applescript(script, lang='JavaScript')
  195. def set_config(name, value, bundleid=None, exportable=False):
  196. """Set a workflow variable in ``info.plist``.
  197. .. versionadded:: 1.33
  198. If ``bundleid`` is not specified, the bundle ID of the calling
  199. workflow is used.
  200. Args:
  201. name (str): Name of variable to set.
  202. value (str): Value to set variable to.
  203. bundleid (str, optional): Bundle ID of workflow variable belongs to.
  204. exportable (bool, optional): Whether variable should be marked
  205. as exportable (Don't Export checkbox).
  206. """
  207. bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
  208. appname = jxa_app_name()
  209. opts = {
  210. 'toValue': value,
  211. 'inWorkflow': bundleid,
  212. 'exportable': exportable,
  213. }
  214. script = JXA_SET_CONFIG.format(app=json.dumps(appname),
  215. arg=json.dumps(name),
  216. opts=json.dumps(opts, sort_keys=True))
  217. run_applescript(script, lang='JavaScript')
  218. def unset_config(name, bundleid=None):
  219. """Delete a workflow variable from ``info.plist``.
  220. .. versionadded:: 1.33
  221. If ``bundleid`` is not specified, the bundle ID of the calling
  222. workflow is used.
  223. Args:
  224. name (str): Name of variable to delete.
  225. bundleid (str, optional): Bundle ID of workflow variable belongs to.
  226. """
  227. bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
  228. appname = jxa_app_name()
  229. opts = {'inWorkflow': bundleid}
  230. script = JXA_UNSET_CONFIG.format(app=json.dumps(appname),
  231. arg=json.dumps(name),
  232. opts=json.dumps(opts, sort_keys=True))
  233. run_applescript(script, lang='JavaScript')
  234. def search_in_alfred(query=None):
  235. """Open Alfred with given search query.
  236. .. versionadded:: 1.39.0
  237. Omit ``query`` to simply open Alfred's main window.
  238. Args:
  239. query (unicode, optional): Search query.
  240. """
  241. query = query or u''
  242. appname = jxa_app_name()
  243. script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query))
  244. run_applescript(script, lang='JavaScript')
  245. def browse_in_alfred(path):
  246. """Open Alfred's filesystem navigation mode at ``path``.
  247. .. versionadded:: 1.39.0
  248. Args:
  249. path (unicode): File or directory path.
  250. """
  251. appname = jxa_app_name()
  252. script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path))
  253. run_applescript(script, lang='JavaScript')
  254. def action_in_alfred(paths):
  255. """Action the give filepaths in Alfred.
  256. .. versionadded:: 1.39.0
  257. Args:
  258. paths (list): Unicode paths to files/directories to action.
  259. """
  260. appname = jxa_app_name()
  261. script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths))
  262. run_applescript(script, lang='JavaScript')
  263. def reload_workflow(bundleid=None):
  264. """Tell Alfred to reload a workflow from disk.
  265. .. versionadded:: 1.39.0
  266. If ``bundleid`` is not specified, the bundle ID of the calling
  267. workflow is used.
  268. Args:
  269. bundleid (unicode, optional): Bundle ID of workflow to reload.
  270. """
  271. bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
  272. appname = jxa_app_name()
  273. script = JXA_RELOAD_WORKFLOW.format(app=json.dumps(appname),
  274. arg=json.dumps(bundleid))
  275. run_applescript(script, lang='JavaScript')
  276. def appinfo(name):
  277. """Get information about an installed application.
  278. .. versionadded:: 1.31
  279. Args:
  280. name (str): Name of application to look up.
  281. Returns:
  282. AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
  283. """
  284. cmd = [
  285. 'mdfind',
  286. '-onlyin', '/Applications',
  287. '-onlyin', '/System/Applications',
  288. '-onlyin', os.path.expanduser('~/Applications'),
  289. '(kMDItemContentTypeTree == com.apple.application &&'
  290. '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'
  291. .format(name)
  292. ]
  293. output = run_command(cmd).strip()
  294. if not output:
  295. return None
  296. path = output.split('\n')[0]
  297. cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path]
  298. bid = run_command(cmd).strip()
  299. if not bid: # pragma: no cover
  300. return None
  301. return AppInfo(unicodify(name), unicodify(path), unicodify(bid))
  302. @contextmanager
  303. def atomic_writer(fpath, mode):
  304. """Atomic file writer.
  305. .. versionadded:: 1.12
  306. Context manager that ensures the file is only written if the write
  307. succeeds. The data is first written to a temporary file.
  308. :param fpath: path of file to write to.
  309. :type fpath: ``unicode``
  310. :param mode: sames as for :func:`open`
  311. :type mode: string
  312. """
  313. suffix = '.{}.tmp'.format(os.getpid())
  314. temppath = fpath + suffix
  315. with open(temppath, mode) as fp:
  316. try:
  317. yield fp
  318. os.rename(temppath, fpath)
  319. finally:
  320. try:
  321. os.remove(temppath)
  322. except (OSError, IOError):
  323. pass
  324. class LockFile(object):
  325. """Context manager to protect filepaths with lockfiles.
  326. .. versionadded:: 1.13
  327. Creates a lockfile alongside ``protected_path``. Other ``LockFile``
  328. instances will refuse to lock the same path.
  329. >>> path = '/path/to/file'
  330. >>> with LockFile(path):
  331. >>> with open(path, 'wb') as fp:
  332. >>> fp.write(data)
  333. Args:
  334. protected_path (unicode): File to protect with a lockfile
  335. timeout (float, optional): Raises an :class:`AcquisitionError`
  336. if lock cannot be acquired within this number of seconds.
  337. If ``timeout`` is 0 (the default), wait forever.
  338. delay (float, optional): How often to check (in seconds) if
  339. lock has been released.
  340. Attributes:
  341. delay (float): How often to check (in seconds) whether the lock
  342. can be acquired.
  343. lockfile (unicode): Path of the lockfile.
  344. timeout (float): How long to wait to acquire the lock.
  345. """
  346. def __init__(self, protected_path, timeout=0.0, delay=0.05):
  347. """Create new :class:`LockFile` object."""
  348. self.lockfile = protected_path + '.lock'
  349. self._lockfile = None
  350. self.timeout = timeout
  351. self.delay = delay
  352. self._lock = Event()
  353. atexit.register(self.release)
  354. @property
  355. def locked(self):
  356. """``True`` if file is locked by this instance."""
  357. return self._lock.is_set()
  358. def acquire(self, blocking=True):
  359. """Acquire the lock if possible.
  360. If the lock is in use and ``blocking`` is ``False``, return
  361. ``False``.
  362. Otherwise, check every :attr:`delay` seconds until it acquires
  363. lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
  364. """
  365. if self.locked and not blocking:
  366. return False
  367. start = time.time()
  368. while True:
  369. # Raise error if we've been waiting too long to acquire the lock
  370. if self.timeout and (time.time() - start) >= self.timeout:
  371. raise AcquisitionError('lock acquisition timed out')
  372. # If already locked, wait then try again
  373. if self.locked:
  374. time.sleep(self.delay)
  375. continue
  376. # Create in append mode so we don't lose any contents
  377. if self._lockfile is None:
  378. self._lockfile = open(self.lockfile, 'a')
  379. # Try to acquire the lock
  380. try:
  381. fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
  382. self._lock.set()
  383. break
  384. except IOError as err: # pragma: no cover
  385. if err.errno not in (errno.EACCES, errno.EAGAIN):
  386. raise
  387. # Don't try again
  388. if not blocking: # pragma: no cover
  389. return False
  390. # Wait, then try again
  391. time.sleep(self.delay)
  392. return True
  393. def release(self):
  394. """Release the lock by deleting `self.lockfile`."""
  395. if not self._lock.is_set():
  396. return False
  397. try:
  398. fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
  399. except IOError: # pragma: no cover
  400. pass
  401. finally:
  402. self._lock.clear()
  403. self._lockfile = None
  404. try:
  405. os.unlink(self.lockfile)
  406. except (IOError, OSError): # pragma: no cover
  407. pass
  408. return True
  409. def __enter__(self):
  410. """Acquire lock."""
  411. self.acquire()
  412. return self
  413. def __exit__(self, typ, value, traceback):
  414. """Release lock."""
  415. self.release()
  416. def __del__(self):
  417. """Clear up `self.lockfile`."""
  418. self.release() # pragma: no cover
  419. class uninterruptible(object):
  420. """Decorator that postpones SIGTERM until wrapped function returns.
  421. .. versionadded:: 1.12
  422. .. important:: This decorator is NOT thread-safe.
  423. As of version 2.7, Alfred allows Script Filters to be killed. If
  424. your workflow is killed in the middle of critical code (e.g.
  425. writing data to disk), this may corrupt your workflow's data.
  426. Use this decorator to wrap critical functions that *must* complete.
  427. If the script is killed while a wrapped function is executing,
  428. the SIGTERM will be caught and handled after your function has
  429. finished executing.
  430. Alfred-Workflow uses this internally to ensure its settings, data
  431. and cache writes complete.
  432. """
  433. def __init__(self, func, class_name=''):
  434. """Decorate `func`."""
  435. self.func = func
  436. functools.update_wrapper(self, func)
  437. self._caught_signal = None
  438. def signal_handler(self, signum, frame):
  439. """Called when process receives SIGTERM."""
  440. self._caught_signal = (signum, frame)
  441. def __call__(self, *args, **kwargs):
  442. """Trap ``SIGTERM`` and call wrapped function."""
  443. self._caught_signal = None
  444. # Register handler for SIGTERM, then call `self.func`
  445. self.old_signal_handler = signal.getsignal(signal.SIGTERM)
  446. signal.signal(signal.SIGTERM, self.signal_handler)
  447. self.func(*args, **kwargs)
  448. # Restore old signal handler
  449. signal.signal(signal.SIGTERM, self.old_signal_handler)
  450. # Handle any signal caught during execution
  451. if self._caught_signal is not None:
  452. signum, frame = self._caught_signal
  453. if callable(self.old_signal_handler):
  454. self.old_signal_handler(signum, frame)
  455. elif self.old_signal_handler == signal.SIG_DFL:
  456. sys.exit(0)
  457. def __get__(self, obj=None, klass=None):
  458. """Decorator API."""
  459. return self.__class__(self.func.__get__(obj, klass),
  460. klass.__name__)