├── workflow ├── version ├── .alfredversionchecked ├── Notify.tgz ├── __init__.py ├── background.py ├── notify.py ├── update.py ├── util.py ├── web.py └── workflow3.py ├── icon.png ├── pic ├── gli.png ├── glm.png ├── glp.png ├── glr.png ├── gltodo.png ├── gltoken.png └── glurl.png ├── README.md ├── .gitignore ├── gitlab.py ├── main.py └── info.plist /workflow/version: -------------------------------------------------------------------------------- 1 | 1.35 -------------------------------------------------------------------------------- /workflow/.alfredversionchecked: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/icon.png -------------------------------------------------------------------------------- /pic/gli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/pic/gli.png -------------------------------------------------------------------------------- /pic/glm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/pic/glm.png -------------------------------------------------------------------------------- /pic/glp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/pic/glp.png -------------------------------------------------------------------------------- /pic/glr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/pic/glr.png -------------------------------------------------------------------------------- /pic/gltodo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/pic/gltodo.png -------------------------------------------------------------------------------- /pic/gltoken.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/pic/gltoken.png -------------------------------------------------------------------------------- /pic/glurl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/pic/glurl.png -------------------------------------------------------------------------------- /workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangshub/Alfred-Gitlab-Tool/HEAD/workflow/Notify.tgz -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alfred Gitlab Tool 2 | 3 | Alfred Gitlab dashboard workflow. Navigate projects, issues, merges and todos quickly. 4 | 5 | ## Install 6 | 7 | - Dependency [Alfred3](https://www.alfredapp.com/) 8 | - Download [Gitlab workflow release](https://github.com/wangshub/Alfred-Gitlab-Tool/releases) 9 | - Click to install. 10 | 11 | ### Configure 12 | 13 | - **`glurl`: Set Gitlab url.** 14 | 15 | ![](./pic/glurl.png) 16 | 17 | - **`gltoken`: Set Gitlab token.** 18 | 19 | ![](./pic/gltoken.png) 20 | 21 | ### Feature 22 | 23 | - **`glp`: Fuzzy search Gitlab projects.** 24 | 25 | ![](./pic/glp.png) 26 | 27 | - **`glm`: Navigate Gitlab merge requests assigned to you.** 28 | 29 | ![](./pic/glm.png) 30 | 31 | - **`gli`: Search Gitlab issues via API.** 32 | 33 | ![](./pic/gli.png) 34 | 35 | - **`gltodo`: Open Gitlab todos in browser.** 36 | 37 | ![](./pic/gltodo.png) 38 | 39 | ## Question 40 | 41 | All pull requests, issues and ideas are welcome. 42 | 43 | ## Thanks 44 | 45 | - Inspired by [lukewaite/alfred-gitlab](https://github.com/lukewaite/alfred-gitlab). 46 | - Python libary [alfred-workflow](http://www.deanishe.net/alfred-workflow/). 47 | 48 | ## License 49 | 50 | MIT @ [github/wangshub](https://github.com/wangshub) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | snippet/ 2 | .idea/ 3 | .DS_Store 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Environments 93 | .env 94 | .venv 95 | env/ 96 | venv/ 97 | ENV/ 98 | env.bak/ 99 | venv.bak/ 100 | 101 | # Spyder project settings 102 | .spyderproject 103 | .spyproject 104 | 105 | # Rope project settings 106 | .ropeproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | -------------------------------------------------------------------------------- /workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Workflow objects 16 | from .workflow import Workflow, manager 17 | from .workflow3 import Variables, Workflow3 18 | 19 | # Exceptions 20 | from .workflow import PasswordNotFound, KeychainError 21 | 22 | # Icons 23 | from .workflow import ( 24 | ICON_ACCOUNT, 25 | ICON_BURN, 26 | ICON_CLOCK, 27 | ICON_COLOR, 28 | ICON_COLOUR, 29 | ICON_EJECT, 30 | ICON_ERROR, 31 | ICON_FAVORITE, 32 | ICON_FAVOURITE, 33 | ICON_GROUP, 34 | ICON_HELP, 35 | ICON_HOME, 36 | ICON_INFO, 37 | ICON_NETWORK, 38 | ICON_NOTE, 39 | ICON_SETTINGS, 40 | ICON_SWIRL, 41 | ICON_SWITCH, 42 | ICON_SYNC, 43 | ICON_TRASH, 44 | ICON_USER, 45 | ICON_WARNING, 46 | ICON_WEB, 47 | ) 48 | 49 | # Filter matching rules 50 | from .workflow import ( 51 | MATCH_ALL, 52 | MATCH_ALLCHARS, 53 | MATCH_ATOM, 54 | MATCH_CAPITALS, 55 | MATCH_INITIALS, 56 | MATCH_INITIALS_CONTAIN, 57 | MATCH_INITIALS_STARTSWITH, 58 | MATCH_STARTSWITH, 59 | MATCH_SUBSTRING, 60 | ) 61 | 62 | 63 | __title__ = 'Alfred-Workflow' 64 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 65 | __author__ = 'Dean Jackson' 66 | __licence__ = 'MIT' 67 | __copyright__ = 'Copyright 2014-2017 Dean Jackson' 68 | 69 | __all__ = [ 70 | 'Variables', 71 | 'Workflow', 72 | 'Workflow3', 73 | 'manager', 74 | 'PasswordNotFound', 75 | 'KeychainError', 76 | 'ICON_ACCOUNT', 77 | 'ICON_BURN', 78 | 'ICON_CLOCK', 79 | 'ICON_COLOR', 80 | 'ICON_COLOUR', 81 | 'ICON_EJECT', 82 | 'ICON_ERROR', 83 | 'ICON_FAVORITE', 84 | 'ICON_FAVOURITE', 85 | 'ICON_GROUP', 86 | 'ICON_HELP', 87 | 'ICON_HOME', 88 | 'ICON_INFO', 89 | 'ICON_NETWORK', 90 | 'ICON_NOTE', 91 | 'ICON_SETTINGS', 92 | 'ICON_SWIRL', 93 | 'ICON_SWITCH', 94 | 'ICON_SYNC', 95 | 'ICON_TRASH', 96 | 'ICON_USER', 97 | 'ICON_WARNING', 98 | 'ICON_WEB', 99 | 'MATCH_ALL', 100 | 'MATCH_ALLCHARS', 101 | 'MATCH_ATOM', 102 | 'MATCH_CAPITALS', 103 | 'MATCH_INITIALS', 104 | 'MATCH_INITIALS_CONTAIN', 105 | 'MATCH_INITIALS_STARTSWITH', 106 | 'MATCH_STARTSWITH', 107 | 'MATCH_SUBSTRING', 108 | ] 109 | -------------------------------------------------------------------------------- /gitlab.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import sys 3 | from workflow import web, Workflow3, PasswordNotFound 4 | 5 | 6 | def get_projects(token, url): 7 | """ 8 | Parse all pages of projects 9 | :return: list 10 | """ 11 | return get_gitlab_repos(url, token, 1, []) 12 | 13 | 14 | def get_gitlab_repos(url, token, page, result): 15 | if page == 1: 16 | url = url.encode('utf-8') + '/projects' 17 | log.info('Fetching {url} page {page}'.format(url=url, page=page)) 18 | params = dict(private_token=token, per_page=100, page=page) 19 | r = web.get(url, params) 20 | r.raise_for_status() 21 | result = result + r.json() 22 | next_page = r.headers.get('X-Next-Page') 23 | if next_page: 24 | result = get_gitlab_repos(url, token, next_page, result) 25 | return result 26 | 27 | 28 | def get_gitlab_issue(url, token, query, page, result): 29 | if page == 1: 30 | url = url.encode('utf-8') + '/search?scope=issues&search='+query 31 | params = dict(private_token=token, per_page=100, page=page) 32 | r = web.get(url, params) 33 | r.raise_for_status() 34 | result = result + r.json() 35 | next_page = r.headers.get('X-Next-Page') 36 | if next_page: 37 | result = get_gitlab_issue(url, token, query, next_page, result) 38 | return result 39 | 40 | 41 | def get_gitlab_merge_requests(url, token, page, result): 42 | if page == 1: 43 | url = url.encode('utf-8') + '/merge_requests' 44 | params = dict(private_token=token, 45 | per_page=100, 46 | page=page, 47 | scope='assigned-to-me', 48 | state='opened') 49 | r = web.get(url, params) 50 | r.raise_for_status() 51 | result = result + r.json() 52 | next_page = r.headers.get('X-Next-Page') 53 | if next_page: 54 | get_gitlab_merge_requests(url, token, next_page, result) 55 | return result 56 | 57 | 58 | def main(wf): 59 | try: 60 | gitlab_token = wf.get_password('gitlab_token') 61 | gitlab_url = wf.get_password('gitlab_url') 62 | 63 | # Retrieve projects from cache if available and no more than 600 64 | # seconds old 65 | def wrapper(): 66 | return get_projects(gitlab_token, gitlab_url) 67 | 68 | projects = wf.cached_data('gitlab_projects', wrapper, max_age=3600) 69 | # log.info('total gitlab projects = ' + str(len(projects))) 70 | 71 | # Record our progress in the log file 72 | log.debug('{} gitlab projects cached'.format(len(projects))) 73 | 74 | except PasswordNotFound: # API key has not yet been set 75 | # Nothing we can do about this, so just log it 76 | wf.logger.error('No API key saved') 77 | 78 | 79 | if __name__ == u"__main__": 80 | wf = Workflow3() 81 | log = wf.logger 82 | wf.run(main) 83 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from os import sys, path 3 | import sys 4 | import argparse 5 | from gitlab import get_gitlab_issue, get_gitlab_merge_requests 6 | from workflow import Workflow3, ICON_WEB, ICON_WARNING, ICON_INFO, web, PasswordNotFound 7 | from workflow.background import run_in_background, is_running 8 | 9 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 10 | 11 | log = None 12 | 13 | 14 | def search_for_project(project): 15 | """Generate a string search key for a project""" 16 | elements = [] 17 | elements.append(project['name_with_namespace']) 18 | elements.append(project['path_with_namespace']) 19 | return u' '.join(elements) 20 | 21 | 22 | def save_gitlab_token(wf, token): 23 | """ 24 | save gitlab token 25 | :param wf: 26 | :param token: 27 | :return: 28 | """ 29 | log.info('Setting gitlab token {}'.format(token)) 30 | wf.save_password('gitlab_token', token) 31 | 32 | 33 | def save_gitlab_url(wf, url): 34 | """ 35 | save gitlab url 36 | :param wf: 37 | :param url: 38 | :return: 39 | """ 40 | log.info('Setting gitlab url {}'.format(url)) 41 | wf.save_password('gitlab_url', url) 42 | 43 | 44 | def open_gitlab_todo(wf): 45 | """ 46 | open gitlab todos url in default browser 47 | :param wf: 48 | :return: 49 | """ 50 | url_todo = wf.get_password('gitlab_url').replace('/api/v4', '') + '/dashboard/todos' 51 | wf.add_item(title='Open gitlab todo in default browser', 52 | arg=url_todo, 53 | valid=True, 54 | icon=None) 55 | wf.send_feedback() 56 | 57 | 58 | def search_gitlab_repo(wf, query): 59 | """ 60 | search repo from cached data 61 | :param wf: 62 | :param query: 63 | :return: 64 | """ 65 | projects = wf.cached_data('gitlab_projects', max_age=0) 66 | 67 | # update gitlab api data 68 | if not wf.cached_data_fresh('gitlab_projects', max_age=3600) and not is_running('gitlab_update'): 69 | cmd = ['/usr/bin/python', wf.workflowfile('gitlab.py')] 70 | run_in_background('gitlab_update', cmd) 71 | wf.rerun = 0. 72 | 73 | if query and projects: 74 | projects = wf.filter(query, projects, key=search_for_project, min_score=20) 75 | 76 | if not projects: 77 | wf.add_item('No projects found', icon=ICON_WARNING) 78 | wf.send_feedback() 79 | return 0 80 | 81 | for proj in projects: 82 | if proj['issues_enabled']: 83 | subtitle = "issue:{0: <3} star:{1: <3} fork:{2: <3}".format(proj['open_issues_count'], 84 | proj['star_count'], 85 | proj['forks_count']) 86 | else: 87 | subtitle = "" 88 | wf.add_item(title=proj['name_with_namespace'], 89 | subtitle=subtitle, 90 | arg=proj['web_url'], 91 | valid=True, 92 | icon=None, 93 | uid=proj['id']) 94 | wf.send_feedback() 95 | 96 | 97 | def query_gitlab_issue(wf, query): 98 | """ 99 | query gitlab issues 100 | :param wf: 101 | :param query: 102 | :return: 103 | """ 104 | query = query.encode('utf-8') 105 | gitlab_token = wf.get_password('gitlab_token') 106 | gitlab_url = wf.get_password('gitlab_url') 107 | issues = get_gitlab_issue(gitlab_url, gitlab_token, query, 1, []) 108 | 109 | if not issues: 110 | wf.add_item('No issues found', icon=ICON_WARNING) 111 | wf.send_feedback() 112 | return 0 113 | 114 | for issue in issues: 115 | wf.add_item(title=issue['title'], 116 | subtitle=issue['description'], 117 | arg=issue['web_url'], 118 | valid=True, 119 | icon=None, 120 | uid=issue['id']) 121 | wf.send_feedback() 122 | 123 | 124 | def get_gitlab_mr_assigned(wf): 125 | gitlab_token = wf.get_password('gitlab_token') 126 | gitlab_url = wf.get_password('gitlab_url') 127 | merges = get_gitlab_merge_requests(gitlab_url, gitlab_token, 1, []) 128 | if not merges: 129 | wf.add_item('No issues found', icon=ICON_WARNING) 130 | wf.send_feedback() 131 | return 0 132 | for merge in merges: 133 | title =merge['title'] + ' by ' + merge['author']['username'] 134 | wf.add_item(title, 135 | subtitle=merge['description'], 136 | arg=merge['web_url'], 137 | valid=True, 138 | icon=None, 139 | uid=merge['id']) 140 | wf.send_feedback() 141 | 142 | 143 | def main(wf): 144 | # command line parser 145 | parser = argparse.ArgumentParser() 146 | parser.add_argument('--token', dest='token', nargs='?', default=None) 147 | parser.add_argument('--url', dest='url', nargs='?', default=None) 148 | parser.add_argument('--repo', dest='repo', nargs='?', default=None) 149 | parser.add_argument('--issue', dest='issue', nargs='?', default=None) 150 | parser.add_argument('--merge', dest='merge', action='store_true', default=False) 151 | parser.add_argument('--todo', dest='todo', action='store_true', default=False) 152 | parser.add_argument('query', nargs='?', default=None) 153 | args = parser.parse_args(wf.args) 154 | log.info(args) 155 | 156 | if args.token: 157 | save_gitlab_token(wf, args.token) 158 | return 0 159 | 160 | if args.url: 161 | save_gitlab_url(wf, args.url) 162 | return 0 163 | 164 | if args.todo: 165 | open_gitlab_todo(wf) 166 | return 0 167 | 168 | if args.repo: 169 | search_gitlab_repo(wf, args.repo) 170 | return 0 171 | 172 | if args.issue: 173 | query_gitlab_issue(wf, args.issue) 174 | return 0 175 | 176 | if args.merge: 177 | get_gitlab_mr_assigned(wf) 178 | return 0 179 | 180 | 181 | if __name__ == u"__main__": 182 | wf = Workflow3() 183 | log = wf.logger 184 | sys.exit(wf.run(main)) -------------------------------------------------------------------------------- /workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """ 12 | This module provides an API to run commands in background processes. 13 | Combine with the :ref:`caching API ` to work from cached data 14 | while you fetch fresh data in the background. 15 | 16 | See :ref:`the User Manual ` for more information 17 | and examples. 18 | """ 19 | 20 | from __future__ import print_function, unicode_literals 21 | 22 | import signal 23 | import sys 24 | import os 25 | import subprocess 26 | import pickle 27 | 28 | from workflow import Workflow 29 | 30 | __all__ = ['is_running', 'run_in_background'] 31 | 32 | _wf = None 33 | 34 | 35 | def wf(): 36 | global _wf 37 | if _wf is None: 38 | _wf = Workflow() 39 | return _wf 40 | 41 | 42 | def _log(): 43 | return wf().logger 44 | 45 | 46 | def _arg_cache(name): 47 | """Return path to pickle cache file for arguments. 48 | 49 | :param name: name of task 50 | :type name: ``unicode`` 51 | :returns: Path to cache file 52 | :rtype: ``unicode`` filepath 53 | 54 | """ 55 | return wf().cachefile(name + '.argcache') 56 | 57 | 58 | def _pid_file(name): 59 | """Return path to PID file for ``name``. 60 | 61 | :param name: name of task 62 | :type name: ``unicode`` 63 | :returns: Path to PID file for task 64 | :rtype: ``unicode`` filepath 65 | 66 | """ 67 | return wf().cachefile(name + '.pid') 68 | 69 | 70 | def _process_exists(pid): 71 | """Check if a process with PID ``pid`` exists. 72 | 73 | :param pid: PID to check 74 | :type pid: ``int`` 75 | :returns: ``True`` if process exists, else ``False`` 76 | :rtype: ``Boolean`` 77 | 78 | """ 79 | try: 80 | os.kill(pid, 0) 81 | except OSError: # not running 82 | return False 83 | return True 84 | 85 | 86 | def _job_pid(name): 87 | """Get PID of job or `None` if job does not exist. 88 | 89 | Args: 90 | name (str): Name of job. 91 | 92 | Returns: 93 | int: PID of job process (or `None` if job doesn't exist). 94 | """ 95 | pidfile = _pid_file(name) 96 | if not os.path.exists(pidfile): 97 | return 98 | 99 | with open(pidfile, 'rb') as fp: 100 | pid = int(fp.read()) 101 | 102 | if _process_exists(pid): 103 | return pid 104 | 105 | try: 106 | os.unlink(pidfile) 107 | except Exception: # pragma: no cover 108 | pass 109 | 110 | 111 | def is_running(name): 112 | """Test whether task ``name`` is currently running. 113 | 114 | :param name: name of task 115 | :type name: unicode 116 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 117 | :rtype: bool 118 | 119 | """ 120 | if _job_pid(name) is not None: 121 | return True 122 | 123 | return False 124 | 125 | 126 | def _background(pidfile, stdin='/dev/null', stdout='/dev/null', 127 | stderr='/dev/null'): # pragma: no cover 128 | """Fork the current process into a background daemon. 129 | 130 | :param pidfile: file to write PID of daemon process to. 131 | :type pidfile: filepath 132 | :param stdin: where to read input 133 | :type stdin: filepath 134 | :param stdout: where to write stdout output 135 | :type stdout: filepath 136 | :param stderr: where to write stderr output 137 | :type stderr: filepath 138 | 139 | """ 140 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 141 | try: 142 | pid = os.fork() 143 | if pid > 0: 144 | if write: # write PID of child process to `pidfile` 145 | tmp = pidfile + '.tmp' 146 | with open(tmp, 'wb') as fp: 147 | fp.write(str(pid)) 148 | os.rename(tmp, pidfile) 149 | if wait: # wait for child process to exit 150 | os.waitpid(pid, 0) 151 | os._exit(0) 152 | except OSError as err: 153 | _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror) 154 | raise err 155 | 156 | # Do first fork and wait for second fork to finish. 157 | _fork_and_exit_parent('fork #1 failed', wait=True) 158 | 159 | # Decouple from parent environment. 160 | os.chdir(wf().workflowdir) 161 | os.setsid() 162 | 163 | # Do second fork and write PID to pidfile. 164 | _fork_and_exit_parent('fork #2 failed', write=True) 165 | 166 | # Now I am a daemon! 167 | # Redirect standard file descriptors. 168 | si = open(stdin, 'r', 0) 169 | so = open(stdout, 'a+', 0) 170 | se = open(stderr, 'a+', 0) 171 | if hasattr(sys.stdin, 'fileno'): 172 | os.dup2(si.fileno(), sys.stdin.fileno()) 173 | if hasattr(sys.stdout, 'fileno'): 174 | os.dup2(so.fileno(), sys.stdout.fileno()) 175 | if hasattr(sys.stderr, 'fileno'): 176 | os.dup2(se.fileno(), sys.stderr.fileno()) 177 | 178 | 179 | def kill(name, sig=signal.SIGTERM): 180 | """Send a signal to job ``name`` via :func:`os.kill`. 181 | 182 | .. versionadded:: 1.29 183 | 184 | Args: 185 | name (str): Name of the job 186 | sig (int, optional): Signal to send (default: SIGTERM) 187 | 188 | Returns: 189 | bool: `False` if job isn't running, `True` if signal was sent. 190 | """ 191 | pid = _job_pid(name) 192 | if pid is None: 193 | return False 194 | 195 | os.kill(pid, sig) 196 | return True 197 | 198 | 199 | def run_in_background(name, args, **kwargs): 200 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 201 | 202 | :param name: name of job 203 | :type name: unicode 204 | :param args: arguments passed as first argument to :func:`subprocess.call` 205 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 206 | :returns: exit code of sub-process 207 | :rtype: int 208 | 209 | When you call this function, it caches its arguments and then calls 210 | ``background.py`` in a subprocess. The Python subprocess will load the 211 | cached arguments, fork into the background, and then run the command you 212 | specified. 213 | 214 | This function will return as soon as the ``background.py`` subprocess has 215 | forked, returning the exit code of *that* process (i.e. not of the command 216 | you're trying to run). 217 | 218 | If that process fails, an error will be written to the log file. 219 | 220 | If a process is already running under the same name, this function will 221 | return immediately and will not run the specified command. 222 | 223 | """ 224 | if is_running(name): 225 | _log().info('[%s] job already running', name) 226 | return 227 | 228 | argcache = _arg_cache(name) 229 | 230 | # Cache arguments 231 | with open(argcache, 'wb') as fp: 232 | pickle.dump({'args': args, 'kwargs': kwargs}, fp) 233 | _log().debug('[%s] command cached: %s', name, argcache) 234 | 235 | # Call this script 236 | cmd = ['/usr/bin/python', __file__, name] 237 | _log().debug('[%s] passing job to background runner: %r', name, cmd) 238 | retcode = subprocess.call(cmd) 239 | 240 | if retcode: # pragma: no cover 241 | _log().error('[%s] background runner failed with %d', name, retcode) 242 | else: 243 | _log().debug('[%s] background job started', name) 244 | 245 | return retcode 246 | 247 | 248 | def main(wf): # pragma: no cover 249 | """Run command in a background process. 250 | 251 | Load cached arguments, fork into background, then call 252 | :meth:`subprocess.call` with cached arguments. 253 | 254 | """ 255 | log = wf.logger 256 | name = wf.args[0] 257 | argcache = _arg_cache(name) 258 | if not os.path.exists(argcache): 259 | msg = '[{0}] command cache not found: {1}'.format(name, argcache) 260 | log.critical(msg) 261 | raise IOError(msg) 262 | 263 | # Fork to background and run command 264 | pidfile = _pid_file(name) 265 | _background(pidfile) 266 | 267 | # Load cached arguments 268 | with open(argcache, 'rb') as fp: 269 | data = pickle.load(fp) 270 | 271 | # Cached arguments 272 | args = data['args'] 273 | kwargs = data['kwargs'] 274 | 275 | # Delete argument cache file 276 | os.unlink(argcache) 277 | 278 | try: 279 | # Run the command 280 | log.debug('[%s] running command: %r', name, args) 281 | 282 | retcode = subprocess.call(args, **kwargs) 283 | 284 | if retcode: 285 | log.error('[%s] command failed with status %d', name, retcode) 286 | finally: 287 | os.unlink(pidfile) 288 | 289 | log.debug('[%s] job complete', name) 290 | 291 | 292 | if __name__ == '__main__': # pragma: no cover 293 | wf().run(main) 294 | -------------------------------------------------------------------------------- /workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the macOS Notification Center. This feature 15 | is only available on Mountain Lion (10.8) and later. It will 16 | silently fail on older systems. 17 | 18 | The main API is a single function, :func:`~workflow.notify.notify`. 19 | 20 | It works by copying a simple application to your workflow's data 21 | directory. It replaces the application's icon with your workflow's 22 | icon and then calls the application to post notifications. 23 | """ 24 | 25 | from __future__ import print_function, unicode_literals 26 | 27 | import os 28 | import plistlib 29 | import shutil 30 | import subprocess 31 | import sys 32 | import tarfile 33 | import tempfile 34 | import uuid 35 | 36 | import workflow 37 | 38 | 39 | _wf = None 40 | _log = None 41 | 42 | 43 | #: Available system sounds from System Preferences > Sound > Sound Effects 44 | SOUNDS = ( 45 | 'Basso', 46 | 'Blow', 47 | 'Bottle', 48 | 'Frog', 49 | 'Funk', 50 | 'Glass', 51 | 'Hero', 52 | 'Morse', 53 | 'Ping', 54 | 'Pop', 55 | 'Purr', 56 | 'Sosumi', 57 | 'Submarine', 58 | 'Tink', 59 | ) 60 | 61 | 62 | def wf(): 63 | """Return Workflow object for this module. 64 | 65 | Returns: 66 | workflow.Workflow: Workflow object for current workflow. 67 | """ 68 | global _wf 69 | if _wf is None: 70 | _wf = workflow.Workflow() 71 | return _wf 72 | 73 | 74 | def log(): 75 | """Return logger for this module. 76 | 77 | Returns: 78 | logging.Logger: Logger for this module. 79 | """ 80 | global _log 81 | if _log is None: 82 | _log = wf().logger 83 | return _log 84 | 85 | 86 | def notifier_program(): 87 | """Return path to notifier applet executable. 88 | 89 | Returns: 90 | unicode: Path to Notify.app ``applet`` executable. 91 | """ 92 | return wf().datafile('Notify.app/Contents/MacOS/applet') 93 | 94 | 95 | def notifier_icon_path(): 96 | """Return path to icon file in installed Notify.app. 97 | 98 | Returns: 99 | unicode: Path to ``applet.icns`` within the app bundle. 100 | """ 101 | return wf().datafile('Notify.app/Contents/Resources/applet.icns') 102 | 103 | 104 | def install_notifier(): 105 | """Extract ``Notify.app`` from the workflow to data directory. 106 | 107 | Changes the bundle ID of the installed app and gives it the 108 | workflow's icon. 109 | """ 110 | archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') 111 | destdir = wf().datadir 112 | app_path = os.path.join(destdir, 'Notify.app') 113 | n = notifier_program() 114 | log().debug('installing Notify.app to %r ...', destdir) 115 | # z = zipfile.ZipFile(archive, 'r') 116 | # z.extractall(destdir) 117 | tgz = tarfile.open(archive, 'r:gz') 118 | tgz.extractall(destdir) 119 | assert os.path.exists(n), \ 120 | 'Notify.app could not be installed in %s' % destdir 121 | 122 | # Replace applet icon 123 | icon = notifier_icon_path() 124 | workflow_icon = wf().workflowfile('icon.png') 125 | if os.path.exists(icon): 126 | os.unlink(icon) 127 | 128 | png_to_icns(workflow_icon, icon) 129 | 130 | # Set file icon 131 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 132 | # none of this code will "work" on pre-10.8 systems. Let it run 133 | # until I figure out a better way of excluding this module 134 | # from coverage in py2.6. 135 | if sys.version_info >= (2, 7): # pragma: no cover 136 | from AppKit import NSWorkspace, NSImage 137 | 138 | ws = NSWorkspace.sharedWorkspace() 139 | img = NSImage.alloc().init() 140 | img.initWithContentsOfFile_(icon) 141 | ws.setIcon_forFile_options_(img, app_path, 0) 142 | 143 | # Change bundle ID of installed app 144 | ip_path = os.path.join(app_path, 'Contents/Info.plist') 145 | bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) 146 | data = plistlib.readPlist(ip_path) 147 | log().debug('changing bundle ID to %r', bundle_id) 148 | data['CFBundleIdentifier'] = bundle_id 149 | plistlib.writePlist(data, ip_path) 150 | 151 | 152 | def validate_sound(sound): 153 | """Coerce ``sound`` to valid sound name. 154 | 155 | Returns ``None`` for invalid sounds. Sound names can be found 156 | in ``System Preferences > Sound > Sound Effects``. 157 | 158 | Args: 159 | sound (str): Name of system sound. 160 | 161 | Returns: 162 | str: Proper name of sound or ``None``. 163 | """ 164 | if not sound: 165 | return None 166 | 167 | # Case-insensitive comparison of `sound` 168 | if sound.lower() in [s.lower() for s in SOUNDS]: 169 | # Title-case is correct for all system sounds as of macOS 10.11 170 | return sound.title() 171 | return None 172 | 173 | 174 | def notify(title='', text='', sound=None): 175 | """Post notification via Notify.app helper. 176 | 177 | Args: 178 | title (str, optional): Notification title. 179 | text (str, optional): Notification body text. 180 | sound (str, optional): Name of sound to play. 181 | 182 | Raises: 183 | ValueError: Raised if both ``title`` and ``text`` are empty. 184 | 185 | Returns: 186 | bool: ``True`` if notification was posted, else ``False``. 187 | """ 188 | if title == text == '': 189 | raise ValueError('Empty notification') 190 | 191 | sound = validate_sound(sound) or '' 192 | 193 | n = notifier_program() 194 | 195 | if not os.path.exists(n): 196 | install_notifier() 197 | 198 | env = os.environ.copy() 199 | enc = 'utf-8' 200 | env['NOTIFY_TITLE'] = title.encode(enc) 201 | env['NOTIFY_MESSAGE'] = text.encode(enc) 202 | env['NOTIFY_SOUND'] = sound.encode(enc) 203 | cmd = [n] 204 | retcode = subprocess.call(cmd, env=env) 205 | if retcode == 0: 206 | return True 207 | 208 | log().error('Notify.app exited with status {0}.'.format(retcode)) 209 | return False 210 | 211 | 212 | def convert_image(inpath, outpath, size): 213 | """Convert an image file using ``sips``. 214 | 215 | Args: 216 | inpath (str): Path of source file. 217 | outpath (str): Path to destination file. 218 | size (int): Width and height of destination image in pixels. 219 | 220 | Raises: 221 | RuntimeError: Raised if ``sips`` exits with non-zero status. 222 | """ 223 | cmd = [ 224 | b'sips', 225 | b'-z', str(size), str(size), 226 | inpath, 227 | b'--out', outpath] 228 | # log().debug(cmd) 229 | with open(os.devnull, 'w') as pipe: 230 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) 231 | 232 | if retcode != 0: 233 | raise RuntimeError('sips exited with %d' % retcode) 234 | 235 | 236 | def png_to_icns(png_path, icns_path): 237 | """Convert PNG file to ICNS using ``iconutil``. 238 | 239 | Create an iconset from the source PNG file. Generate PNG files 240 | in each size required by macOS, then call ``iconutil`` to turn 241 | them into a single ICNS file. 242 | 243 | Args: 244 | png_path (str): Path to source PNG file. 245 | icns_path (str): Path to destination ICNS file. 246 | 247 | Raises: 248 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 249 | """ 250 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) 251 | 252 | try: 253 | iconset = os.path.join(tempdir, 'Icon.iconset') 254 | 255 | assert not os.path.exists(iconset), \ 256 | 'iconset already exists: ' + iconset 257 | os.makedirs(iconset) 258 | 259 | # Copy source icon to icon set and generate all the other 260 | # sizes needed 261 | configs = [] 262 | for i in (16, 32, 128, 256, 512): 263 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 264 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2))) 265 | 266 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 267 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 268 | 269 | for name, size in configs: 270 | outpath = os.path.join(iconset, name) 271 | if os.path.exists(outpath): 272 | continue 273 | convert_image(png_path, outpath, size) 274 | 275 | cmd = [ 276 | b'iconutil', 277 | b'-c', b'icns', 278 | b'-o', icns_path, 279 | iconset] 280 | 281 | retcode = subprocess.call(cmd) 282 | if retcode != 0: 283 | raise RuntimeError('iconset exited with %d' % retcode) 284 | 285 | assert os.path.exists(icns_path), \ 286 | 'generated ICNS file not found: ' + repr(icns_path) 287 | finally: 288 | try: 289 | shutil.rmtree(tempdir) 290 | except OSError: # pragma: no cover 291 | pass 292 | 293 | 294 | if __name__ == '__main__': # pragma: nocover 295 | # Simple command-line script to test module with 296 | # This won't work on 2.6, as `argparse` isn't available 297 | # by default. 298 | import argparse 299 | 300 | from unicodedata import normalize 301 | 302 | def ustr(s): 303 | """Coerce `s` to normalised Unicode.""" 304 | return normalize('NFD', s.decode('utf-8')) 305 | 306 | p = argparse.ArgumentParser() 307 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 308 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 309 | action='store_true') 310 | p.add_argument('-t', '--title', 311 | help="Notification title.", type=ustr, 312 | default='') 313 | p.add_argument('-s', '--sound', type=ustr, 314 | help="Optional notification sound.", default='') 315 | p.add_argument('text', type=ustr, 316 | help="Notification body text.", default='', nargs='?') 317 | o = p.parse_args() 318 | 319 | # List available sounds 320 | if o.list_sounds: 321 | for sound in SOUNDS: 322 | print(sound) 323 | sys.exit(0) 324 | 325 | # Convert PNG to ICNS 326 | if o.png: 327 | icns = os.path.join( 328 | os.path.dirname(o.png), 329 | os.path.splitext(os.path.basename(o.png))[0] + '.icns') 330 | 331 | print('converting {0!r} to {1!r} ...'.format(o.png, icns), 332 | file=sys.stderr) 333 | 334 | assert not os.path.exists(icns), \ 335 | 'destination file already exists: ' + icns 336 | 337 | png_to_icns(o.png, icns) 338 | sys.exit(0) 339 | 340 | # Post notification 341 | if o.title == o.text == '': 342 | print('ERROR: empty notification.', file=sys.stderr) 343 | sys.exit(1) 344 | else: 345 | notify(o.title, o.text, o.sound) 346 | -------------------------------------------------------------------------------- /workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | import os 27 | import tempfile 28 | import re 29 | import subprocess 30 | 31 | import workflow 32 | import web 33 | 34 | # __all__ = [] 35 | 36 | 37 | RELEASES_BASE = 'https://api.github.com/repos/{0}/releases' 38 | 39 | 40 | _wf = None 41 | 42 | 43 | def wf(): 44 | """Lazy `Workflow` object.""" 45 | global _wf 46 | if _wf is None: 47 | _wf = workflow.Workflow() 48 | return _wf 49 | 50 | 51 | class Version(object): 52 | """Mostly semantic versioning. 53 | 54 | The main difference to proper :ref:`semantic versioning ` 55 | is that this implementation doesn't require a minor or patch version. 56 | 57 | Version strings may also be prefixed with "v", e.g.: 58 | 59 | >>> v = Version('v1.1.1') 60 | >>> v.tuple 61 | (1, 1, 1, '') 62 | 63 | >>> v = Version('2.0') 64 | >>> v.tuple 65 | (2, 0, 0, '') 66 | 67 | >>> Version('3.1-beta').tuple 68 | (3, 1, 0, 'beta') 69 | 70 | >>> Version('1.0.1') > Version('0.0.1') 71 | True 72 | """ 73 | 74 | #: Match version and pre-release/build information in version strings 75 | match_version = re.compile(r'([0-9\.]+)(.+)?').match 76 | 77 | def __init__(self, vstr): 78 | """Create new `Version` object. 79 | 80 | Args: 81 | vstr (basestring): Semantic version string. 82 | """ 83 | self.vstr = vstr 84 | self.major = 0 85 | self.minor = 0 86 | self.patch = 0 87 | self.suffix = '' 88 | self.build = '' 89 | self._parse(vstr) 90 | 91 | def _parse(self, vstr): 92 | if vstr.startswith('v'): 93 | m = self.match_version(vstr[1:]) 94 | else: 95 | m = self.match_version(vstr) 96 | if not m: 97 | raise ValueError('invalid version number: {0}'.format(vstr)) 98 | 99 | version, suffix = m.groups() 100 | parts = self._parse_dotted_string(version) 101 | self.major = parts.pop(0) 102 | if len(parts): 103 | self.minor = parts.pop(0) 104 | if len(parts): 105 | self.patch = parts.pop(0) 106 | if not len(parts) == 0: 107 | raise ValueError('invalid version (too long) : {0}'.format(vstr)) 108 | 109 | if suffix: 110 | # Build info 111 | idx = suffix.find('+') 112 | if idx > -1: 113 | self.build = suffix[idx+1:] 114 | suffix = suffix[:idx] 115 | if suffix: 116 | if not suffix.startswith('-'): 117 | raise ValueError( 118 | 'suffix must start with - : {0}'.format(suffix)) 119 | self.suffix = suffix[1:] 120 | 121 | # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) 122 | 123 | def _parse_dotted_string(self, s): 124 | """Parse string ``s`` into list of ints and strings.""" 125 | parsed = [] 126 | parts = s.split('.') 127 | for p in parts: 128 | if p.isdigit(): 129 | p = int(p) 130 | parsed.append(p) 131 | return parsed 132 | 133 | @property 134 | def tuple(self): 135 | """Version number as a tuple of major, minor, patch, pre-release.""" 136 | return (self.major, self.minor, self.patch, self.suffix) 137 | 138 | def __lt__(self, other): 139 | """Implement comparison.""" 140 | if not isinstance(other, Version): 141 | raise ValueError('not a Version instance: {0!r}'.format(other)) 142 | t = self.tuple[:3] 143 | o = other.tuple[:3] 144 | if t < o: 145 | return True 146 | if t == o: # We need to compare suffixes 147 | if self.suffix and not other.suffix: 148 | return True 149 | if other.suffix and not self.suffix: 150 | return False 151 | return (self._parse_dotted_string(self.suffix) < 152 | self._parse_dotted_string(other.suffix)) 153 | # t > o 154 | return False 155 | 156 | def __eq__(self, other): 157 | """Implement comparison.""" 158 | if not isinstance(other, Version): 159 | raise ValueError('not a Version instance: {0!r}'.format(other)) 160 | return self.tuple == other.tuple 161 | 162 | def __ne__(self, other): 163 | """Implement comparison.""" 164 | return not self.__eq__(other) 165 | 166 | def __gt__(self, other): 167 | """Implement comparison.""" 168 | if not isinstance(other, Version): 169 | raise ValueError('not a Version instance: {0!r}'.format(other)) 170 | return other.__lt__(self) 171 | 172 | def __le__(self, other): 173 | """Implement comparison.""" 174 | if not isinstance(other, Version): 175 | raise ValueError('not a Version instance: {0!r}'.format(other)) 176 | return not other.__lt__(self) 177 | 178 | def __ge__(self, other): 179 | """Implement comparison.""" 180 | return not self.__lt__(other) 181 | 182 | def __str__(self): 183 | """Return semantic version string.""" 184 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 185 | if self.suffix: 186 | vstr = '{0}-{1}'.format(vstr, self.suffix) 187 | if self.build: 188 | vstr = '{0}+{1}'.format(vstr, self.build) 189 | return vstr 190 | 191 | def __repr__(self): 192 | """Return 'code' representation of `Version`.""" 193 | return "Version('{0}')".format(str(self)) 194 | 195 | 196 | def download_workflow(url): 197 | """Download workflow at ``url`` to a local temporary file. 198 | 199 | :param url: URL to .alfredworkflow file in GitHub repo 200 | :returns: path to downloaded file 201 | 202 | """ 203 | filename = url.split('/')[-1] 204 | 205 | if (not filename.endswith('.alfredworkflow') and 206 | not filename.endswith('.alfred3workflow')): 207 | raise ValueError('attachment not a workflow: {0}'.format(filename)) 208 | 209 | local_path = os.path.join(tempfile.gettempdir(), filename) 210 | 211 | wf().logger.debug( 212 | 'downloading updated workflow from `%s` to `%s` ...', url, local_path) 213 | 214 | response = web.get(url) 215 | 216 | with open(local_path, 'wb') as output: 217 | output.write(response.content) 218 | 219 | return local_path 220 | 221 | 222 | def build_api_url(slug): 223 | """Generate releases URL from GitHub slug. 224 | 225 | :param slug: Repo name in form ``username/repo`` 226 | :returns: URL to the API endpoint for the repo's releases 227 | 228 | """ 229 | if len(slug.split('/')) != 2: 230 | raise ValueError('invalid GitHub slug: {0}'.format(slug)) 231 | 232 | return RELEASES_BASE.format(slug) 233 | 234 | 235 | def _validate_release(release): 236 | """Return release for running version of Alfred.""" 237 | alf3 = wf().alfred_version.major == 3 238 | 239 | downloads = {'.alfredworkflow': [], '.alfred3workflow': []} 240 | dl_count = 0 241 | version = release['tag_name'] 242 | 243 | for asset in release.get('assets', []): 244 | url = asset.get('browser_download_url') 245 | if not url: # pragma: nocover 246 | continue 247 | 248 | ext = os.path.splitext(url)[1].lower() 249 | if ext not in downloads: 250 | continue 251 | 252 | # Ignore Alfred 3-only files if Alfred 2 is running 253 | if ext == '.alfred3workflow' and not alf3: 254 | continue 255 | 256 | downloads[ext].append(url) 257 | dl_count += 1 258 | 259 | # download_urls.append(url) 260 | 261 | if dl_count == 0: 262 | wf().logger.warning( 263 | 'invalid release (no workflow file): %s', version) 264 | return None 265 | 266 | for k in downloads: 267 | if len(downloads[k]) > 1: 268 | wf().logger.warning( 269 | 'invalid release (multiple %s files): %s', k, version) 270 | return None 271 | 272 | # Prefer .alfred3workflow file if there is one and Alfred 3 is 273 | # running. 274 | if alf3 and len(downloads['.alfred3workflow']): 275 | download_url = downloads['.alfred3workflow'][0] 276 | 277 | else: 278 | download_url = downloads['.alfredworkflow'][0] 279 | 280 | wf().logger.debug('release %s: %s', version, download_url) 281 | 282 | return { 283 | 'version': version, 284 | 'download_url': download_url, 285 | 'prerelease': release['prerelease'] 286 | } 287 | 288 | 289 | def get_valid_releases(github_slug, prereleases=False): 290 | """Return list of all valid releases. 291 | 292 | :param github_slug: ``username/repo`` for workflow's GitHub repo 293 | :param prereleases: Whether to include pre-releases. 294 | :returns: list of dicts. Each :class:`dict` has the form 295 | ``{'version': '1.1', 'download_url': 'http://github.com/...', 296 | 'prerelease': False }`` 297 | 298 | 299 | A valid release is one that contains one ``.alfredworkflow`` file. 300 | 301 | If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading 302 | ``v`` will be stripped. 303 | 304 | """ 305 | api_url = build_api_url(github_slug) 306 | releases = [] 307 | 308 | wf().logger.debug('retrieving releases list: %s', api_url) 309 | 310 | def retrieve_releases(): 311 | wf().logger.info( 312 | 'retrieving releases: %s', github_slug) 313 | return web.get(api_url).json() 314 | 315 | slug = github_slug.replace('/', '-') 316 | for release in wf().cached_data('gh-releases-' + slug, retrieve_releases): 317 | 318 | release = _validate_release(release) 319 | if release is None: 320 | wf().logger.debug('invalid release: %r', release) 321 | continue 322 | 323 | elif release['prerelease'] and not prereleases: 324 | wf().logger.debug('ignoring prerelease: %s', release['version']) 325 | continue 326 | 327 | wf().logger.debug('release: %r', release) 328 | 329 | releases.append(release) 330 | 331 | return releases 332 | 333 | 334 | def check_update(github_slug, current_version, prereleases=False): 335 | """Check whether a newer release is available on GitHub. 336 | 337 | :param github_slug: ``username/repo`` for workflow's GitHub repo 338 | :param current_version: the currently installed version of the 339 | workflow. :ref:`Semantic versioning ` is required. 340 | :param prereleases: Whether to include pre-releases. 341 | :type current_version: ``unicode`` 342 | :returns: ``True`` if an update is available, else ``False`` 343 | 344 | If an update is available, its version number and download URL will 345 | be cached. 346 | 347 | """ 348 | releases = get_valid_releases(github_slug, prereleases) 349 | 350 | if not len(releases): 351 | wf().logger.warning('no valid releases for %s', github_slug) 352 | wf().cache_data('__workflow_update_status', {'available': False}) 353 | return False 354 | 355 | wf().logger.info('%d releases for %s', len(releases), github_slug) 356 | 357 | # GitHub returns releases newest-first 358 | latest_release = releases[0] 359 | 360 | # (latest_version, download_url) = get_latest_release(releases) 361 | vr = Version(latest_release['version']) 362 | vl = Version(current_version) 363 | wf().logger.debug('latest=%r, installed=%r', vr, vl) 364 | if vr > vl: 365 | 366 | wf().cache_data('__workflow_update_status', { 367 | 'version': latest_release['version'], 368 | 'download_url': latest_release['download_url'], 369 | 'available': True 370 | }) 371 | 372 | return True 373 | 374 | wf().cache_data('__workflow_update_status', {'available': False}) 375 | return False 376 | 377 | 378 | def install_update(): 379 | """If a newer release is available, download and install it. 380 | 381 | :returns: ``True`` if an update is installed, else ``False`` 382 | 383 | """ 384 | update_data = wf().cached_data('__workflow_update_status', max_age=0) 385 | 386 | if not update_data or not update_data.get('available'): 387 | wf().logger.info('no update available') 388 | return False 389 | 390 | local_file = download_workflow(update_data['download_url']) 391 | 392 | wf().logger.info('installing updated workflow ...') 393 | subprocess.call(['open', local_file]) 394 | 395 | update_data['available'] = False 396 | wf().cache_data('__workflow_update_status', update_data) 397 | return True 398 | 399 | 400 | if __name__ == '__main__': # pragma: nocover 401 | import sys 402 | 403 | def show_help(status=0): 404 | """Print help message.""" 405 | print('Usage : update.py (check|install) ' 406 | '[--prereleases] ') 407 | sys.exit(status) 408 | 409 | argv = sys.argv[:] 410 | if '-h' in argv or '--help' in argv: 411 | show_help() 412 | 413 | prereleases = '--prereleases' in argv 414 | 415 | if prereleases: 416 | argv.remove('--prereleases') 417 | 418 | if len(argv) != 4: 419 | show_help(1) 420 | 421 | action, github_slug, version = argv[1:] 422 | 423 | try: 424 | 425 | if action == 'check': 426 | check_update(github_slug, version, prereleases) 427 | elif action == 'install': 428 | install_update() 429 | else: 430 | show_help(1) 431 | 432 | except Exception as err: # ensure traceback is in log file 433 | wf().logger.exception(err) 434 | raise err 435 | -------------------------------------------------------------------------------- /workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | from __future__ import print_function, absolute_import 14 | 15 | import atexit 16 | from collections import namedtuple 17 | from contextlib import contextmanager 18 | import errno 19 | import fcntl 20 | import functools 21 | import os 22 | import signal 23 | import subprocess 24 | import sys 25 | from threading import Event 26 | import time 27 | 28 | # AppleScript to call an External Trigger in Alfred 29 | AS_TRIGGER = """ 30 | tell application "Alfred 3" 31 | run trigger "{name}" in workflow "{bundleid}" {arg} 32 | end tell 33 | """ 34 | 35 | # AppleScript to save a variable in info.plist 36 | AS_CONFIG_SET = """ 37 | tell application "Alfred 3" 38 | set configuration "{name}" to value "{value}" in workflow "{bundleid}" {export} 39 | end tell 40 | """ 41 | 42 | # AppleScript to remove a variable from info.plist 43 | AS_CONFIG_UNSET = """ 44 | tell application "Alfred 3" 45 | remove configuration "{name}" in workflow "{bundleid}" 46 | end tell 47 | """ 48 | 49 | 50 | class AcquisitionError(Exception): 51 | """Raised if a lock cannot be acquired.""" 52 | 53 | 54 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) 55 | """Information about an installed application. 56 | 57 | Returned by :func:`appinfo`. All attributes are Unicode. 58 | 59 | .. py:attribute:: name 60 | 61 | Name of the application, e.g. ``u'Safari'``. 62 | 63 | .. py:attribute:: path 64 | 65 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 66 | 67 | .. py:attribute:: bundleid 68 | 69 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 70 | 71 | """ 72 | 73 | 74 | def unicodify(s, encoding='utf-8', norm=None): 75 | """Ensure string is Unicode. 76 | 77 | .. versionadded:: 1.31 78 | 79 | Decode encoded strings using ``encoding`` and normalise Unicode 80 | to form ``norm`` if specified. 81 | 82 | Args: 83 | s (str): String to decode. May also be Unicode. 84 | encoding (str, optional): Encoding to use on bytestrings. 85 | norm (None, optional): Normalisation form to apply to Unicode string. 86 | 87 | Returns: 88 | unicode: Decoded, optionally normalised, Unicode string. 89 | 90 | """ 91 | if not isinstance(s, unicode): 92 | s = unicode(s, encoding) 93 | 94 | if norm: 95 | from unicodedata import normalize 96 | s = normalize(norm, s) 97 | 98 | return s 99 | 100 | 101 | def utf8ify(s): 102 | """Ensure string is a bytestring. 103 | 104 | .. versionadded:: 1.31 105 | 106 | Returns `str` objects unchanced, encodes `unicode` objects to 107 | UTF-8, and calls :func:`str` on anything else. 108 | 109 | Args: 110 | s (object): A Python object 111 | 112 | Returns: 113 | str: UTF-8 string or string representation of s. 114 | 115 | """ 116 | if isinstance(s, str): 117 | return s 118 | 119 | if isinstance(s, unicode): 120 | return s.encode('utf-8') 121 | 122 | return str(s) 123 | 124 | 125 | def applescriptify(s): 126 | """Escape string for insertion into an AppleScript string. 127 | 128 | .. versionadded:: 1.31 129 | 130 | Replaces ``"`` with `"& quote &"`. Use this function if you want 131 | 132 | to insert a string into an AppleScript script: 133 | >>> script = 'tell application "Alfred 3" to search "{}"' 134 | >>> query = 'g "python" test' 135 | >>> script.format(applescriptify(query)) 136 | 'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"' 137 | 138 | Args: 139 | s (unicode): Unicode string to escape. 140 | 141 | Returns: 142 | unicode: Escaped string 143 | 144 | """ 145 | return s.replace(u'"', u'" & quote & "') 146 | 147 | 148 | def run_command(cmd, **kwargs): 149 | """Run a command and return the output. 150 | 151 | .. versionadded:: 1.31 152 | 153 | A thin wrapper around :func:`subprocess.check_output` that ensures 154 | all arguments are encoded to UTF-8 first. 155 | 156 | Args: 157 | cmd (list): Command arguments to pass to ``check_output``. 158 | **kwargs: Keyword arguments to pass to ``check_output``. 159 | 160 | Returns: 161 | str: Output returned by ``check_output``. 162 | 163 | """ 164 | cmd = [utf8ify(s) for s in cmd] 165 | return subprocess.check_output(cmd, **kwargs) 166 | 167 | 168 | def run_applescript(script, *args, **kwargs): 169 | """Execute an AppleScript script and return its output. 170 | 171 | .. versionadded:: 1.31 172 | 173 | Run AppleScript either by filepath or code. If ``script`` is a valid 174 | filepath, that script will be run, otherwise ``script`` is treated 175 | as code. 176 | 177 | Args: 178 | script (str, optional): Filepath of script or code to run. 179 | *args: Optional command-line arguments to pass to the script. 180 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 181 | 182 | Returns: 183 | str: Output of run command. 184 | 185 | """ 186 | cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')] 187 | 188 | if os.path.exists(script): 189 | cmd += [script] 190 | else: 191 | cmd += ['-e', script] 192 | 193 | cmd.extend(args) 194 | 195 | return run_command(cmd) 196 | 197 | 198 | def run_jxa(script, *args): 199 | """Execute a JXA script and return its output. 200 | 201 | .. versionadded:: 1.31 202 | 203 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 204 | 205 | Args: 206 | script (str): Filepath of script or code to run. 207 | *args: Optional command-line arguments to pass to script. 208 | 209 | Returns: 210 | str: Output of script. 211 | 212 | """ 213 | return run_applescript(script, *args, lang='JavaScript') 214 | 215 | 216 | def run_trigger(name, bundleid=None, arg=None): 217 | """Call an Alfred External Trigger. 218 | 219 | .. versionadded:: 1.31 220 | 221 | If ``bundleid`` is not specified, reads the bundle ID of the current 222 | workflow from Alfred's environment variables. 223 | 224 | Args: 225 | name (str): Name of External Trigger to call. 226 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 227 | arg (str, optional): Argument to pass to trigger. 228 | 229 | """ 230 | if not bundleid: 231 | bundleid = os.getenv('alfred_workflow_bundleid') 232 | 233 | if arg: 234 | arg = 'with argument "{}"'.format(applescriptify(arg)) 235 | else: 236 | arg = '' 237 | 238 | script = AS_TRIGGER.format(name=name, bundleid=bundleid, 239 | arg=arg) 240 | 241 | run_applescript(script) 242 | 243 | 244 | def set_config(name, value, bundleid=None, exportable=False): 245 | """Set a workflow variable in ``info.plist``. 246 | 247 | .. versionadded:: 1.33 248 | 249 | Args: 250 | name (str): Name of variable to set. 251 | value (str): Value to set variable to. 252 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 253 | exportable (bool, optional): Whether variable should be marked 254 | as exportable (Don't Export checkbox). 255 | 256 | """ 257 | if not bundleid: 258 | bundleid = os.getenv('alfred_workflow_bundleid') 259 | 260 | name = applescriptify(name) 261 | value = applescriptify(value) 262 | bundleid = applescriptify(bundleid) 263 | 264 | if exportable: 265 | export = 'exportable true' 266 | else: 267 | export = 'exportable false' 268 | 269 | script = AS_CONFIG_SET.format(name=name, bundleid=bundleid, 270 | value=value, export=export) 271 | 272 | run_applescript(script) 273 | 274 | 275 | def unset_config(name, bundleid=None): 276 | """Delete a workflow variable from ``info.plist``. 277 | 278 | .. versionadded:: 1.33 279 | 280 | Args: 281 | name (str): Name of variable to delete. 282 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 283 | 284 | """ 285 | if not bundleid: 286 | bundleid = os.getenv('alfred_workflow_bundleid') 287 | 288 | name = applescriptify(name) 289 | bundleid = applescriptify(bundleid) 290 | 291 | script = AS_CONFIG_UNSET.format(name=name, bundleid=bundleid) 292 | 293 | run_applescript(script) 294 | 295 | 296 | def appinfo(name): 297 | """Get information about an installed application. 298 | 299 | .. versionadded:: 1.31 300 | 301 | Args: 302 | name (str): Name of application to look up. 303 | 304 | Returns: 305 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 306 | 307 | """ 308 | cmd = ['mdfind', '-onlyin', '/Applications', 309 | '-onlyin', os.path.expanduser('~/Applications'), 310 | '(kMDItemContentTypeTree == com.apple.application &&' 311 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' 312 | .format(name)] 313 | 314 | output = run_command(cmd).strip() 315 | if not output: 316 | return None 317 | 318 | path = output.split('\n')[0] 319 | 320 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] 321 | bid = run_command(cmd).strip() 322 | if not bid: # pragma: no cover 323 | return None 324 | 325 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) 326 | 327 | 328 | @contextmanager 329 | def atomic_writer(fpath, mode): 330 | """Atomic file writer. 331 | 332 | .. versionadded:: 1.12 333 | 334 | Context manager that ensures the file is only written if the write 335 | succeeds. The data is first written to a temporary file. 336 | 337 | :param fpath: path of file to write to. 338 | :type fpath: ``unicode`` 339 | :param mode: sames as for :func:`open` 340 | :type mode: string 341 | 342 | """ 343 | suffix = '.{}.tmp'.format(os.getpid()) 344 | temppath = fpath + suffix 345 | with open(temppath, mode) as fp: 346 | try: 347 | yield fp 348 | os.rename(temppath, fpath) 349 | finally: 350 | try: 351 | os.remove(temppath) 352 | except (OSError, IOError): 353 | pass 354 | 355 | 356 | class LockFile(object): 357 | """Context manager to protect filepaths with lockfiles. 358 | 359 | .. versionadded:: 1.13 360 | 361 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 362 | instances will refuse to lock the same path. 363 | 364 | >>> path = '/path/to/file' 365 | >>> with LockFile(path): 366 | >>> with open(path, 'wb') as fp: 367 | >>> fp.write(data) 368 | 369 | Args: 370 | protected_path (unicode): File to protect with a lockfile 371 | timeout (float, optional): Raises an :class:`AcquisitionError` 372 | if lock cannot be acquired within this number of seconds. 373 | If ``timeout`` is 0 (the default), wait forever. 374 | delay (float, optional): How often to check (in seconds) if 375 | lock has been released. 376 | 377 | Attributes: 378 | delay (float): How often to check (in seconds) whether the lock 379 | can be acquired. 380 | lockfile (unicode): Path of the lockfile. 381 | timeout (float): How long to wait to acquire the lock. 382 | 383 | """ 384 | 385 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 386 | """Create new :class:`LockFile` object.""" 387 | self.lockfile = protected_path + '.lock' 388 | self._lockfile = None 389 | self.timeout = timeout 390 | self.delay = delay 391 | self._lock = Event() 392 | atexit.register(self.release) 393 | 394 | @property 395 | def locked(self): 396 | """``True`` if file is locked by this instance.""" 397 | return self._lock.is_set() 398 | 399 | def acquire(self, blocking=True): 400 | """Acquire the lock if possible. 401 | 402 | If the lock is in use and ``blocking`` is ``False``, return 403 | ``False``. 404 | 405 | Otherwise, check every :attr:`delay` seconds until it acquires 406 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 407 | 408 | """ 409 | if self.locked and not blocking: 410 | return False 411 | 412 | start = time.time() 413 | while True: 414 | 415 | # Raise error if we've been waiting too long to acquire the lock 416 | if self.timeout and (time.time() - start) >= self.timeout: 417 | raise AcquisitionError('lock acquisition timed out') 418 | 419 | # If already locked, wait then try again 420 | if self.locked: 421 | time.sleep(self.delay) 422 | continue 423 | 424 | # Create in append mode so we don't lose any contents 425 | if self._lockfile is None: 426 | self._lockfile = open(self.lockfile, 'a') 427 | 428 | # Try to acquire the lock 429 | try: 430 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 431 | self._lock.set() 432 | break 433 | except IOError as err: # pragma: no cover 434 | if err.errno not in (errno.EACCES, errno.EAGAIN): 435 | raise 436 | 437 | # Don't try again 438 | if not blocking: # pragma: no cover 439 | return False 440 | 441 | # Wait, then try again 442 | time.sleep(self.delay) 443 | 444 | return True 445 | 446 | def release(self): 447 | """Release the lock by deleting `self.lockfile`.""" 448 | if not self._lock.is_set(): 449 | return False 450 | 451 | try: 452 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 453 | except IOError: # pragma: no cover 454 | pass 455 | finally: 456 | self._lock.clear() 457 | self._lockfile = None 458 | try: 459 | os.unlink(self.lockfile) 460 | except (IOError, OSError): # pragma: no cover 461 | pass 462 | 463 | return True 464 | 465 | def __enter__(self): 466 | """Acquire lock.""" 467 | self.acquire() 468 | return self 469 | 470 | def __exit__(self, typ, value, traceback): 471 | """Release lock.""" 472 | self.release() 473 | 474 | def __del__(self): 475 | """Clear up `self.lockfile`.""" 476 | self.release() # pragma: no cover 477 | 478 | 479 | class uninterruptible(object): 480 | """Decorator that postpones SIGTERM until wrapped function returns. 481 | 482 | .. versionadded:: 1.12 483 | 484 | .. important:: This decorator is NOT thread-safe. 485 | 486 | As of version 2.7, Alfred allows Script Filters to be killed. If 487 | your workflow is killed in the middle of critical code (e.g. 488 | writing data to disk), this may corrupt your workflow's data. 489 | 490 | Use this decorator to wrap critical functions that *must* complete. 491 | If the script is killed while a wrapped function is executing, 492 | the SIGTERM will be caught and handled after your function has 493 | finished executing. 494 | 495 | Alfred-Workflow uses this internally to ensure its settings, data 496 | and cache writes complete. 497 | 498 | """ 499 | 500 | def __init__(self, func, class_name=''): 501 | """Decorate `func`.""" 502 | self.func = func 503 | functools.update_wrapper(self, func) 504 | self._caught_signal = None 505 | 506 | def signal_handler(self, signum, frame): 507 | """Called when process receives SIGTERM.""" 508 | self._caught_signal = (signum, frame) 509 | 510 | def __call__(self, *args, **kwargs): 511 | """Trap ``SIGTERM`` and call wrapped function.""" 512 | self._caught_signal = None 513 | # Register handler for SIGTERM, then call `self.func` 514 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 515 | signal.signal(signal.SIGTERM, self.signal_handler) 516 | 517 | self.func(*args, **kwargs) 518 | 519 | # Restore old signal handler 520 | signal.signal(signal.SIGTERM, self.old_signal_handler) 521 | 522 | # Handle any signal caught during execution 523 | if self._caught_signal is not None: 524 | signum, frame = self._caught_signal 525 | if callable(self.old_signal_handler): 526 | self.old_signal_handler(signum, frame) 527 | elif self.old_signal_handler == signal.SIG_DFL: 528 | sys.exit(0) 529 | 530 | def __get__(self, obj=None, klass=None): 531 | """Decorator API.""" 532 | return self.__class__(self.func.__get__(obj, klass), 533 | klass.__name__) 534 | -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | 7 | category 8 | Internet 9 | connections 10 | 11 | 07D583FC-AFE3-47D1-8076-FF78BE066E4E 12 | 13 | 14 | destinationuid 15 | 1D6A596A-AC0F-4636-BD4E-54393AF66480 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 1D6A596A-AC0F-4636-BD4E-54393AF66480 25 | 26 | 27 | destinationuid 28 | 8CEA566D-1206-4BCD-B4B6-98744ED00ACB 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | 2DDE5C6C-F4CE-4438-B34C-A6D75E3B63B1 38 | 39 | 40 | destinationuid 41 | 29A7F627-6B96-49DA-8900-5DD1D03CE06C 42 | modifiers 43 | 0 44 | modifiersubtext 45 | 46 | vitoclose 47 | 48 | 49 | 50 | 7DD49BD6-5445-4547-B438-E8B21E93DAE5 51 | 52 | 53 | destinationuid 54 | B6427010-7B56-4342-B5AC-85D22C5E5C50 55 | modifiers 56 | 0 57 | modifiersubtext 58 | 59 | vitoclose 60 | 61 | 62 | 63 | A61EBF1F-A567-4C64-A518-699270FA1D14 64 | 65 | 66 | destinationuid 67 | 69290AD4-4F59-4B83-A57D-CBB1719BD40C 68 | modifiers 69 | 0 70 | modifiersubtext 71 | 72 | vitoclose 73 | 74 | 75 | 76 | B6427010-7B56-4342-B5AC-85D22C5E5C50 77 | 78 | 79 | destinationuid 80 | A62E393E-7B4C-4097-90D8-01F20E5FF67D 81 | modifiers 82 | 0 83 | modifiersubtext 84 | 85 | vitoclose 86 | 87 | 88 | 89 | C3C3143C-7AA7-47E0-953C-80F6D8FAD7BF 90 | 91 | 92 | destinationuid 93 | 9C524F85-B372-4FD7-9189-56B8585F3888 94 | modifiers 95 | 0 96 | modifiersubtext 97 | 98 | vitoclose 99 | 100 | 101 | 102 | E780650D-E8CD-434B-AACC-7F96E8F7AD3C 103 | 104 | 105 | destinationuid 106 | B12B07B3-1ADC-4677-8D9B-050AC916DF49 107 | modifiers 108 | 0 109 | modifiersubtext 110 | 111 | vitoclose 112 | 113 | 114 | 115 | 116 | createdby 117 | wangshub 118 | description 119 | 120 | disabled 121 | 122 | name 123 | GitLab-Tool 124 | objects 125 | 126 | 127 | config 128 | 129 | browser 130 | 131 | spaces 132 | 133 | url 134 | {query} 135 | utf8 136 | 137 | 138 | type 139 | alfred.workflow.action.openurl 140 | uid 141 | B12B07B3-1ADC-4677-8D9B-050AC916DF49 142 | version 143 | 1 144 | 145 | 146 | config 147 | 148 | browser 149 | 150 | spaces 151 | 152 | url 153 | {query} 154 | utf8 155 | 156 | 157 | type 158 | alfred.workflow.action.openurl 159 | uid 160 | 9C524F85-B372-4FD7-9189-56B8585F3888 161 | version 162 | 1 163 | 164 | 165 | config 166 | 167 | alfredfiltersresults 168 | 169 | alfredfiltersresultsmatchmode 170 | 0 171 | argumenttrimmode 172 | 0 173 | argumenttype 174 | 2 175 | escaping 176 | 102 177 | keyword 178 | glm 179 | queuedelaycustom 180 | 3 181 | queuedelayimmediatelyinitially 182 | 183 | queuedelaymode 184 | 0 185 | queuemode 186 | 1 187 | runningsubtext 188 | Please wait ... 189 | script 190 | python main.py --merge "{query}" 191 | scriptargtype 192 | 0 193 | scriptfile 194 | 195 | subtext 196 | Open merge requests assigned to you. 197 | title 198 | Open merge requests assigned to you. 199 | type 200 | 0 201 | withspace 202 | 203 | 204 | type 205 | alfred.workflow.input.scriptfilter 206 | uid 207 | E780650D-E8CD-434B-AACC-7F96E8F7AD3C 208 | version 209 | 2 210 | 211 | 212 | config 213 | 214 | alfredfiltersresults 215 | 216 | alfredfiltersresultsmatchmode 217 | 0 218 | argumenttrimmode 219 | 0 220 | argumenttype 221 | 0 222 | escaping 223 | 102 224 | keyword 225 | glp 226 | queuedelaycustom 227 | 3 228 | queuedelayimmediatelyinitially 229 | 230 | queuedelaymode 231 | 0 232 | queuemode 233 | 1 234 | runningsubtext 235 | Please wait ... 236 | script 237 | python main.py --repo "{query}" 238 | scriptargtype 239 | 0 240 | scriptfile 241 | 242 | subtext 243 | 244 | title 245 | Searching Gitlab Projects 246 | type 247 | 0 248 | withspace 249 | 250 | 251 | type 252 | alfred.workflow.input.scriptfilter 253 | uid 254 | C3C3143C-7AA7-47E0-953C-80F6D8FAD7BF 255 | version 256 | 2 257 | 258 | 259 | config 260 | 261 | alfredfiltersresults 262 | 263 | alfredfiltersresultsmatchmode 264 | 0 265 | argumenttrimmode 266 | 0 267 | argumenttype 268 | 0 269 | escaping 270 | 102 271 | keyword 272 | gli 273 | queuedelaycustom 274 | 3 275 | queuedelayimmediatelyinitially 276 | 277 | queuedelaymode 278 | 0 279 | queuemode 280 | 1 281 | runningsubtext 282 | Please wait ... 283 | script 284 | python main.py --issue "{query}" 285 | scriptargtype 286 | 0 287 | scriptfile 288 | 289 | subtext 290 | Search gitlab issues 291 | title 292 | Search gitlab issues 293 | type 294 | 0 295 | withspace 296 | 297 | 298 | type 299 | alfred.workflow.input.scriptfilter 300 | uid 301 | 2DDE5C6C-F4CE-4438-B34C-A6D75E3B63B1 302 | version 303 | 2 304 | 305 | 306 | config 307 | 308 | browser 309 | 310 | spaces 311 | 312 | url 313 | {query} 314 | utf8 315 | 316 | 317 | type 318 | alfred.workflow.action.openurl 319 | uid 320 | 29A7F627-6B96-49DA-8900-5DD1D03CE06C 321 | version 322 | 1 323 | 324 | 325 | config 326 | 327 | alfredfiltersresults 328 | 329 | alfredfiltersresultsmatchmode 330 | 0 331 | argumenttrimmode 332 | 0 333 | argumenttype 334 | 2 335 | escaping 336 | 102 337 | keyword 338 | gltodo 339 | queuedelaycustom 340 | 3 341 | queuedelayimmediatelyinitially 342 | 343 | queuedelaymode 344 | 0 345 | queuemode 346 | 1 347 | runningsubtext 348 | Please wait ... 349 | script 350 | python main.py --todo "{query}" 351 | scriptargtype 352 | 0 353 | scriptfile 354 | 355 | subtext 356 | Open todos 357 | title 358 | Open todos 359 | type 360 | 0 361 | withspace 362 | 363 | 364 | type 365 | alfred.workflow.input.scriptfilter 366 | uid 367 | A61EBF1F-A567-4C64-A518-699270FA1D14 368 | version 369 | 2 370 | 371 | 372 | config 373 | 374 | browser 375 | 376 | spaces 377 | 378 | url 379 | {query} 380 | utf8 381 | 382 | 383 | type 384 | alfred.workflow.action.openurl 385 | uid 386 | 69290AD4-4F59-4B83-A57D-CBB1719BD40C 387 | version 388 | 1 389 | 390 | 391 | config 392 | 393 | lastpathcomponent 394 | 395 | onlyshowifquerypopulated 396 | 397 | removeextension 398 | 399 | text 400 | Your GitLab API key has been saved. 401 | title 402 | Saved API Key 403 | 404 | type 405 | alfred.workflow.output.notification 406 | uid 407 | 8CEA566D-1206-4BCD-B4B6-98744ED00ACB 408 | version 409 | 1 410 | 411 | 412 | config 413 | 414 | argumenttype 415 | 0 416 | keyword 417 | gltoken 418 | subtext 419 | For example: xxxxxxxxxxxxxxxxxxxx 420 | text 421 | Set your GitLab Token 422 | withspace 423 | 424 | 425 | type 426 | alfred.workflow.input.keyword 427 | uid 428 | 07D583FC-AFE3-47D1-8076-FF78BE066E4E 429 | version 430 | 1 431 | 432 | 433 | config 434 | 435 | concurrently 436 | 437 | escaping 438 | 102 439 | script 440 | python main.py --token "{query}" 441 | scriptargtype 442 | 0 443 | scriptfile 444 | 445 | type 446 | 0 447 | 448 | type 449 | alfred.workflow.action.script 450 | uid 451 | 1D6A596A-AC0F-4636-BD4E-54393AF66480 452 | version 453 | 2 454 | 455 | 456 | config 457 | 458 | lastpathcomponent 459 | 460 | onlyshowifquerypopulated 461 | 462 | removeextension 463 | 464 | text 465 | Your GitLab URL has been saved. 466 | title 467 | Saved API URL 468 | 469 | type 470 | alfred.workflow.output.notification 471 | uid 472 | A62E393E-7B4C-4097-90D8-01F20E5FF67D 473 | version 474 | 1 475 | 476 | 477 | config 478 | 479 | concurrently 480 | 481 | escaping 482 | 102 483 | script 484 | python main.py --url "{query}" 485 | scriptargtype 486 | 0 487 | scriptfile 488 | 489 | type 490 | 0 491 | 492 | type 493 | alfred.workflow.action.script 494 | uid 495 | B6427010-7B56-4342-B5AC-85D22C5E5C50 496 | version 497 | 2 498 | 499 | 500 | config 501 | 502 | argumenttype 503 | 0 504 | keyword 505 | glurl 506 | subtext 507 | For example: http://example.com/api/v4 508 | text 509 | Set your GitLab URL 510 | withspace 511 | 512 | 513 | type 514 | alfred.workflow.input.keyword 515 | uid 516 | 7DD49BD6-5445-4547-B438-E8B21E93DAE5 517 | version 518 | 1 519 | 520 | 521 | readme 522 | glurl: Set Gitlab url. 523 | gltoken: Set Gitlab token. 524 | glp: Fuzzy search Gitlab projects. 525 | glm: Navigate Gitlab merge requests assigned to you. 526 | gli: Search Gitlab issues via API. 527 | gltodo: Open Gitlab todos in browser. 528 | uidata 529 | 530 | 07D583FC-AFE3-47D1-8076-FF78BE066E4E 531 | 532 | xpos 533 | 110 534 | ypos 535 | 440 536 | 537 | 1D6A596A-AC0F-4636-BD4E-54393AF66480 538 | 539 | xpos 540 | 340 541 | ypos 542 | 440 543 | 544 | 29A7F627-6B96-49DA-8900-5DD1D03CE06C 545 | 546 | xpos 547 | 340 548 | ypos 549 | 200 550 | 551 | 2DDE5C6C-F4CE-4438-B34C-A6D75E3B63B1 552 | 553 | xpos 554 | 110 555 | ypos 556 | 200 557 | 558 | 69290AD4-4F59-4B83-A57D-CBB1719BD40C 559 | 560 | xpos 561 | 340 562 | ypos 563 | 320 564 | 565 | 7DD49BD6-5445-4547-B438-E8B21E93DAE5 566 | 567 | xpos 568 | 110 569 | ypos 570 | 560 571 | 572 | 8CEA566D-1206-4BCD-B4B6-98744ED00ACB 573 | 574 | xpos 575 | 550 576 | ypos 577 | 440 578 | 579 | 9C524F85-B372-4FD7-9189-56B8585F3888 580 | 581 | xpos 582 | 340 583 | ypos 584 | 80 585 | 586 | A61EBF1F-A567-4C64-A518-699270FA1D14 587 | 588 | xpos 589 | 110 590 | ypos 591 | 320 592 | 593 | A62E393E-7B4C-4097-90D8-01F20E5FF67D 594 | 595 | xpos 596 | 550 597 | ypos 598 | 560 599 | 600 | B12B07B3-1ADC-4677-8D9B-050AC916DF49 601 | 602 | xpos 603 | 750 604 | ypos 605 | 80 606 | 607 | B6427010-7B56-4342-B5AC-85D22C5E5C50 608 | 609 | xpos 610 | 340 611 | ypos 612 | 560 613 | 614 | C3C3143C-7AA7-47E0-953C-80F6D8FAD7BF 615 | 616 | xpos 617 | 110 618 | ypos 619 | 80 620 | 621 | E780650D-E8CD-434B-AACC-7F96E8F7AD3C 622 | 623 | xpos 624 | 520 625 | ypos 626 | 80 627 | 628 | 629 | version 630 | 1.2.0 631 | webaddress 632 | https://github.com/wangshub 633 | 634 | 635 | -------------------------------------------------------------------------------- /workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | import codecs 13 | import json 14 | import mimetypes 15 | import os 16 | import random 17 | import re 18 | import socket 19 | import string 20 | import unicodedata 21 | import urllib 22 | import urllib2 23 | import urlparse 24 | import zlib 25 | 26 | 27 | USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' 28 | 29 | # Valid characters for multipart form data boundaries 30 | BOUNDARY_CHARS = string.digits + string.ascii_letters 31 | 32 | # HTTP response codes 33 | RESPONSES = { 34 | 100: 'Continue', 35 | 101: 'Switching Protocols', 36 | 200: 'OK', 37 | 201: 'Created', 38 | 202: 'Accepted', 39 | 203: 'Non-Authoritative Information', 40 | 204: 'No Content', 41 | 205: 'Reset Content', 42 | 206: 'Partial Content', 43 | 300: 'Multiple Choices', 44 | 301: 'Moved Permanently', 45 | 302: 'Found', 46 | 303: 'See Other', 47 | 304: 'Not Modified', 48 | 305: 'Use Proxy', 49 | 307: 'Temporary Redirect', 50 | 400: 'Bad Request', 51 | 401: 'Unauthorized', 52 | 402: 'Payment Required', 53 | 403: 'Forbidden', 54 | 404: 'Not Found', 55 | 405: 'Method Not Allowed', 56 | 406: 'Not Acceptable', 57 | 407: 'Proxy Authentication Required', 58 | 408: 'Request Timeout', 59 | 409: 'Conflict', 60 | 410: 'Gone', 61 | 411: 'Length Required', 62 | 412: 'Precondition Failed', 63 | 413: 'Request Entity Too Large', 64 | 414: 'Request-URI Too Long', 65 | 415: 'Unsupported Media Type', 66 | 416: 'Requested Range Not Satisfiable', 67 | 417: 'Expectation Failed', 68 | 500: 'Internal Server Error', 69 | 501: 'Not Implemented', 70 | 502: 'Bad Gateway', 71 | 503: 'Service Unavailable', 72 | 504: 'Gateway Timeout', 73 | 505: 'HTTP Version Not Supported' 74 | } 75 | 76 | 77 | def str_dict(dic): 78 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 79 | 80 | :param dic: Mapping of Unicode strings 81 | :type dic: dict 82 | :returns: Dictionary containing only UTF-8 strings 83 | :rtype: dict 84 | 85 | """ 86 | if isinstance(dic, CaseInsensitiveDictionary): 87 | dic2 = CaseInsensitiveDictionary() 88 | else: 89 | dic2 = {} 90 | for k, v in dic.items(): 91 | if isinstance(k, unicode): 92 | k = k.encode('utf-8') 93 | if isinstance(v, unicode): 94 | v = v.encode('utf-8') 95 | dic2[k] = v 96 | return dic2 97 | 98 | 99 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 100 | """Prevent redirections.""" 101 | 102 | def redirect_request(self, *args): 103 | return None 104 | 105 | 106 | # Adapted from https://gist.github.com/babakness/3901174 107 | class CaseInsensitiveDictionary(dict): 108 | """Dictionary with caseless key search. 109 | 110 | Enables case insensitive searching while preserving case sensitivity 111 | when keys are listed, ie, via keys() or items() methods. 112 | 113 | Works by storing a lowercase version of the key as the new key and 114 | stores the original key-value pair as the key's value 115 | (values become dictionaries). 116 | 117 | """ 118 | 119 | def __init__(self, initval=None): 120 | """Create new case-insensitive dictionary.""" 121 | if isinstance(initval, dict): 122 | for key, value in initval.iteritems(): 123 | self.__setitem__(key, value) 124 | 125 | elif isinstance(initval, list): 126 | for (key, value) in initval: 127 | self.__setitem__(key, value) 128 | 129 | def __contains__(self, key): 130 | return dict.__contains__(self, key.lower()) 131 | 132 | def __getitem__(self, key): 133 | return dict.__getitem__(self, key.lower())['val'] 134 | 135 | def __setitem__(self, key, value): 136 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 137 | 138 | def get(self, key, default=None): 139 | try: 140 | v = dict.__getitem__(self, key.lower()) 141 | except KeyError: 142 | return default 143 | else: 144 | return v['val'] 145 | 146 | def update(self, other): 147 | for k, v in other.items(): 148 | self[k] = v 149 | 150 | def items(self): 151 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 152 | 153 | def keys(self): 154 | return [v['key'] for v in dict.itervalues(self)] 155 | 156 | def values(self): 157 | return [v['val'] for v in dict.itervalues(self)] 158 | 159 | def iteritems(self): 160 | for v in dict.itervalues(self): 161 | yield v['key'], v['val'] 162 | 163 | def iterkeys(self): 164 | for v in dict.itervalues(self): 165 | yield v['key'] 166 | 167 | def itervalues(self): 168 | for v in dict.itervalues(self): 169 | yield v['val'] 170 | 171 | 172 | class Response(object): 173 | """ 174 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 175 | 176 | Simplified version of the ``Response`` object in the ``requests`` library. 177 | 178 | >>> r = request('http://www.google.com') 179 | >>> r.status_code 180 | 200 181 | >>> r.encoding 182 | ISO-8859-1 183 | >>> r.content # bytes 184 | ... 185 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 186 | u' ...' 187 | >>> r.json() # content parsed as JSON 188 | 189 | """ 190 | 191 | def __init__(self, request, stream=False): 192 | """Call `request` with :mod:`urllib2` and process results. 193 | 194 | :param request: :class:`urllib2.Request` instance 195 | :param stream: Whether to stream response or retrieve it all at once 196 | :type stream: bool 197 | 198 | """ 199 | self.request = request 200 | self._stream = stream 201 | self.url = None 202 | self.raw = None 203 | self._encoding = None 204 | self.error = None 205 | self.status_code = None 206 | self.reason = None 207 | self.headers = CaseInsensitiveDictionary() 208 | self._content = None 209 | self._content_loaded = False 210 | self._gzipped = False 211 | 212 | # Execute query 213 | try: 214 | self.raw = urllib2.urlopen(request) 215 | except urllib2.HTTPError as err: 216 | self.error = err 217 | try: 218 | self.url = err.geturl() 219 | # sometimes (e.g. when authentication fails) 220 | # urllib can't get a URL from an HTTPError 221 | # This behaviour changes across Python versions, 222 | # so no test cover (it isn't important). 223 | except AttributeError: # pragma: no cover 224 | pass 225 | self.status_code = err.code 226 | else: 227 | self.status_code = self.raw.getcode() 228 | self.url = self.raw.geturl() 229 | self.reason = RESPONSES.get(self.status_code) 230 | 231 | # Parse additional info if request succeeded 232 | if not self.error: 233 | headers = self.raw.info() 234 | self.transfer_encoding = headers.getencoding() 235 | self.mimetype = headers.gettype() 236 | for key in headers.keys(): 237 | self.headers[key.lower()] = headers.get(key) 238 | 239 | # Is content gzipped? 240 | # Transfer-Encoding appears to not be used in the wild 241 | # (contrary to the HTTP standard), but no harm in testing 242 | # for it 243 | if ('gzip' in headers.get('content-encoding', '') or 244 | 'gzip' in headers.get('transfer-encoding', '')): 245 | self._gzipped = True 246 | 247 | @property 248 | def stream(self): 249 | """Whether response is streamed. 250 | 251 | Returns: 252 | bool: `True` if response is streamed. 253 | """ 254 | return self._stream 255 | 256 | @stream.setter 257 | def stream(self, value): 258 | if self._content_loaded: 259 | raise RuntimeError("`content` has already been read from " 260 | "this Response.") 261 | 262 | self._stream = value 263 | 264 | def json(self): 265 | """Decode response contents as JSON. 266 | 267 | :returns: object decoded from JSON 268 | :rtype: list, dict or unicode 269 | 270 | """ 271 | return json.loads(self.content, self.encoding or 'utf-8') 272 | 273 | @property 274 | def encoding(self): 275 | """Text encoding of document or ``None``. 276 | 277 | :returns: Text encoding if found. 278 | :rtype: str or ``None`` 279 | 280 | """ 281 | if not self._encoding: 282 | self._encoding = self._get_encoding() 283 | 284 | return self._encoding 285 | 286 | @property 287 | def content(self): 288 | """Raw content of response (i.e. bytes). 289 | 290 | :returns: Body of HTTP response 291 | :rtype: str 292 | 293 | """ 294 | if not self._content: 295 | 296 | # Decompress gzipped content 297 | if self._gzipped: 298 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 299 | self._content = decoder.decompress(self.raw.read()) 300 | 301 | else: 302 | self._content = self.raw.read() 303 | 304 | self._content_loaded = True 305 | 306 | return self._content 307 | 308 | @property 309 | def text(self): 310 | """Unicode-decoded content of response body. 311 | 312 | If no encoding can be determined from HTTP headers or the content 313 | itself, the encoded response body will be returned instead. 314 | 315 | :returns: Body of HTTP response 316 | :rtype: unicode or str 317 | 318 | """ 319 | if self.encoding: 320 | return unicodedata.normalize('NFC', unicode(self.content, 321 | self.encoding)) 322 | return self.content 323 | 324 | def iter_content(self, chunk_size=4096, decode_unicode=False): 325 | """Iterate over response data. 326 | 327 | .. versionadded:: 1.6 328 | 329 | :param chunk_size: Number of bytes to read into memory 330 | :type chunk_size: int 331 | :param decode_unicode: Decode to Unicode using detected encoding 332 | :type decode_unicode: bool 333 | :returns: iterator 334 | 335 | """ 336 | if not self.stream: 337 | raise RuntimeError("You cannot call `iter_content` on a " 338 | "Response unless you passed `stream=True`" 339 | " to `get()`/`post()`/`request()`.") 340 | 341 | if self._content_loaded: 342 | raise RuntimeError( 343 | "`content` has already been read from this Response.") 344 | 345 | def decode_stream(iterator, r): 346 | 347 | decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') 348 | 349 | for chunk in iterator: 350 | data = decoder.decode(chunk) 351 | if data: 352 | yield data 353 | 354 | data = decoder.decode(b'', final=True) 355 | if data: # pragma: no cover 356 | yield data 357 | 358 | def generate(): 359 | 360 | if self._gzipped: 361 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 362 | 363 | while True: 364 | chunk = self.raw.read(chunk_size) 365 | if not chunk: 366 | break 367 | 368 | if self._gzipped: 369 | chunk = decoder.decompress(chunk) 370 | 371 | yield chunk 372 | 373 | chunks = generate() 374 | 375 | if decode_unicode and self.encoding: 376 | chunks = decode_stream(chunks, self) 377 | 378 | return chunks 379 | 380 | def save_to_path(self, filepath): 381 | """Save retrieved data to file at ``filepath``. 382 | 383 | .. versionadded: 1.9.6 384 | 385 | :param filepath: Path to save retrieved data. 386 | 387 | """ 388 | filepath = os.path.abspath(filepath) 389 | dirname = os.path.dirname(filepath) 390 | if not os.path.exists(dirname): 391 | os.makedirs(dirname) 392 | 393 | self.stream = True 394 | 395 | with open(filepath, 'wb') as fileobj: 396 | for data in self.iter_content(): 397 | fileobj.write(data) 398 | 399 | def raise_for_status(self): 400 | """Raise stored error if one occurred. 401 | 402 | error will be instance of :class:`urllib2.HTTPError` 403 | """ 404 | if self.error is not None: 405 | raise self.error 406 | return 407 | 408 | def _get_encoding(self): 409 | """Get encoding from HTTP headers or content. 410 | 411 | :returns: encoding or `None` 412 | :rtype: unicode or ``None`` 413 | 414 | """ 415 | headers = self.raw.info() 416 | encoding = None 417 | 418 | if headers.getparam('charset'): 419 | encoding = headers.getparam('charset') 420 | 421 | # HTTP Content-Type header 422 | for param in headers.getplist(): 423 | if param.startswith('charset='): 424 | encoding = param[8:] 425 | break 426 | 427 | if not self.stream: # Try sniffing response content 428 | # Encoding declared in document should override HTTP headers 429 | if self.mimetype == 'text/html': # sniff HTML headers 430 | m = re.search("""""", 431 | self.content) 432 | if m: 433 | encoding = m.group(1) 434 | 435 | elif ((self.mimetype.startswith('application/') or 436 | self.mimetype.startswith('text/')) and 437 | 'xml' in self.mimetype): 438 | m = re.search("""]*\?>""", 439 | self.content) 440 | if m: 441 | encoding = m.group(1) 442 | 443 | # Format defaults 444 | if self.mimetype == 'application/json' and not encoding: 445 | # The default encoding for JSON 446 | encoding = 'utf-8' 447 | 448 | elif self.mimetype == 'application/xml' and not encoding: 449 | # The default for 'application/xml' 450 | encoding = 'utf-8' 451 | 452 | if encoding: 453 | encoding = encoding.lower() 454 | 455 | return encoding 456 | 457 | 458 | def request(method, url, params=None, data=None, headers=None, cookies=None, 459 | files=None, auth=None, timeout=60, allow_redirects=False, 460 | stream=False): 461 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 462 | 463 | :param method: 'GET' or 'POST' 464 | :type method: unicode 465 | :param url: URL to open 466 | :type url: unicode 467 | :param params: mapping of URL parameters 468 | :type params: dict 469 | :param data: mapping of form data ``{'field_name': 'value'}`` or 470 | :class:`str` 471 | :type data: dict or str 472 | :param headers: HTTP headers 473 | :type headers: dict 474 | :param cookies: cookies to send to server 475 | :type cookies: dict 476 | :param files: files to upload (see below). 477 | :type files: dict 478 | :param auth: username, password 479 | :type auth: tuple 480 | :param timeout: connection timeout limit in seconds 481 | :type timeout: int 482 | :param allow_redirects: follow redirections 483 | :type allow_redirects: bool 484 | :param stream: Stream content instead of fetching it all at once. 485 | :type stream: bool 486 | :returns: Response object 487 | :rtype: :class:`Response` 488 | 489 | 490 | The ``files`` argument is a dictionary:: 491 | 492 | {'fieldname' : { 'filename': 'blah.txt', 493 | 'content': '', 494 | 'mimetype': 'text/plain'} 495 | } 496 | 497 | * ``fieldname`` is the name of the field in the HTML form. 498 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 499 | be used to guess the mimetype, or ``application/octet-stream`` 500 | will be used. 501 | 502 | """ 503 | # TODO: cookies 504 | socket.setdefaulttimeout(timeout) 505 | 506 | # Default handlers 507 | openers = [] 508 | 509 | if not allow_redirects: 510 | openers.append(NoRedirectHandler()) 511 | 512 | if auth is not None: # Add authorisation handler 513 | username, password = auth 514 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 515 | password_manager.add_password(None, url, username, password) 516 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 517 | openers.append(auth_manager) 518 | 519 | # Install our custom chain of openers 520 | opener = urllib2.build_opener(*openers) 521 | urllib2.install_opener(opener) 522 | 523 | if not headers: 524 | headers = CaseInsensitiveDictionary() 525 | else: 526 | headers = CaseInsensitiveDictionary(headers) 527 | 528 | if 'user-agent' not in headers: 529 | headers['user-agent'] = USER_AGENT 530 | 531 | # Accept gzip-encoded content 532 | encodings = [s.strip() for s in 533 | headers.get('accept-encoding', '').split(',')] 534 | if 'gzip' not in encodings: 535 | encodings.append('gzip') 536 | 537 | headers['accept-encoding'] = ', '.join(encodings) 538 | 539 | # Force POST by providing an empty data string 540 | if method == 'POST' and not data: 541 | data = '' 542 | 543 | if files: 544 | if not data: 545 | data = {} 546 | new_headers, data = encode_multipart_formdata(data, files) 547 | headers.update(new_headers) 548 | elif data and isinstance(data, dict): 549 | data = urllib.urlencode(str_dict(data)) 550 | 551 | # Make sure everything is encoded text 552 | headers = str_dict(headers) 553 | 554 | if isinstance(url, unicode): 555 | url = url.encode('utf-8') 556 | 557 | if params: # GET args (POST args are handled in encode_multipart_formdata) 558 | 559 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 560 | 561 | if query: # Combine query string and `params` 562 | url_params = urlparse.parse_qs(query) 563 | # `params` take precedence over URL query string 564 | url_params.update(params) 565 | params = url_params 566 | 567 | query = urllib.urlencode(str_dict(params), doseq=True) 568 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 569 | 570 | req = urllib2.Request(url, data, headers) 571 | return Response(req, stream) 572 | 573 | 574 | def get(url, params=None, headers=None, cookies=None, auth=None, 575 | timeout=60, allow_redirects=True, stream=False): 576 | """Initiate a GET request. Arguments as for :func:`request`. 577 | 578 | :returns: :class:`Response` instance 579 | 580 | """ 581 | return request('GET', url, params, headers=headers, cookies=cookies, 582 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 583 | stream=stream) 584 | 585 | 586 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 587 | auth=None, timeout=60, allow_redirects=False, stream=False): 588 | """Initiate a POST request. Arguments as for :func:`request`. 589 | 590 | :returns: :class:`Response` instance 591 | 592 | """ 593 | return request('POST', url, params, data, headers, cookies, files, auth, 594 | timeout, allow_redirects, stream) 595 | 596 | 597 | def encode_multipart_formdata(fields, files): 598 | """Encode form data (``fields``) and ``files`` for POST request. 599 | 600 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 601 | :type fields: dict 602 | :param files: dictionary of fieldnames/files elements for file data. 603 | See below for details. 604 | :type files: dict of :class:`dict` 605 | :returns: ``(headers, body)`` ``headers`` is a 606 | :class:`dict` of HTTP headers 607 | :rtype: 2-tuple ``(dict, str)`` 608 | 609 | The ``files`` argument is a dictionary:: 610 | 611 | {'fieldname' : { 'filename': 'blah.txt', 612 | 'content': '', 613 | 'mimetype': 'text/plain'} 614 | } 615 | 616 | - ``fieldname`` is the name of the field in the HTML form. 617 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 618 | be used to guess the mimetype, or ``application/octet-stream`` 619 | will be used. 620 | 621 | """ 622 | def get_content_type(filename): 623 | """Return or guess mimetype of ``filename``. 624 | 625 | :param filename: filename of file 626 | :type filename: unicode/str 627 | :returns: mime-type, e.g. ``text/html`` 628 | :rtype: str 629 | 630 | """ 631 | 632 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 633 | 634 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 635 | for i in range(30)) 636 | CRLF = '\r\n' 637 | output = [] 638 | 639 | # Normal form fields 640 | for (name, value) in fields.items(): 641 | if isinstance(name, unicode): 642 | name = name.encode('utf-8') 643 | if isinstance(value, unicode): 644 | value = value.encode('utf-8') 645 | output.append('--' + boundary) 646 | output.append('Content-Disposition: form-data; name="%s"' % name) 647 | output.append('') 648 | output.append(value) 649 | 650 | # Files to upload 651 | for name, d in files.items(): 652 | filename = d[u'filename'] 653 | content = d[u'content'] 654 | if u'mimetype' in d: 655 | mimetype = d[u'mimetype'] 656 | else: 657 | mimetype = get_content_type(filename) 658 | if isinstance(name, unicode): 659 | name = name.encode('utf-8') 660 | if isinstance(filename, unicode): 661 | filename = filename.encode('utf-8') 662 | if isinstance(mimetype, unicode): 663 | mimetype = mimetype.encode('utf-8') 664 | output.append('--' + boundary) 665 | output.append('Content-Disposition: form-data; ' 666 | 'name="%s"; filename="%s"' % (name, filename)) 667 | output.append('Content-Type: %s' % mimetype) 668 | output.append('') 669 | output.append(content) 670 | 671 | output.append('--' + boundary + '--') 672 | output.append('') 673 | body = CRLF.join(output) 674 | headers = { 675 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 676 | 'Content-Length': str(len(body)), 677 | } 678 | return (headers, body) 679 | -------------------------------------------------------------------------------- /workflow/workflow3.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2016-06-25 8 | # 9 | 10 | """An Alfred 3-only version of :class:`~workflow.Workflow`. 11 | 12 | :class:`~workflow.Workflow3` supports Alfred 3's new features, such as 13 | setting :ref:`workflow-variables` and 14 | :class:`the more advanced modifiers ` supported by Alfred 3. 15 | 16 | In order for the feedback mechanism to work correctly, it's important 17 | to create :class:`Item3` and :class:`Modifier` objects via the 18 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 19 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 20 | objects directly, the current :class:`Workflow3` object won't be aware 21 | of them, and they won't be sent to Alfred when you call 22 | :meth:`Workflow3.send_feedback()`. 23 | 24 | """ 25 | 26 | from __future__ import print_function, unicode_literals, absolute_import 27 | 28 | import json 29 | import os 30 | import sys 31 | 32 | from .workflow import ICON_WARNING, Workflow 33 | 34 | 35 | class Variables(dict): 36 | """Workflow variables for Run Script actions. 37 | 38 | .. versionadded: 1.26 39 | 40 | This class allows you to set workflow variables from 41 | Run Script actions. 42 | 43 | It is a subclass of :class:`dict`. 44 | 45 | >>> v = Variables(username='deanishe', password='hunter2') 46 | >>> v.arg = u'output value' 47 | >>> print(v) 48 | 49 | See :ref:`variables-run-script` in the User Guide for more 50 | information. 51 | 52 | Args: 53 | arg (unicode, optional): Main output/``{query}``. 54 | **variables: Workflow variables to set. 55 | 56 | 57 | Attributes: 58 | arg (unicode): Output value (``{query}``). 59 | config (dict): Configuration for downstream workflow element. 60 | 61 | """ 62 | 63 | def __init__(self, arg=None, **variables): 64 | """Create a new `Variables` object.""" 65 | self.arg = arg 66 | self.config = {} 67 | super(Variables, self).__init__(**variables) 68 | 69 | @property 70 | def obj(self): 71 | """Return ``alfredworkflow`` `dict`.""" 72 | o = {} 73 | if self: 74 | d2 = {} 75 | for k, v in self.items(): 76 | d2[k] = v 77 | o['variables'] = d2 78 | 79 | if self.config: 80 | o['config'] = self.config 81 | 82 | if self.arg is not None: 83 | o['arg'] = self.arg 84 | 85 | return {'alfredworkflow': o} 86 | 87 | def __unicode__(self): 88 | """Convert to ``alfredworkflow`` JSON object. 89 | 90 | Returns: 91 | unicode: ``alfredworkflow`` JSON object 92 | 93 | """ 94 | if not self and not self.config: 95 | if self.arg: 96 | return self.arg 97 | else: 98 | return u'' 99 | 100 | return json.dumps(self.obj) 101 | 102 | def __str__(self): 103 | """Convert to ``alfredworkflow`` JSON object. 104 | 105 | Returns: 106 | str: UTF-8 encoded ``alfredworkflow`` JSON object 107 | 108 | """ 109 | return unicode(self).encode('utf-8') 110 | 111 | 112 | class Modifier(object): 113 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 114 | 115 | Don't use this class directly (as it won't be associated with any 116 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 117 | to add modifiers to results. 118 | 119 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 120 | >>> it.setvar('name', 'default') 121 | >>> m = it.add_modifier('cmd') 122 | >>> m.setvar('name', 'alternate') 123 | 124 | See :ref:`workflow-variables` in the User Guide for more information 125 | and :ref:`example usage `. 126 | 127 | Args: 128 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 129 | subtitle (unicode, optional): Override default subtitle. 130 | arg (unicode, optional): Argument to pass for this modifier. 131 | valid (bool, optional): Override item's validity. 132 | icon (unicode, optional): Filepath/UTI of icon to use 133 | icontype (unicode, optional): Type of icon. See 134 | :meth:`Workflow.add_item() ` 135 | for valid values. 136 | 137 | Attributes: 138 | arg (unicode): Arg to pass to following action. 139 | config (dict): Configuration for a downstream element, such as 140 | a File Filter. 141 | icon (unicode): Filepath/UTI of icon. 142 | icontype (unicode): Type of icon. See 143 | :meth:`Workflow.add_item() ` 144 | for valid values. 145 | key (unicode): Modifier key (see above). 146 | subtitle (unicode): Override item subtitle. 147 | valid (bool): Override item validity. 148 | variables (dict): Workflow variables set by this modifier. 149 | 150 | """ 151 | 152 | def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, 153 | icontype=None): 154 | """Create a new :class:`Modifier`. 155 | 156 | Don't use this class directly (as it won't be associated with any 157 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 158 | to add modifiers to results. 159 | 160 | Args: 161 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 162 | subtitle (unicode, optional): Override default subtitle. 163 | arg (unicode, optional): Argument to pass for this modifier. 164 | valid (bool, optional): Override item's validity. 165 | icon (unicode, optional): Filepath/UTI of icon to use 166 | icontype (unicode, optional): Type of icon. See 167 | :meth:`Workflow.add_item() ` 168 | for valid values. 169 | 170 | """ 171 | self.key = key 172 | self.subtitle = subtitle 173 | self.arg = arg 174 | self.valid = valid 175 | self.icon = icon 176 | self.icontype = icontype 177 | 178 | self.config = {} 179 | self.variables = {} 180 | 181 | def setvar(self, name, value): 182 | """Set a workflow variable for this Item. 183 | 184 | Args: 185 | name (unicode): Name of variable. 186 | value (unicode): Value of variable. 187 | 188 | """ 189 | self.variables[name] = value 190 | 191 | def getvar(self, name, default=None): 192 | """Return value of workflow variable for ``name`` or ``default``. 193 | 194 | Args: 195 | name (unicode): Variable name. 196 | default (None, optional): Value to return if variable is unset. 197 | 198 | Returns: 199 | unicode or ``default``: Value of variable if set or ``default``. 200 | 201 | """ 202 | return self.variables.get(name, default) 203 | 204 | @property 205 | def obj(self): 206 | """Modifier formatted for JSON serialization for Alfred 3. 207 | 208 | Returns: 209 | dict: Modifier for serializing to JSON. 210 | 211 | """ 212 | o = {} 213 | 214 | if self.subtitle is not None: 215 | o['subtitle'] = self.subtitle 216 | 217 | if self.arg is not None: 218 | o['arg'] = self.arg 219 | 220 | if self.valid is not None: 221 | o['valid'] = self.valid 222 | 223 | if self.variables: 224 | o['variables'] = self.variables 225 | 226 | if self.config: 227 | o['config'] = self.config 228 | 229 | icon = self._icon() 230 | if icon: 231 | o['icon'] = icon 232 | 233 | return o 234 | 235 | def _icon(self): 236 | """Return `icon` object for item. 237 | 238 | Returns: 239 | dict: Mapping for item `icon` (may be empty). 240 | 241 | """ 242 | icon = {} 243 | if self.icon is not None: 244 | icon['path'] = self.icon 245 | 246 | if self.icontype is not None: 247 | icon['type'] = self.icontype 248 | 249 | return icon 250 | 251 | 252 | class Item3(object): 253 | """Represents a feedback item for Alfred 3. 254 | 255 | Generates Alfred-compliant JSON for a single item. 256 | 257 | Don't use this class directly (as it then won't be associated with 258 | any :class:`Workflow3 ` object), but rather use 259 | :meth:`Workflow3.add_item() `. 260 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 261 | 262 | """ 263 | 264 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 265 | match=None, valid=False, uid=None, icon=None, icontype=None, 266 | type=None, largetext=None, copytext=None, quicklookurl=None): 267 | """Create a new :class:`Item3` object. 268 | 269 | Use same arguments as for 270 | :class:`Workflow.Item `. 271 | 272 | Argument ``subtitle_modifiers`` is not supported. 273 | 274 | """ 275 | self.title = title 276 | self.subtitle = subtitle 277 | self.arg = arg 278 | self.autocomplete = autocomplete 279 | self.match = match 280 | self.valid = valid 281 | self.uid = uid 282 | self.icon = icon 283 | self.icontype = icontype 284 | self.type = type 285 | self.quicklookurl = quicklookurl 286 | self.largetext = largetext 287 | self.copytext = copytext 288 | 289 | self.modifiers = {} 290 | 291 | self.config = {} 292 | self.variables = {} 293 | 294 | def setvar(self, name, value): 295 | """Set a workflow variable for this Item. 296 | 297 | Args: 298 | name (unicode): Name of variable. 299 | value (unicode): Value of variable. 300 | 301 | """ 302 | self.variables[name] = value 303 | 304 | def getvar(self, name, default=None): 305 | """Return value of workflow variable for ``name`` or ``default``. 306 | 307 | Args: 308 | name (unicode): Variable name. 309 | default (None, optional): Value to return if variable is unset. 310 | 311 | Returns: 312 | unicode or ``default``: Value of variable if set or ``default``. 313 | 314 | """ 315 | return self.variables.get(name, default) 316 | 317 | def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, 318 | icontype=None): 319 | """Add alternative values for a modifier key. 320 | 321 | Args: 322 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 323 | subtitle (unicode, optional): Override item subtitle. 324 | arg (unicode, optional): Input for following action. 325 | valid (bool, optional): Override item validity. 326 | icon (unicode, optional): Filepath/UTI of icon. 327 | icontype (unicode, optional): Type of icon. See 328 | :meth:`Workflow.add_item() ` 329 | for valid values. 330 | 331 | Returns: 332 | Modifier: Configured :class:`Modifier`. 333 | 334 | """ 335 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 336 | 337 | # Add Item variables to Modifier 338 | mod.variables.update(self.variables) 339 | 340 | self.modifiers[key] = mod 341 | 342 | return mod 343 | 344 | @property 345 | def obj(self): 346 | """Item formatted for JSON serialization. 347 | 348 | Returns: 349 | dict: Data suitable for Alfred 3 feedback. 350 | 351 | """ 352 | # Required values 353 | o = { 354 | 'title': self.title, 355 | 'subtitle': self.subtitle, 356 | 'valid': self.valid, 357 | } 358 | 359 | # Optional values 360 | if self.arg is not None: 361 | o['arg'] = self.arg 362 | 363 | if self.autocomplete is not None: 364 | o['autocomplete'] = self.autocomplete 365 | 366 | if self.match is not None: 367 | o['match'] = self.match 368 | 369 | if self.uid is not None: 370 | o['uid'] = self.uid 371 | 372 | if self.type is not None: 373 | o['type'] = self.type 374 | 375 | if self.quicklookurl is not None: 376 | o['quicklookurl'] = self.quicklookurl 377 | 378 | if self.variables: 379 | o['variables'] = self.variables 380 | 381 | if self.config: 382 | o['config'] = self.config 383 | 384 | # Largetype and copytext 385 | text = self._text() 386 | if text: 387 | o['text'] = text 388 | 389 | icon = self._icon() 390 | if icon: 391 | o['icon'] = icon 392 | 393 | # Modifiers 394 | mods = self._modifiers() 395 | if mods: 396 | o['mods'] = mods 397 | 398 | return o 399 | 400 | def _icon(self): 401 | """Return `icon` object for item. 402 | 403 | Returns: 404 | dict: Mapping for item `icon` (may be empty). 405 | 406 | """ 407 | icon = {} 408 | if self.icon is not None: 409 | icon['path'] = self.icon 410 | 411 | if self.icontype is not None: 412 | icon['type'] = self.icontype 413 | 414 | return icon 415 | 416 | def _text(self): 417 | """Return `largetext` and `copytext` object for item. 418 | 419 | Returns: 420 | dict: `text` mapping (may be empty) 421 | 422 | """ 423 | text = {} 424 | if self.largetext is not None: 425 | text['largetype'] = self.largetext 426 | 427 | if self.copytext is not None: 428 | text['copy'] = self.copytext 429 | 430 | return text 431 | 432 | def _modifiers(self): 433 | """Build `mods` dictionary for JSON feedback. 434 | 435 | Returns: 436 | dict: Modifier mapping or `None`. 437 | 438 | """ 439 | if self.modifiers: 440 | mods = {} 441 | for k, mod in self.modifiers.items(): 442 | mods[k] = mod.obj 443 | 444 | return mods 445 | 446 | return None 447 | 448 | 449 | class Workflow3(Workflow): 450 | """Workflow class that generates Alfred 3 feedback. 451 | 452 | It is a subclass of :class:`~workflow.Workflow` and most of its 453 | methods are documented there. 454 | 455 | Attributes: 456 | item_class (class): Class used to generate feedback items. 457 | variables (dict): Top level workflow variables. 458 | 459 | """ 460 | 461 | item_class = Item3 462 | 463 | def __init__(self, **kwargs): 464 | """Create a new :class:`Workflow3` object. 465 | 466 | See :class:`~workflow.Workflow` for documentation. 467 | 468 | """ 469 | Workflow.__init__(self, **kwargs) 470 | self.variables = {} 471 | self._rerun = 0 472 | # Get session ID from environment if present 473 | self._session_id = os.getenv('_WF_SESSION_ID') or None 474 | if self._session_id: 475 | self.setvar('_WF_SESSION_ID', self._session_id) 476 | 477 | @property 478 | def _default_cachedir(self): 479 | """Alfred 3's default cache directory.""" 480 | return os.path.join( 481 | os.path.expanduser( 482 | '~/Library/Caches/com.runningwithcrayons.Alfred-3/' 483 | 'Workflow Data/'), 484 | self.bundleid) 485 | 486 | @property 487 | def _default_datadir(self): 488 | """Alfred 3's default data directory.""" 489 | return os.path.join(os.path.expanduser( 490 | '~/Library/Application Support/Alfred 3/Workflow Data/'), 491 | self.bundleid) 492 | 493 | @property 494 | def rerun(self): 495 | """How often (in seconds) Alfred should re-run the Script Filter.""" 496 | return self._rerun 497 | 498 | @rerun.setter 499 | def rerun(self, seconds): 500 | """Interval at which Alfred should re-run the Script Filter. 501 | 502 | Args: 503 | seconds (int): Interval between runs. 504 | """ 505 | self._rerun = seconds 506 | 507 | @property 508 | def session_id(self): 509 | """A unique session ID every time the user uses the workflow. 510 | 511 | .. versionadded:: 1.25 512 | 513 | The session ID persists while the user is using this workflow. 514 | It expires when the user runs a different workflow or closes 515 | Alfred. 516 | 517 | """ 518 | if not self._session_id: 519 | from uuid import uuid4 520 | self._session_id = uuid4().hex 521 | self.setvar('_WF_SESSION_ID', self._session_id) 522 | 523 | return self._session_id 524 | 525 | def setvar(self, name, value, persist=False): 526 | """Set a "global" workflow variable. 527 | 528 | .. versionchanged:: 1.33 529 | 530 | These variables are always passed to downstream workflow objects. 531 | 532 | If you have set :attr:`rerun`, these variables are also passed 533 | back to the script when Alfred runs it again. 534 | 535 | Args: 536 | name (unicode): Name of variable. 537 | value (unicode): Value of variable. 538 | persist (bool, optional): Also save variable to ``info.plist``? 539 | 540 | """ 541 | self.variables[name] = value 542 | if persist: 543 | from .util import set_config 544 | set_config(name, value, self.bundleid) 545 | self.logger.debug('saved variable %r with value %r to info.plist', 546 | name, value) 547 | 548 | def getvar(self, name, default=None): 549 | """Return value of workflow variable for ``name`` or ``default``. 550 | 551 | Args: 552 | name (unicode): Variable name. 553 | default (None, optional): Value to return if variable is unset. 554 | 555 | Returns: 556 | unicode or ``default``: Value of variable if set or ``default``. 557 | 558 | """ 559 | return self.variables.get(name, default) 560 | 561 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 562 | valid=False, uid=None, icon=None, icontype=None, type=None, 563 | largetext=None, copytext=None, quicklookurl=None, match=None): 564 | """Add an item to be output to Alfred. 565 | 566 | Args: 567 | match (unicode, optional): If you have "Alfred filters results" 568 | turned on for your Script Filter, Alfred (version 3.5 and 569 | above) will filter against this field, not ``title``. 570 | 571 | See :meth:`Workflow.add_item() ` for 572 | the main documentation and other parameters. 573 | 574 | The key difference is that this method does not support the 575 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 576 | method instead on the returned item instead. 577 | 578 | Returns: 579 | Item3: Alfred feedback item. 580 | 581 | """ 582 | item = self.item_class(title, subtitle, arg, autocomplete, 583 | match, valid, uid, icon, icontype, type, 584 | largetext, copytext, quicklookurl) 585 | 586 | # Add variables to child item 587 | item.variables.update(self.variables) 588 | 589 | self._items.append(item) 590 | return item 591 | 592 | @property 593 | def _session_prefix(self): 594 | """Filename prefix for current session.""" 595 | return '_wfsess-{0}-'.format(self.session_id) 596 | 597 | def _mk_session_name(self, name): 598 | """New cache name/key based on session ID.""" 599 | return self._session_prefix + name 600 | 601 | def cache_data(self, name, data, session=False): 602 | """Cache API with session-scoped expiry. 603 | 604 | .. versionadded:: 1.25 605 | 606 | Args: 607 | name (str): Cache key 608 | data (object): Data to cache 609 | session (bool, optional): Whether to scope the cache 610 | to the current session. 611 | 612 | ``name`` and ``data`` are the same as for the 613 | :meth:`~workflow.Workflow.cache_data` method on 614 | :class:`~workflow.Workflow`. 615 | 616 | If ``session`` is ``True``, then ``name`` is prefixed 617 | with :attr:`session_id`. 618 | 619 | """ 620 | if session: 621 | name = self._mk_session_name(name) 622 | 623 | return super(Workflow3, self).cache_data(name, data) 624 | 625 | def cached_data(self, name, data_func=None, max_age=60, session=False): 626 | """Cache API with session-scoped expiry. 627 | 628 | .. versionadded:: 1.25 629 | 630 | Args: 631 | name (str): Cache key 632 | data_func (callable): Callable that returns fresh data. It 633 | is called if the cache has expired or doesn't exist. 634 | max_age (int): Maximum allowable age of cache in seconds. 635 | session (bool, optional): Whether to scope the cache 636 | to the current session. 637 | 638 | ``name``, ``data_func`` and ``max_age`` are the same as for the 639 | :meth:`~workflow.Workflow.cached_data` method on 640 | :class:`~workflow.Workflow`. 641 | 642 | If ``session`` is ``True``, then ``name`` is prefixed 643 | with :attr:`session_id`. 644 | 645 | """ 646 | if session: 647 | name = self._mk_session_name(name) 648 | 649 | return super(Workflow3, self).cached_data(name, data_func, max_age) 650 | 651 | def clear_session_cache(self, current=False): 652 | """Remove session data from the cache. 653 | 654 | .. versionadded:: 1.25 655 | .. versionchanged:: 1.27 656 | 657 | By default, data belonging to the current session won't be 658 | deleted. Set ``current=True`` to also clear current session. 659 | 660 | Args: 661 | current (bool, optional): If ``True``, also remove data for 662 | current session. 663 | 664 | """ 665 | def _is_session_file(filename): 666 | if current: 667 | return filename.startswith('_wfsess-') 668 | return filename.startswith('_wfsess-') \ 669 | and not filename.startswith(self._session_prefix) 670 | 671 | self.clear_cache(_is_session_file) 672 | 673 | @property 674 | def obj(self): 675 | """Feedback formatted for JSON serialization. 676 | 677 | Returns: 678 | dict: Data suitable for Alfred 3 feedback. 679 | 680 | """ 681 | items = [] 682 | for item in self._items: 683 | items.append(item.obj) 684 | 685 | o = {'items': items} 686 | if self.variables: 687 | o['variables'] = self.variables 688 | if self.rerun: 689 | o['rerun'] = self.rerun 690 | return o 691 | 692 | def warn_empty(self, title, subtitle=u'', icon=None): 693 | """Add a warning to feedback if there are no items. 694 | 695 | .. versionadded:: 1.31 696 | 697 | Add a "warning" item to Alfred feedback if no other items 698 | have been added. This is a handy shortcut to prevent Alfred 699 | from showing its fallback searches, which is does if no 700 | items are returned. 701 | 702 | Args: 703 | title (unicode): Title of feedback item. 704 | subtitle (unicode, optional): Subtitle of feedback item. 705 | icon (str, optional): Icon for feedback item. If not 706 | specified, ``ICON_WARNING`` is used. 707 | 708 | Returns: 709 | Item3: Newly-created item. 710 | """ 711 | if len(self._items): 712 | return 713 | 714 | icon = icon or ICON_WARNING 715 | return self.add_item(title, subtitle, icon=icon) 716 | 717 | def send_feedback(self): 718 | """Print stored items to console/Alfred as JSON.""" 719 | json.dump(self.obj, sys.stdout) 720 | sys.stdout.flush() 721 | --------------------------------------------------------------------------------