├── .gitignore
├── CDto.alfredworkflow
├── CDto
├── cdto.bash
├── icon.png
└── info.plist
├── Effective IP.alfredworkflow
├── Effective IP
├── effectiveip.py
├── icon.png
├── info.plist
└── workflow
│ ├── Notify.tgz
│ ├── __init__.py
│ ├── background.py
│ ├── notify.py
│ ├── update.py
│ ├── util.py
│ ├── version
│ ├── web.py
│ ├── workflow.py
│ └── workflow3.py
├── README.md
├── UpdateAllNPM.alfredworkflow
├── UpdateAllPIP.alfredworkflow
└── 有道翻译.alfredworkflow
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 | local_settings.py
55 |
56 | # Flask stuff:
57 | instance/
58 | .webassets-cache
59 |
60 | # Scrapy stuff:
61 | .scrapy
62 |
63 | # Sphinx documentation
64 | docs/_build/
65 |
66 | # PyBuilder
67 | target/
68 |
69 | # IPython Notebook
70 | .ipynb_checkpoints
71 |
72 | # pyenv
73 | .python-version
74 |
75 | # celery beat schedule file
76 | celerybeat-schedule
77 |
78 | # dotenv
79 | .env
80 |
81 | # virtualenv
82 | venv/
83 | ENV/
84 |
85 | # Spyder project settings
86 | .spyderproject
87 |
88 | # Rope project settings
89 | .ropeproject
90 |
--------------------------------------------------------------------------------
/CDto.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/CDto.alfredworkflow
--------------------------------------------------------------------------------
/CDto/cdto.bash:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Author: Nicolas Chow
4 | # Contact: stidio@163.com
5 | # Blog: http://stidio.github.io
6 | # Created: 2016-11-21 12:21:00
7 |
8 | FILE={query}
9 |
10 | if [ -d "${FILE}" ]; then # 目录直接保存
11 | DIR=${FILE}
12 | else
13 | if [ -f "${FILE}" ]; then # 文件获取文件所在目录
14 | DIR=$(dirname "${FILE}")
15 | else # 其他情况直接退出
16 | exit 1
17 | fi
18 | fi
19 |
20 | # 通过osascript(JavaScript)打开Terminal,然后输入cd ...
21 | osascript -l JavaScript << END
22 | term = Application("Terminal");
23 | term.doScript("cd \"${DIR}\"");
24 | term.activate();
25 | END
--------------------------------------------------------------------------------
/CDto/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/CDto/icon.png
--------------------------------------------------------------------------------
/CDto/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bundleid
6 | io.github.stidio.alfred.cd
7 | connections
8 |
9 | D1F04EF9-1085-4FD0-BA92-EEC4A2FB0701
10 |
11 |
12 | destinationuid
13 | DFF0B41C-19EA-49D6-AEAD-1552A937F289
14 | modifiers
15 | 0
16 | modifiersubtext
17 |
18 | vitoclose
19 |
20 |
21 |
22 |
23 | createdby
24 | Nicolas Chow
25 | description
26 | 打开Terminal并转到任意文件夹或文件所在目录
27 | disabled
28 |
29 | name
30 | CDto
31 | objects
32 |
33 |
34 | config
35 |
36 | concurrently
37 |
38 | escaping
39 | 127
40 | script
41 | #!/bin/bash
42 |
43 | # Author: Nicolas Chow
44 | # Contact: stidio@163.com
45 | # Blog: http://stidio.github.io
46 | # Created: 2016-11-21 12:21:00
47 |
48 | FILE={query}
49 |
50 | if [ -d "${FILE}" ]; then # 目录直接保存
51 | DIR=${FILE}
52 | else
53 | if [ -f "${FILE}" ]; then # 文件获取文件所在目录
54 | DIR=$(dirname "${FILE}")
55 | else # 其他情况直接退出
56 | exit 1
57 | fi
58 | fi
59 |
60 | # 通过osascript(JavaScript)打开Terminal,然后输入cd ...
61 | osascript -l JavaScript << END
62 | term = Application("Terminal");
63 | term.doScript("cd \"${DIR}\"");
64 | term.activate();
65 | END
66 | scriptargtype
67 | 0
68 | scriptfile
69 |
70 | type
71 | 0
72 |
73 | type
74 | alfred.workflow.action.script
75 | uid
76 | DFF0B41C-19EA-49D6-AEAD-1552A937F289
77 | version
78 | 2
79 |
80 |
81 | config
82 |
83 | anchorfields
84 |
85 | argumenttype
86 | 0
87 | daterange
88 | 0
89 | fields
90 |
91 |
92 | field
93 | kMDItemDisplayName
94 | not
95 |
96 | split
97 |
98 | value
99 | {query}
100 | words
101 |
102 |
103 |
104 | field
105 | kMDItemAlternateNames
106 | not
107 |
108 | split
109 |
110 | value
111 | {query}
112 | words
113 |
114 |
115 |
116 | field
117 | kMDItemFinderComment
118 | not
119 |
120 | split
121 |
122 | value
123 | {query}
124 | words
125 |
126 |
127 |
128 | includesystem
129 |
130 | keyword
131 | cd
132 | runningsubtext
133 |
134 | scopes
135 |
136 | subtext
137 | 请继续输入以匹配
138 | title
139 | CDto
140 | types
141 |
142 | withspace
143 |
144 |
145 | type
146 | alfred.workflow.input.filefilter
147 | uid
148 | D1F04EF9-1085-4FD0-BA92-EEC4A2FB0701
149 | version
150 | 1
151 |
152 |
153 | readme
154 |
155 | uidata
156 |
157 | D1F04EF9-1085-4FD0-BA92-EEC4A2FB0701
158 |
159 | xpos
160 | 300
161 | ypos
162 | 180
163 |
164 | DFF0B41C-19EA-49D6-AEAD-1552A937F289
165 |
166 | xpos
167 | 470
168 | ypos
169 | 180
170 |
171 |
172 | webaddress
173 | https://stidio.github.io
174 |
175 |
176 |
--------------------------------------------------------------------------------
/Effective IP.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/Effective IP.alfredworkflow
--------------------------------------------------------------------------------
/Effective IP/effectiveip.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | '''
4 | Author: Nicolas Chow
5 | Contact: stidio@163.com
6 | Blog: http://stidio.github.io
7 | Created: 2016-11-21 17:22:51
8 | Modified: 2018-05-01 17:11:21
9 | '''
10 |
11 | import sys
12 | from workflow import Workflow, ICON_INFO, ICON_ERROR, web
13 | from commands import getoutput
14 | import socket
15 | from urlparse import urlparse
16 |
17 | def get_location_information(ip):
18 | '''
19 | 通过IP获取地理位置信息
20 | '''
21 |
22 | LOCATION_QUERY_URL = 'http://www.ip138.com/ips138.asp'
23 | FEATURE_BEGIN_STR = '
- '
24 | FEATURE_END_STR = '
| '
25 | FEATURE_SPLIT_STR = ''
26 |
27 | try:
28 | rt = web.get(LOCATION_QUERY_URL, dict(ip=ip, action=2))
29 | rt.raise_for_status()
30 | rts = rt.text[
31 | rt.text.find(FEATURE_BEGIN_STR) + len(FEATURE_BEGIN_STR) :
32 | rt.text.find(FEATURE_END_STR)]
33 | rtlist = rts.split(FEATURE_SPLIT_STR)
34 |
35 | # 去掉前缀和多余空格,最长的即是最优解
36 | result = ''
37 | for val in rtlist:
38 | rl = val[val.find(u':') + 1:].strip().split()
39 | # 对诸如:北京市北京市 这种字符串修整为:北京市
40 | for i, k in enumerate(rl):
41 | size = len(k)
42 | if size % 2 == 0:
43 | size /= 2
44 | lhalf = k[:size]
45 | rhalf = k[size:]
46 | if lhalf == rhalf:
47 | rl[i] = lhalf
48 |
49 | temp = ' '.join(rl)
50 | if len(temp) > len(result):
51 | result = temp
52 | except Exception:
53 | result = None
54 |
55 | return result
56 |
57 |
58 | def get_local_ip():
59 | '''
60 | 获取本机内网IP
61 | '''
62 |
63 | return getoutput('ipconfig getifaddr en0')
64 |
65 |
66 | def get_public_ip():
67 | PUBLIC_IP_QUERY_URL = 'http://2017.ip138.com/ic.asp'
68 | try:
69 | rt = web.get(PUBLIC_IP_QUERY_URL)
70 | rt.raise_for_status()
71 | ip = rt.text[rt.text.find('[') + 1 : rt.text.find(']')]
72 | except Exception:
73 | ip = None
74 |
75 | return ip
76 |
77 |
78 | def resolve_ip_from_dns(urlorhost):
79 | '''
80 | 解析IP地址,可传入IP,HOSTNAME,URL
81 | '''
82 |
83 | host = urlparse(urlorhost).hostname
84 | if not host:
85 | host = urlorhost
86 | try:
87 | ip = socket.gethostbyname(host)
88 | except socket.gaierror:
89 | ip = None
90 |
91 | return host, ip
92 |
93 |
94 | def main(wf):
95 | # 去掉参数两边的空格
96 | param = (wf.args[0] if len(wf.args) else '').strip()
97 | if param:
98 | title, ip = resolve_ip_from_dns(param)
99 | else:
100 | title = get_local_ip()
101 | ip = get_public_ip()
102 |
103 | if ip:
104 | location = get_location_information(ip)
105 | wf.add_item(title=title,
106 | subtitle=ip + ' ' + location if location else '',
107 | arg=ip,
108 | valid=True,
109 | icon=ICON_INFO)
110 | else:
111 | wf.add_item(title=title, subtitle='...', icon=ICON_ERROR)
112 |
113 | # Send the results to Alfred as XML
114 | wf.send_feedback()
115 |
116 |
117 | if __name__ == u"__main__":
118 | wf = Workflow()
119 | sys.exit(wf.run(main))
120 |
121 |
--------------------------------------------------------------------------------
/Effective IP/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/Effective IP/icon.png
--------------------------------------------------------------------------------
/Effective IP/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bundleid
6 | io.github.stidio.alfred.effectiveip
7 | category
8 | Tools
9 | connections
10 |
11 | D53B1166-1E78-4DC8-9B9A-F7EC9088C477
12 |
13 |
14 | destinationuid
15 | F3B01B7C-68B6-4CF7-8211-00135BA57791
16 | modifiers
17 | 0
18 | modifiersubtext
19 |
20 | vitoclose
21 |
22 |
23 |
24 | F3B01B7C-68B6-4CF7-8211-00135BA57791
25 |
26 |
27 | createdby
28 | Nicolas Chow
29 | description
30 | 查询本机和外网IP地址,解析任意URL和域名的IP地址,同时进行归属地和运营商查询
31 | disabled
32 |
33 | name
34 | Effective IP
35 | objects
36 |
37 |
38 | config
39 |
40 | autopaste
41 |
42 | clipboardtext
43 | {query}
44 | transient
45 |
46 |
47 | type
48 | alfred.workflow.output.clipboard
49 | uid
50 | F3B01B7C-68B6-4CF7-8211-00135BA57791
51 | version
52 | 2
53 |
54 |
55 | config
56 |
57 | alfredfiltersresults
58 |
59 | argumenttype
60 | 1
61 | escaping
62 | 102
63 | keyword
64 | ip
65 | queuedelaycustom
66 | 3
67 | queuedelayimmediatelyinitially
68 |
69 | queuedelaymode
70 | 1
71 | queuemode
72 | 2
73 | runningsubtext
74 | 正在查询...
75 | script
76 | python effectiveip.py "{query}"
77 | scriptargtype
78 | 0
79 | scriptfile
80 |
81 | subtext
82 | 留空或输入需要查询的网址或域名
83 | title
84 | Effective IP
85 | type
86 | 0
87 | withspace
88 |
89 |
90 | type
91 | alfred.workflow.input.scriptfilter
92 | uid
93 | D53B1166-1E78-4DC8-9B9A-F7EC9088C477
94 | version
95 | 2
96 |
97 |
98 | readme
99 |
100 | uidata
101 |
102 | D53B1166-1E78-4DC8-9B9A-F7EC9088C477
103 |
104 | xpos
105 | 220
106 | ypos
107 | 230
108 |
109 | F3B01B7C-68B6-4CF7-8211-00135BA57791
110 |
111 | xpos
112 | 570
113 | ypos
114 | 140
115 |
116 |
117 | webaddress
118 | https://stidio.github.io
119 |
120 |
121 |
--------------------------------------------------------------------------------
/Effective IP/workflow/Notify.tgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/Effective IP/workflow/Notify.tgz
--------------------------------------------------------------------------------
/Effective IP/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 |
--------------------------------------------------------------------------------
/Effective IP/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 |
--------------------------------------------------------------------------------
/Effective IP/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 |
--------------------------------------------------------------------------------
/Effective IP/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 | raise ValueError('no valid releases for %s', github_slug)
352 |
353 | wf().logger.info('%d releases for %s', len(releases), github_slug)
354 |
355 | # GitHub returns releases newest-first
356 | latest_release = releases[0]
357 |
358 | # (latest_version, download_url) = get_latest_release(releases)
359 | vr = Version(latest_release['version'])
360 | vl = Version(current_version)
361 | wf().logger.debug('latest=%r, installed=%r', vr, vl)
362 | if vr > vl:
363 |
364 | wf().cache_data('__workflow_update_status', {
365 | 'version': latest_release['version'],
366 | 'download_url': latest_release['download_url'],
367 | 'available': True
368 | })
369 |
370 | return True
371 |
372 | wf().cache_data('__workflow_update_status', {'available': False})
373 | return False
374 |
375 |
376 | def install_update():
377 | """If a newer release is available, download and install it.
378 |
379 | :returns: ``True`` if an update is installed, else ``False``
380 |
381 | """
382 | update_data = wf().cached_data('__workflow_update_status', max_age=0)
383 |
384 | if not update_data or not update_data.get('available'):
385 | wf().logger.info('no update available')
386 | return False
387 |
388 | local_file = download_workflow(update_data['download_url'])
389 |
390 | wf().logger.info('installing updated workflow ...')
391 | subprocess.call(['open', local_file])
392 |
393 | update_data['available'] = False
394 | wf().cache_data('__workflow_update_status', update_data)
395 | return True
396 |
397 |
398 | if __name__ == '__main__': # pragma: nocover
399 | import sys
400 |
401 | def show_help(status=0):
402 | """Print help message."""
403 | print('Usage : update.py (check|install) '
404 | '[--prereleases] ')
405 | sys.exit(status)
406 |
407 | argv = sys.argv[:]
408 | if '-h' in argv or '--help' in argv:
409 | show_help()
410 |
411 | prereleases = '--prereleases' in argv
412 |
413 | if prereleases:
414 | argv.remove('--prereleases')
415 |
416 | if len(argv) != 4:
417 | show_help(1)
418 |
419 | action, github_slug, version = argv[1:]
420 |
421 | if action == 'check':
422 | check_update(github_slug, version, prereleases)
423 | elif action == 'install':
424 | install_update()
425 | else:
426 | show_help(1)
427 |
--------------------------------------------------------------------------------
/Effective IP/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 |
36 | class AcquisitionError(Exception):
37 | """Raised if a lock cannot be acquired."""
38 |
39 |
40 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid'])
41 | """Information about an installed application.
42 |
43 | Returned by :func:`appinfo`. All attributes are Unicode.
44 |
45 | .. py:attribute:: name
46 |
47 | Name of the application, e.g. ``u'Safari'``.
48 |
49 | .. py:attribute:: path
50 |
51 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
52 |
53 | .. py:attribute:: bundleid
54 |
55 | Application's bundle ID, e.g. ``u'com.apple.Safari'``.
56 | """
57 |
58 |
59 | def unicodify(s, encoding='utf-8', norm=None):
60 | """Ensure string is Unicode.
61 |
62 | .. versionadded:: 1.31
63 |
64 | Decode encoded strings using ``encoding`` and normalise Unicode
65 | to form ``norm`` if specified.
66 |
67 | Args:
68 | s (str): String to decode. May also be Unicode.
69 | encoding (str, optional): Encoding to use on bytestrings.
70 | norm (None, optional): Normalisation form to apply to Unicode string.
71 |
72 | Returns:
73 | unicode: Decoded, optionally normalised, Unicode string.
74 |
75 | """
76 | if not isinstance(s, unicode):
77 | s = unicode(s, encoding)
78 |
79 | if norm:
80 | from unicodedata import normalize
81 | s = normalize(norm, s)
82 |
83 | return s
84 |
85 |
86 | def utf8ify(s):
87 | """Ensure string is a bytestring.
88 |
89 | .. versionadded:: 1.31
90 |
91 | Returns `str` objects unchanced, encodes `unicode` objects to
92 | UTF-8, and calls :func:`str` on anything else.
93 |
94 | Args:
95 | s (object): A Python object
96 |
97 | Returns:
98 | str: UTF-8 string or string representation of s.
99 | """
100 | if isinstance(s, str):
101 | return s
102 |
103 | if isinstance(s, unicode):
104 | return s.encode('utf-8')
105 |
106 | return str(s)
107 |
108 |
109 | def applescriptify(s):
110 | """Escape string for insertion into an AppleScript string.
111 |
112 | .. versionadded:: 1.31
113 |
114 | Replaces ``"`` with `"& quote &"`. Use this function if you want
115 |
116 | to insert a string into an AppleScript script:
117 | >>> script = 'tell application "Alfred 3" to search "{}"'
118 | >>> query = 'g "python" test'
119 | >>> script.format(applescriptify(query))
120 | 'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"'
121 |
122 | Args:
123 | s (unicode): Unicode string to escape.
124 |
125 | Returns:
126 | unicode: Escaped string
127 | """
128 | return s.replace(u'"', u'" & quote & "')
129 |
130 |
131 | def run_command(cmd, **kwargs):
132 | """Run a command and return the output.
133 |
134 | .. versionadded:: 1.31
135 |
136 | A thin wrapper around :func:`subprocess.check_output` that ensures
137 | all arguments are encoded to UTF-8 first.
138 |
139 | Args:
140 | cmd (list): Command arguments to pass to ``check_output``.
141 | **kwargs: Keyword arguments to pass to ``check_output``.
142 |
143 | Returns:
144 | str: Output returned by ``check_output``.
145 | """
146 | cmd = [utf8ify(s) for s in cmd]
147 | return subprocess.check_output(cmd, **kwargs)
148 |
149 |
150 | def run_applescript(script, *args, **kwargs):
151 | """Execute an AppleScript script and return its output.
152 |
153 | .. versionadded:: 1.31
154 |
155 | Run AppleScript either by filepath or code. If ``script`` is a valid
156 | filepath, that script will be run, otherwise ``script`` is treated
157 | as code.
158 |
159 | Args:
160 | script (str, optional): Filepath of script or code to run.
161 | *args: Optional command-line arguments to pass to the script.
162 | **kwargs: Pass ``lang`` to run a language other than AppleScript.
163 |
164 | Returns:
165 | str: Output of run command.
166 |
167 | """
168 | cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')]
169 |
170 | if os.path.exists(script):
171 | cmd += [script]
172 | else:
173 | cmd += ['-e', script]
174 |
175 | cmd.extend(args)
176 |
177 | return run_command(cmd)
178 |
179 |
180 | def run_jxa(script, *args):
181 | """Execute a JXA script and return its output.
182 |
183 | .. versionadded:: 1.31
184 |
185 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
186 |
187 | Args:
188 | script (str): Filepath of script or code to run.
189 | *args: Optional command-line arguments to pass to script.
190 |
191 | Returns:
192 | str: Output of script.
193 | """
194 | return run_applescript(script, *args, lang='JavaScript')
195 |
196 |
197 | def run_trigger(name, bundleid=None, arg=None):
198 | """Call an Alfred External Trigger.
199 |
200 | .. versionadded:: 1.31
201 |
202 | If ``bundleid`` is not specified, reads the bundle ID of the current
203 | workflow from Alfred's environment variables.
204 |
205 | Args:
206 | name (str): Name of External Trigger to call.
207 | bundleid (str, optional): Bundle ID of workflow trigger belongs to.
208 | arg (str, optional): Argument to pass to trigger.
209 | """
210 | if not bundleid:
211 | bundleid = os.getenv('alfred_workflow_bundleid')
212 |
213 | if arg:
214 | arg = 'with argument "{}"'.format(applescriptify(arg))
215 | else:
216 | arg = ''
217 |
218 | script = AS_TRIGGER.format(name=name, bundleid=bundleid,
219 | arg=arg)
220 |
221 | run_applescript(script)
222 |
223 |
224 | def appinfo(name):
225 | """Get information about an installed application.
226 |
227 | .. versionadded:: 1.31
228 |
229 | Args:
230 | name (str): Name of application to look up.
231 |
232 | Returns:
233 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
234 | """
235 | cmd = ['mdfind', '-onlyin', '/',
236 | '(kMDItemContentTypeTree == com.apple.application &&'
237 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'
238 | .format(name)]
239 |
240 | path = run_command(cmd).strip()
241 | if not path:
242 | return None
243 |
244 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path]
245 | bid = run_command(cmd).strip()
246 | if not bid: # pragma: no cover
247 | return None
248 |
249 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid))
250 |
251 |
252 | @contextmanager
253 | def atomic_writer(fpath, mode):
254 | """Atomic file writer.
255 |
256 | .. versionadded:: 1.12
257 |
258 | Context manager that ensures the file is only written if the write
259 | succeeds. The data is first written to a temporary file.
260 |
261 | :param fpath: path of file to write to.
262 | :type fpath: ``unicode``
263 | :param mode: sames as for :func:`open`
264 | :type mode: string
265 |
266 | """
267 | suffix = '.{}.tmp'.format(os.getpid())
268 | temppath = fpath + suffix
269 | with open(temppath, mode) as fp:
270 | try:
271 | yield fp
272 | os.rename(temppath, fpath)
273 | finally:
274 | try:
275 | os.remove(temppath)
276 | except (OSError, IOError):
277 | pass
278 |
279 |
280 | class LockFile(object):
281 | """Context manager to protect filepaths with lockfiles.
282 |
283 | .. versionadded:: 1.13
284 |
285 | Creates a lockfile alongside ``protected_path``. Other ``LockFile``
286 | instances will refuse to lock the same path.
287 |
288 | >>> path = '/path/to/file'
289 | >>> with LockFile(path):
290 | >>> with open(path, 'wb') as fp:
291 | >>> fp.write(data)
292 |
293 | Args:
294 | protected_path (unicode): File to protect with a lockfile
295 | timeout (float, optional): Raises an :class:`AcquisitionError`
296 | if lock cannot be acquired within this number of seconds.
297 | If ``timeout`` is 0 (the default), wait forever.
298 | delay (float, optional): How often to check (in seconds) if
299 | lock has been released.
300 |
301 | Attributes:
302 | delay (float): How often to check (in seconds) whether the lock
303 | can be acquired.
304 | lockfile (unicode): Path of the lockfile.
305 | timeout (float): How long to wait to acquire the lock.
306 |
307 | """
308 |
309 | def __init__(self, protected_path, timeout=0.0, delay=0.05):
310 | """Create new :class:`LockFile` object."""
311 | self.lockfile = protected_path + '.lock'
312 | self._lockfile = None
313 | self.timeout = timeout
314 | self.delay = delay
315 | self._lock = Event()
316 | atexit.register(self.release)
317 |
318 | @property
319 | def locked(self):
320 | """``True`` if file is locked by this instance."""
321 | return self._lock.is_set()
322 |
323 | def acquire(self, blocking=True):
324 | """Acquire the lock if possible.
325 |
326 | If the lock is in use and ``blocking`` is ``False``, return
327 | ``False``.
328 |
329 | Otherwise, check every :attr:`delay` seconds until it acquires
330 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
331 |
332 | """
333 | if self.locked and not blocking:
334 | return False
335 |
336 | start = time.time()
337 | while True:
338 |
339 | # Raise error if we've been waiting too long to acquire the lock
340 | if self.timeout and (time.time() - start) >= self.timeout:
341 | raise AcquisitionError('lock acquisition timed out')
342 |
343 | # If already locked, wait then try again
344 | if self.locked:
345 | time.sleep(self.delay)
346 | continue
347 |
348 | # Create in append mode so we don't lose any contents
349 | if self._lockfile is None:
350 | self._lockfile = open(self.lockfile, 'a')
351 |
352 | # Try to acquire the lock
353 | try:
354 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
355 | self._lock.set()
356 | break
357 | except IOError as err: # pragma: no cover
358 | if err.errno not in (errno.EACCES, errno.EAGAIN):
359 | raise
360 |
361 | # Don't try again
362 | if not blocking: # pragma: no cover
363 | return False
364 |
365 | # Wait, then try again
366 | time.sleep(self.delay)
367 |
368 | return True
369 |
370 | def release(self):
371 | """Release the lock by deleting `self.lockfile`."""
372 | if not self._lock.is_set():
373 | return False
374 |
375 | try:
376 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
377 | except IOError: # pragma: no cover
378 | pass
379 | finally:
380 | self._lock.clear()
381 | self._lockfile = None
382 | try:
383 | os.unlink(self.lockfile)
384 | except (IOError, OSError): # pragma: no cover
385 | pass
386 |
387 | return True
388 |
389 | def __enter__(self):
390 | """Acquire lock."""
391 | self.acquire()
392 | return self
393 |
394 | def __exit__(self, typ, value, traceback):
395 | """Release lock."""
396 | self.release()
397 |
398 | def __del__(self):
399 | """Clear up `self.lockfile`."""
400 | self.release() # pragma: no cover
401 |
402 |
403 | class uninterruptible(object):
404 | """Decorator that postpones SIGTERM until wrapped function returns.
405 |
406 | .. versionadded:: 1.12
407 |
408 | .. important:: This decorator is NOT thread-safe.
409 |
410 | As of version 2.7, Alfred allows Script Filters to be killed. If
411 | your workflow is killed in the middle of critical code (e.g.
412 | writing data to disk), this may corrupt your workflow's data.
413 |
414 | Use this decorator to wrap critical functions that *must* complete.
415 | If the script is killed while a wrapped function is executing,
416 | the SIGTERM will be caught and handled after your function has
417 | finished executing.
418 |
419 | Alfred-Workflow uses this internally to ensure its settings, data
420 | and cache writes complete.
421 |
422 | """
423 |
424 | def __init__(self, func, class_name=''):
425 | """Decorate `func`."""
426 | self.func = func
427 | functools.update_wrapper(self, func)
428 | self._caught_signal = None
429 |
430 | def signal_handler(self, signum, frame):
431 | """Called when process receives SIGTERM."""
432 | self._caught_signal = (signum, frame)
433 |
434 | def __call__(self, *args, **kwargs):
435 | """Trap ``SIGTERM`` and call wrapped function."""
436 | self._caught_signal = None
437 | # Register handler for SIGTERM, then call `self.func`
438 | self.old_signal_handler = signal.getsignal(signal.SIGTERM)
439 | signal.signal(signal.SIGTERM, self.signal_handler)
440 |
441 | self.func(*args, **kwargs)
442 |
443 | # Restore old signal handler
444 | signal.signal(signal.SIGTERM, self.old_signal_handler)
445 |
446 | # Handle any signal caught during execution
447 | if self._caught_signal is not None:
448 | signum, frame = self._caught_signal
449 | if callable(self.old_signal_handler):
450 | self.old_signal_handler(signum, frame)
451 | elif self.old_signal_handler == signal.SIG_DFL:
452 | sys.exit(0)
453 |
454 | def __get__(self, obj=None, klass=None):
455 | """Decorator API."""
456 | return self.__class__(self.func.__get__(obj, klass),
457 | klass.__name__)
458 |
--------------------------------------------------------------------------------
/Effective IP/workflow/version:
--------------------------------------------------------------------------------
1 | 1.32
--------------------------------------------------------------------------------
/Effective IP/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 |
--------------------------------------------------------------------------------
/Effective IP/workflow/workflow.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 | """The :class:`Workflow` object is the main interface to this library.
11 |
12 | :class:`Workflow` is targeted at Alfred 2. Use
13 | :class:`~workflow.Workflow3` if you want to use Alfred 3's new
14 | features, such as :ref:`workflow variables ` or
15 | more powerful modifiers.
16 |
17 | See :ref:`setup` in the :ref:`user-manual` for an example of how to set
18 | up your Python script to best utilise the :class:`Workflow` object.
19 |
20 | """
21 |
22 | from __future__ import print_function, unicode_literals
23 |
24 | import binascii
25 | import cPickle
26 | from copy import deepcopy
27 | import json
28 | import logging
29 | import logging.handlers
30 | import os
31 | import pickle
32 | import plistlib
33 | import re
34 | import shutil
35 | import string
36 | import subprocess
37 | import sys
38 | import time
39 | import unicodedata
40 |
41 | try:
42 | import xml.etree.cElementTree as ET
43 | except ImportError: # pragma: no cover
44 | import xml.etree.ElementTree as ET
45 |
46 | from util import (
47 | AcquisitionError, # imported to maintain API
48 | atomic_writer,
49 | LockFile,
50 | uninterruptible,
51 | )
52 |
53 | #: Sentinel for properties that haven't been set yet (that might
54 | #: correctly have the value ``None``)
55 | UNSET = object()
56 |
57 | ####################################################################
58 | # Standard system icons
59 | ####################################################################
60 |
61 | # These icons are default macOS icons. They are super-high quality, and
62 | # will be familiar to users.
63 | # This library uses `ICON_ERROR` when a workflow dies in flames, so
64 | # in my own workflows, I use `ICON_WARNING` for less fatal errors
65 | # (e.g. bad user input, no results etc.)
66 |
67 | # The system icons are all in this directory. There are many more than
68 | # are listed here
69 |
70 | ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources'
71 |
72 | ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns')
73 | ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns')
74 | ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns')
75 | ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns')
76 | ICON_COLOUR = ICON_COLOR # Queen's English, if you please
77 | ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns')
78 | # Shown when a workflow throws an error
79 | ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns')
80 | ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns')
81 | ICON_FAVOURITE = ICON_FAVORITE
82 | ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns')
83 | ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns')
84 | ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns')
85 | ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns')
86 | ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns')
87 | ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns')
88 | ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns')
89 | ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns')
90 | ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns')
91 | ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns')
92 | ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns')
93 | ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns')
94 | ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns')
95 | ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns')
96 |
97 | ####################################################################
98 | # non-ASCII to ASCII diacritic folding.
99 | # Used by `fold_to_ascii` method
100 | ####################################################################
101 |
102 | ASCII_REPLACEMENTS = {
103 | 'À': 'A',
104 | 'Á': 'A',
105 | 'Â': 'A',
106 | 'Ã': 'A',
107 | 'Ä': 'A',
108 | 'Å': 'A',
109 | 'Æ': 'AE',
110 | 'Ç': 'C',
111 | 'È': 'E',
112 | 'É': 'E',
113 | 'Ê': 'E',
114 | 'Ë': 'E',
115 | 'Ì': 'I',
116 | 'Í': 'I',
117 | 'Î': 'I',
118 | 'Ï': 'I',
119 | 'Ð': 'D',
120 | 'Ñ': 'N',
121 | 'Ò': 'O',
122 | 'Ó': 'O',
123 | 'Ô': 'O',
124 | 'Õ': 'O',
125 | 'Ö': 'O',
126 | 'Ø': 'O',
127 | 'Ù': 'U',
128 | 'Ú': 'U',
129 | 'Û': 'U',
130 | 'Ü': 'U',
131 | 'Ý': 'Y',
132 | 'Þ': 'Th',
133 | 'ß': 'ss',
134 | 'à': 'a',
135 | 'á': 'a',
136 | 'â': 'a',
137 | 'ã': 'a',
138 | 'ä': 'a',
139 | 'å': 'a',
140 | 'æ': 'ae',
141 | 'ç': 'c',
142 | 'è': 'e',
143 | 'é': 'e',
144 | 'ê': 'e',
145 | 'ë': 'e',
146 | 'ì': 'i',
147 | 'í': 'i',
148 | 'î': 'i',
149 | 'ï': 'i',
150 | 'ð': 'd',
151 | 'ñ': 'n',
152 | 'ò': 'o',
153 | 'ó': 'o',
154 | 'ô': 'o',
155 | 'õ': 'o',
156 | 'ö': 'o',
157 | 'ø': 'o',
158 | 'ù': 'u',
159 | 'ú': 'u',
160 | 'û': 'u',
161 | 'ü': 'u',
162 | 'ý': 'y',
163 | 'þ': 'th',
164 | 'ÿ': 'y',
165 | 'Ł': 'L',
166 | 'ł': 'l',
167 | 'Ń': 'N',
168 | 'ń': 'n',
169 | 'Ņ': 'N',
170 | 'ņ': 'n',
171 | 'Ň': 'N',
172 | 'ň': 'n',
173 | 'Ŋ': 'ng',
174 | 'ŋ': 'NG',
175 | 'Ō': 'O',
176 | 'ō': 'o',
177 | 'Ŏ': 'O',
178 | 'ŏ': 'o',
179 | 'Ő': 'O',
180 | 'ő': 'o',
181 | 'Œ': 'OE',
182 | 'œ': 'oe',
183 | 'Ŕ': 'R',
184 | 'ŕ': 'r',
185 | 'Ŗ': 'R',
186 | 'ŗ': 'r',
187 | 'Ř': 'R',
188 | 'ř': 'r',
189 | 'Ś': 'S',
190 | 'ś': 's',
191 | 'Ŝ': 'S',
192 | 'ŝ': 's',
193 | 'Ş': 'S',
194 | 'ş': 's',
195 | 'Š': 'S',
196 | 'š': 's',
197 | 'Ţ': 'T',
198 | 'ţ': 't',
199 | 'Ť': 'T',
200 | 'ť': 't',
201 | 'Ŧ': 'T',
202 | 'ŧ': 't',
203 | 'Ũ': 'U',
204 | 'ũ': 'u',
205 | 'Ū': 'U',
206 | 'ū': 'u',
207 | 'Ŭ': 'U',
208 | 'ŭ': 'u',
209 | 'Ů': 'U',
210 | 'ů': 'u',
211 | 'Ű': 'U',
212 | 'ű': 'u',
213 | 'Ŵ': 'W',
214 | 'ŵ': 'w',
215 | 'Ŷ': 'Y',
216 | 'ŷ': 'y',
217 | 'Ÿ': 'Y',
218 | 'Ź': 'Z',
219 | 'ź': 'z',
220 | 'Ż': 'Z',
221 | 'ż': 'z',
222 | 'Ž': 'Z',
223 | 'ž': 'z',
224 | 'ſ': 's',
225 | 'Α': 'A',
226 | 'Β': 'B',
227 | 'Γ': 'G',
228 | 'Δ': 'D',
229 | 'Ε': 'E',
230 | 'Ζ': 'Z',
231 | 'Η': 'E',
232 | 'Θ': 'Th',
233 | 'Ι': 'I',
234 | 'Κ': 'K',
235 | 'Λ': 'L',
236 | 'Μ': 'M',
237 | 'Ν': 'N',
238 | 'Ξ': 'Ks',
239 | 'Ο': 'O',
240 | 'Π': 'P',
241 | 'Ρ': 'R',
242 | 'Σ': 'S',
243 | 'Τ': 'T',
244 | 'Υ': 'U',
245 | 'Φ': 'Ph',
246 | 'Χ': 'Kh',
247 | 'Ψ': 'Ps',
248 | 'Ω': 'O',
249 | 'α': 'a',
250 | 'β': 'b',
251 | 'γ': 'g',
252 | 'δ': 'd',
253 | 'ε': 'e',
254 | 'ζ': 'z',
255 | 'η': 'e',
256 | 'θ': 'th',
257 | 'ι': 'i',
258 | 'κ': 'k',
259 | 'λ': 'l',
260 | 'μ': 'm',
261 | 'ν': 'n',
262 | 'ξ': 'x',
263 | 'ο': 'o',
264 | 'π': 'p',
265 | 'ρ': 'r',
266 | 'ς': 's',
267 | 'σ': 's',
268 | 'τ': 't',
269 | 'υ': 'u',
270 | 'φ': 'ph',
271 | 'χ': 'kh',
272 | 'ψ': 'ps',
273 | 'ω': 'o',
274 | 'А': 'A',
275 | 'Б': 'B',
276 | 'В': 'V',
277 | 'Г': 'G',
278 | 'Д': 'D',
279 | 'Е': 'E',
280 | 'Ж': 'Zh',
281 | 'З': 'Z',
282 | 'И': 'I',
283 | 'Й': 'I',
284 | 'К': 'K',
285 | 'Л': 'L',
286 | 'М': 'M',
287 | 'Н': 'N',
288 | 'О': 'O',
289 | 'П': 'P',
290 | 'Р': 'R',
291 | 'С': 'S',
292 | 'Т': 'T',
293 | 'У': 'U',
294 | 'Ф': 'F',
295 | 'Х': 'Kh',
296 | 'Ц': 'Ts',
297 | 'Ч': 'Ch',
298 | 'Ш': 'Sh',
299 | 'Щ': 'Shch',
300 | 'Ъ': "'",
301 | 'Ы': 'Y',
302 | 'Ь': "'",
303 | 'Э': 'E',
304 | 'Ю': 'Iu',
305 | 'Я': 'Ia',
306 | 'а': 'a',
307 | 'б': 'b',
308 | 'в': 'v',
309 | 'г': 'g',
310 | 'д': 'd',
311 | 'е': 'e',
312 | 'ж': 'zh',
313 | 'з': 'z',
314 | 'и': 'i',
315 | 'й': 'i',
316 | 'к': 'k',
317 | 'л': 'l',
318 | 'м': 'm',
319 | 'н': 'n',
320 | 'о': 'o',
321 | 'п': 'p',
322 | 'р': 'r',
323 | 'с': 's',
324 | 'т': 't',
325 | 'у': 'u',
326 | 'ф': 'f',
327 | 'х': 'kh',
328 | 'ц': 'ts',
329 | 'ч': 'ch',
330 | 'ш': 'sh',
331 | 'щ': 'shch',
332 | 'ъ': "'",
333 | 'ы': 'y',
334 | 'ь': "'",
335 | 'э': 'e',
336 | 'ю': 'iu',
337 | 'я': 'ia',
338 | # 'ᴀ': '',
339 | # 'ᴁ': '',
340 | # 'ᴂ': '',
341 | # 'ᴃ': '',
342 | # 'ᴄ': '',
343 | # 'ᴅ': '',
344 | # 'ᴆ': '',
345 | # 'ᴇ': '',
346 | # 'ᴈ': '',
347 | # 'ᴉ': '',
348 | # 'ᴊ': '',
349 | # 'ᴋ': '',
350 | # 'ᴌ': '',
351 | # 'ᴍ': '',
352 | # 'ᴎ': '',
353 | # 'ᴏ': '',
354 | # 'ᴐ': '',
355 | # 'ᴑ': '',
356 | # 'ᴒ': '',
357 | # 'ᴓ': '',
358 | # 'ᴔ': '',
359 | # 'ᴕ': '',
360 | # 'ᴖ': '',
361 | # 'ᴗ': '',
362 | # 'ᴘ': '',
363 | # 'ᴙ': '',
364 | # 'ᴚ': '',
365 | # 'ᴛ': '',
366 | # 'ᴜ': '',
367 | # 'ᴝ': '',
368 | # 'ᴞ': '',
369 | # 'ᴟ': '',
370 | # 'ᴠ': '',
371 | # 'ᴡ': '',
372 | # 'ᴢ': '',
373 | # 'ᴣ': '',
374 | # 'ᴤ': '',
375 | # 'ᴥ': '',
376 | 'ᴦ': 'G',
377 | 'ᴧ': 'L',
378 | 'ᴨ': 'P',
379 | 'ᴩ': 'R',
380 | 'ᴪ': 'PS',
381 | 'ẞ': 'Ss',
382 | 'Ỳ': 'Y',
383 | 'ỳ': 'y',
384 | 'Ỵ': 'Y',
385 | 'ỵ': 'y',
386 | 'Ỹ': 'Y',
387 | 'ỹ': 'y',
388 | }
389 |
390 | ####################################################################
391 | # Smart-to-dumb punctuation mapping
392 | ####################################################################
393 |
394 | DUMB_PUNCTUATION = {
395 | '‘': "'",
396 | '’': "'",
397 | '‚': "'",
398 | '“': '"',
399 | '”': '"',
400 | '„': '"',
401 | '–': '-',
402 | '—': '-'
403 | }
404 |
405 |
406 | ####################################################################
407 | # Used by `Workflow.filter`
408 | ####################################################################
409 |
410 | # Anchor characters in a name
411 | #: Characters that indicate the beginning of a "word" in CamelCase
412 | INITIALS = string.ascii_uppercase + string.digits
413 |
414 | #: Split on non-letters, numbers
415 | split_on_delimiters = re.compile('[^a-zA-Z0-9]').split
416 |
417 | # Match filter flags
418 | #: Match items that start with ``query``
419 | MATCH_STARTSWITH = 1
420 | #: Match items whose capital letters start with ``query``
421 | MATCH_CAPITALS = 2
422 | #: Match items with a component "word" that matches ``query``
423 | MATCH_ATOM = 4
424 | #: Match items whose initials (based on atoms) start with ``query``
425 | MATCH_INITIALS_STARTSWITH = 8
426 | #: Match items whose initials (based on atoms) contain ``query``
427 | MATCH_INITIALS_CONTAIN = 16
428 | #: Combination of :const:`MATCH_INITIALS_STARTSWITH` and
429 | #: :const:`MATCH_INITIALS_CONTAIN`
430 | MATCH_INITIALS = 24
431 | #: Match items if ``query`` is a substring
432 | MATCH_SUBSTRING = 32
433 | #: Match items if all characters in ``query`` appear in the item in order
434 | MATCH_ALLCHARS = 64
435 | #: Combination of all other ``MATCH_*`` constants
436 | MATCH_ALL = 127
437 |
438 |
439 | ####################################################################
440 | # Used by `Workflow.check_update`
441 | ####################################################################
442 |
443 | # Number of days to wait between checking for updates to the workflow
444 | DEFAULT_UPDATE_FREQUENCY = 1
445 |
446 |
447 | ####################################################################
448 | # Keychain access errors
449 | ####################################################################
450 |
451 |
452 | class KeychainError(Exception):
453 | """Raised for unknown Keychain errors.
454 |
455 | Raised by methods :meth:`Workflow.save_password`,
456 | :meth:`Workflow.get_password` and :meth:`Workflow.delete_password`
457 | when ``security`` CLI app returns an unknown error code.
458 |
459 | """
460 |
461 |
462 | class PasswordNotFound(KeychainError):
463 | """Password not in Keychain.
464 |
465 | Raised by method :meth:`Workflow.get_password` when ``account``
466 | is unknown to the Keychain.
467 |
468 | """
469 |
470 |
471 | class PasswordExists(KeychainError):
472 | """Raised when trying to overwrite an existing account password.
473 |
474 | You should never receive this error: it is used internally
475 | by the :meth:`Workflow.save_password` method to know if it needs
476 | to delete the old password first (a Keychain implementation detail).
477 |
478 | """
479 |
480 |
481 | ####################################################################
482 | # Helper functions
483 | ####################################################################
484 |
485 | def isascii(text):
486 | """Test if ``text`` contains only ASCII characters.
487 |
488 | :param text: text to test for ASCII-ness
489 | :type text: ``unicode``
490 | :returns: ``True`` if ``text`` contains only ASCII characters
491 | :rtype: ``Boolean``
492 |
493 | """
494 | try:
495 | text.encode('ascii')
496 | except UnicodeEncodeError:
497 | return False
498 | return True
499 |
500 |
501 | ####################################################################
502 | # Implementation classes
503 | ####################################################################
504 |
505 | class SerializerManager(object):
506 | """Contains registered serializers.
507 |
508 | .. versionadded:: 1.8
509 |
510 | A configured instance of this class is available at
511 | :attr:`workflow.manager`.
512 |
513 | Use :meth:`register()` to register new (or replace
514 | existing) serializers, which you can specify by name when calling
515 | :class:`~workflow.Workflow` data storage methods.
516 |
517 | See :ref:`guide-serialization` and :ref:`guide-persistent-data`
518 | for further information.
519 |
520 | """
521 |
522 | def __init__(self):
523 | """Create new SerializerManager object."""
524 | self._serializers = {}
525 |
526 | def register(self, name, serializer):
527 | """Register ``serializer`` object under ``name``.
528 |
529 | Raises :class:`AttributeError` if ``serializer`` in invalid.
530 |
531 | .. note::
532 |
533 | ``name`` will be used as the file extension of the saved files.
534 |
535 | :param name: Name to register ``serializer`` under
536 | :type name: ``unicode`` or ``str``
537 | :param serializer: object with ``load()`` and ``dump()``
538 | methods
539 |
540 | """
541 | # Basic validation
542 | getattr(serializer, 'load')
543 | getattr(serializer, 'dump')
544 |
545 | self._serializers[name] = serializer
546 |
547 | def serializer(self, name):
548 | """Return serializer object for ``name``.
549 |
550 | :param name: Name of serializer to return
551 | :type name: ``unicode`` or ``str``
552 | :returns: serializer object or ``None`` if no such serializer
553 | is registered.
554 |
555 | """
556 | return self._serializers.get(name)
557 |
558 | def unregister(self, name):
559 | """Remove registered serializer with ``name``.
560 |
561 | Raises a :class:`ValueError` if there is no such registered
562 | serializer.
563 |
564 | :param name: Name of serializer to remove
565 | :type name: ``unicode`` or ``str``
566 | :returns: serializer object
567 |
568 | """
569 | if name not in self._serializers:
570 | raise ValueError('No such serializer registered : {0}'.format(
571 | name))
572 |
573 | serializer = self._serializers[name]
574 | del self._serializers[name]
575 |
576 | return serializer
577 |
578 | @property
579 | def serializers(self):
580 | """Return names of registered serializers."""
581 | return sorted(self._serializers.keys())
582 |
583 |
584 | class JSONSerializer(object):
585 | """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``.
586 |
587 | .. versionadded:: 1.8
588 |
589 | Use this serializer if you need readable data files. JSON doesn't
590 | support Python objects as well as ``cPickle``/``pickle``, so be
591 | careful which data you try to serialize as JSON.
592 |
593 | """
594 |
595 | @classmethod
596 | def load(cls, file_obj):
597 | """Load serialized object from open JSON file.
598 |
599 | .. versionadded:: 1.8
600 |
601 | :param file_obj: file handle
602 | :type file_obj: ``file`` object
603 | :returns: object loaded from JSON file
604 | :rtype: object
605 |
606 | """
607 | return json.load(file_obj)
608 |
609 | @classmethod
610 | def dump(cls, obj, file_obj):
611 | """Serialize object ``obj`` to open JSON file.
612 |
613 | .. versionadded:: 1.8
614 |
615 | :param obj: Python object to serialize
616 | :type obj: JSON-serializable data structure
617 | :param file_obj: file handle
618 | :type file_obj: ``file`` object
619 |
620 | """
621 | return json.dump(obj, file_obj, indent=2, encoding='utf-8')
622 |
623 |
624 | class CPickleSerializer(object):
625 | """Wrapper around :mod:`cPickle`. Sets ``protocol``.
626 |
627 | .. versionadded:: 1.8
628 |
629 | This is the default serializer and the best combination of speed and
630 | flexibility.
631 |
632 | """
633 |
634 | @classmethod
635 | def load(cls, file_obj):
636 | """Load serialized object from open pickle file.
637 |
638 | .. versionadded:: 1.8
639 |
640 | :param file_obj: file handle
641 | :type file_obj: ``file`` object
642 | :returns: object loaded from pickle file
643 | :rtype: object
644 |
645 | """
646 | return cPickle.load(file_obj)
647 |
648 | @classmethod
649 | def dump(cls, obj, file_obj):
650 | """Serialize object ``obj`` to open pickle file.
651 |
652 | .. versionadded:: 1.8
653 |
654 | :param obj: Python object to serialize
655 | :type obj: Python object
656 | :param file_obj: file handle
657 | :type file_obj: ``file`` object
658 |
659 | """
660 | return cPickle.dump(obj, file_obj, protocol=-1)
661 |
662 |
663 | class PickleSerializer(object):
664 | """Wrapper around :mod:`pickle`. Sets ``protocol``.
665 |
666 | .. versionadded:: 1.8
667 |
668 | Use this serializer if you need to add custom pickling.
669 |
670 | """
671 |
672 | @classmethod
673 | def load(cls, file_obj):
674 | """Load serialized object from open pickle file.
675 |
676 | .. versionadded:: 1.8
677 |
678 | :param file_obj: file handle
679 | :type file_obj: ``file`` object
680 | :returns: object loaded from pickle file
681 | :rtype: object
682 |
683 | """
684 | return pickle.load(file_obj)
685 |
686 | @classmethod
687 | def dump(cls, obj, file_obj):
688 | """Serialize object ``obj`` to open pickle file.
689 |
690 | .. versionadded:: 1.8
691 |
692 | :param obj: Python object to serialize
693 | :type obj: Python object
694 | :param file_obj: file handle
695 | :type file_obj: ``file`` object
696 |
697 | """
698 | return pickle.dump(obj, file_obj, protocol=-1)
699 |
700 |
701 | # Set up default manager and register built-in serializers
702 | manager = SerializerManager()
703 | manager.register('cpickle', CPickleSerializer)
704 | manager.register('pickle', PickleSerializer)
705 | manager.register('json', JSONSerializer)
706 |
707 |
708 | class Item(object):
709 | """Represents a feedback item for Alfred.
710 |
711 | Generates Alfred-compliant XML for a single item.
712 |
713 | You probably shouldn't use this class directly, but via
714 | :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item`
715 | for details of arguments.
716 |
717 | """
718 |
719 | def __init__(self, title, subtitle='', modifier_subtitles=None,
720 | arg=None, autocomplete=None, valid=False, uid=None,
721 | icon=None, icontype=None, type=None, largetext=None,
722 | copytext=None, quicklookurl=None):
723 | """Same arguments as :meth:`Workflow.add_item`."""
724 | self.title = title
725 | self.subtitle = subtitle
726 | self.modifier_subtitles = modifier_subtitles or {}
727 | self.arg = arg
728 | self.autocomplete = autocomplete
729 | self.valid = valid
730 | self.uid = uid
731 | self.icon = icon
732 | self.icontype = icontype
733 | self.type = type
734 | self.largetext = largetext
735 | self.copytext = copytext
736 | self.quicklookurl = quicklookurl
737 |
738 | @property
739 | def elem(self):
740 | """Create and return feedback item for Alfred.
741 |
742 | :returns: :class:`ElementTree.Element `
743 | instance for this :class:`Item` instance.
744 |
745 | """
746 | # Attributes on - element
747 | attr = {}
748 | if self.valid:
749 | attr['valid'] = 'yes'
750 | else:
751 | attr['valid'] = 'no'
752 | # Allow empty string for autocomplete. This is a useful value,
753 | # as TABing the result will revert the query back to just the
754 | # keyword
755 | if self.autocomplete is not None:
756 | attr['autocomplete'] = self.autocomplete
757 |
758 | # Optional attributes
759 | for name in ('uid', 'type'):
760 | value = getattr(self, name, None)
761 | if value:
762 | attr[name] = value
763 |
764 | root = ET.Element('item', attr)
765 | ET.SubElement(root, 'title').text = self.title
766 | ET.SubElement(root, 'subtitle').text = self.subtitle
767 |
768 | # Add modifier subtitles
769 | for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'):
770 | if mod in self.modifier_subtitles:
771 | ET.SubElement(root, 'subtitle',
772 | {'mod': mod}).text = self.modifier_subtitles[mod]
773 |
774 | # Add arg as element instead of attribute on
- , as it's more
775 | # flexible (newlines aren't allowed in attributes)
776 | if self.arg:
777 | ET.SubElement(root, 'arg').text = self.arg
778 |
779 | # Add icon if there is one
780 | if self.icon:
781 | if self.icontype:
782 | attr = dict(type=self.icontype)
783 | else:
784 | attr = {}
785 | ET.SubElement(root, 'icon', attr).text = self.icon
786 |
787 | if self.largetext:
788 | ET.SubElement(root, 'text',
789 | {'type': 'largetype'}).text = self.largetext
790 |
791 | if self.copytext:
792 | ET.SubElement(root, 'text',
793 | {'type': 'copy'}).text = self.copytext
794 |
795 | if self.quicklookurl:
796 | ET.SubElement(root, 'quicklookurl').text = self.quicklookurl
797 |
798 | return root
799 |
800 |
801 | class Settings(dict):
802 | """A dictionary that saves itself when changed.
803 |
804 | Dictionary keys & values will be saved as a JSON file
805 | at ``filepath``. If the file does not exist, the dictionary
806 | (and settings file) will be initialised with ``defaults``.
807 |
808 | :param filepath: where to save the settings
809 | :type filepath: :class:`unicode`
810 | :param defaults: dict of default settings
811 | :type defaults: :class:`dict`
812 |
813 |
814 | An appropriate instance is provided by :class:`Workflow` instances at
815 | :attr:`Workflow.settings`.
816 |
817 | """
818 |
819 | def __init__(self, filepath, defaults=None):
820 | """Create new :class:`Settings` object."""
821 | super(Settings, self).__init__()
822 | self._filepath = filepath
823 | self._nosave = False
824 | self._original = {}
825 | if os.path.exists(self._filepath):
826 | self._load()
827 | elif defaults:
828 | for key, val in defaults.items():
829 | self[key] = val
830 | self.save() # save default settings
831 |
832 | def _load(self):
833 | """Load cached settings from JSON file `self._filepath`."""
834 | data = {}
835 | with LockFile(self._filepath, 0.5):
836 | with open(self._filepath, 'rb') as fp:
837 | data.update(json.load(fp))
838 |
839 | self._original = deepcopy(data)
840 |
841 | self._nosave = True
842 | self.update(data)
843 | self._nosave = False
844 |
845 | @uninterruptible
846 | def save(self):
847 | """Save settings to JSON file specified in ``self._filepath``.
848 |
849 | If you're using this class via :attr:`Workflow.settings`, which
850 | you probably are, ``self._filepath`` will be ``settings.json``
851 | in your workflow's data directory (see :attr:`~Workflow.datadir`).
852 | """
853 | if self._nosave:
854 | return
855 |
856 | data = {}
857 | data.update(self)
858 |
859 | with LockFile(self._filepath, 0.5):
860 | with atomic_writer(self._filepath, 'wb') as fp:
861 | json.dump(data, fp, sort_keys=True, indent=2,
862 | encoding='utf-8')
863 |
864 | # dict methods
865 | def __setitem__(self, key, value):
866 | """Implement :class:`dict` interface."""
867 | if self._original.get(key) != value:
868 | super(Settings, self).__setitem__(key, value)
869 | self.save()
870 |
871 | def __delitem__(self, key):
872 | """Implement :class:`dict` interface."""
873 | super(Settings, self).__delitem__(key)
874 | self.save()
875 |
876 | def update(self, *args, **kwargs):
877 | """Override :class:`dict` method to save on update."""
878 | super(Settings, self).update(*args, **kwargs)
879 | self.save()
880 |
881 | def setdefault(self, key, value=None):
882 | """Override :class:`dict` method to save on update."""
883 | ret = super(Settings, self).setdefault(key, value)
884 | self.save()
885 | return ret
886 |
887 |
888 | class Workflow(object):
889 | """The ``Workflow`` object is the main interface to Alfred-Workflow.
890 |
891 | It provides APIs for accessing the Alfred/workflow environment,
892 | storing & caching data, using Keychain, and generating Script
893 | Filter feedback.
894 |
895 | ``Workflow`` is compatible with both Alfred 2 and 3. The
896 | :class:`~workflow.Workflow3` subclass provides additional,
897 | Alfred 3-only features, such as workflow variables.
898 |
899 | :param default_settings: default workflow settings. If no settings file
900 | exists, :class:`Workflow.settings` will be pre-populated with
901 | ``default_settings``.
902 | :type default_settings: :class:`dict`
903 | :param update_settings: settings for updating your workflow from
904 | GitHub releases. The only required key is ``github_slug``,
905 | whose value must take the form of ``username/repo``.
906 | If specified, ``Workflow`` will check the repo's releases
907 | for updates. Your workflow must also have a semantic version
908 | number. Please see the :ref:`User Manual ` and
909 | `update API docs ` for more information.
910 | :type update_settings: :class:`dict`
911 | :param input_encoding: encoding of command line arguments. You
912 | should probably leave this as the default (``utf-8``), which
913 | is the encoding Alfred uses.
914 | :type input_encoding: :class:`unicode`
915 | :param normalization: normalisation to apply to CLI args.
916 | See :meth:`Workflow.decode` for more details.
917 | :type normalization: :class:`unicode`
918 | :param capture_args: Capture and act on ``workflow:*`` arguments. See
919 | :ref:`Magic arguments ` for details.
920 | :type capture_args: :class:`Boolean`
921 | :param libraries: sequence of paths to directories containing
922 | libraries. These paths will be prepended to ``sys.path``.
923 | :type libraries: :class:`tuple` or :class:`list`
924 | :param help_url: URL to webpage where a user can ask for help with
925 | the workflow, report bugs, etc. This could be the GitHub repo
926 | or a page on AlfredForum.com. If your workflow throws an error,
927 | this URL will be displayed in the log and Alfred's debugger. It can
928 | also be opened directly in a web browser with the ``workflow:help``
929 | :ref:`magic argument `.
930 | :type help_url: :class:`unicode` or :class:`str`
931 |
932 | """
933 |
934 | # Which class to use to generate feedback items. You probably
935 | # won't want to change this
936 | item_class = Item
937 |
938 | def __init__(self, default_settings=None, update_settings=None,
939 | input_encoding='utf-8', normalization='NFC',
940 | capture_args=True, libraries=None,
941 | help_url=None):
942 | """Create new :class:`Workflow` object."""
943 | self._default_settings = default_settings or {}
944 | self._update_settings = update_settings or {}
945 | self._input_encoding = input_encoding
946 | self._normalizsation = normalization
947 | self._capture_args = capture_args
948 | self.help_url = help_url
949 | self._workflowdir = None
950 | self._settings_path = None
951 | self._settings = None
952 | self._bundleid = None
953 | self._debugging = None
954 | self._name = None
955 | self._cache_serializer = 'cpickle'
956 | self._data_serializer = 'cpickle'
957 | self._info = None
958 | self._info_loaded = False
959 | self._logger = None
960 | self._items = []
961 | self._alfred_env = None
962 | # Version number of the workflow
963 | self._version = UNSET
964 | # Version from last workflow run
965 | self._last_version_run = UNSET
966 | # Cache for regex patterns created for filter keys
967 | self._search_pattern_cache = {}
968 | # Magic arguments
969 | #: The prefix for all magic arguments. Default is ``workflow:``
970 | self.magic_prefix = 'workflow:'
971 | #: Mapping of available magic arguments. The built-in magic
972 | #: arguments are registered by default. To add your own magic arguments
973 | #: (or override built-ins), add a key:value pair where the key is
974 | #: what the user should enter (prefixed with :attr:`magic_prefix`)
975 | #: and the value is a callable that will be called when the argument
976 | #: is entered. If you would like to display a message in Alfred, the
977 | #: function should return a ``unicode`` string.
978 | #:
979 | #: By default, the magic arguments documented
980 | #: :ref:`here ` are registered.
981 | self.magic_arguments = {}
982 |
983 | self._register_default_magic()
984 |
985 | if libraries:
986 | sys.path = libraries + sys.path
987 |
988 | ####################################################################
989 | # API methods
990 | ####################################################################
991 |
992 | # info.plist contents and alfred_* environment variables ----------
993 |
994 | @property
995 | def alfred_version(self):
996 | """Alfred version as :class:`~workflow.update.Version` object."""
997 | from update import Version
998 | return Version(self.alfred_env.get('version'))
999 |
1000 | @property
1001 | def alfred_env(self):
1002 | """Dict of Alfred's environmental variables minus ``alfred_`` prefix.
1003 |
1004 | .. versionadded:: 1.7
1005 |
1006 | The variables Alfred 2.4+ exports are:
1007 |
1008 | ============================ =========================================
1009 | Variable Description
1010 | ============================ =========================================
1011 | debug Set to ``1`` if Alfred's debugger is
1012 | open, otherwise unset.
1013 | preferences Path to Alfred.alfredpreferences
1014 | (where your workflows and settings are
1015 | stored).
1016 | preferences_localhash Machine-specific preferences are stored
1017 | in ``Alfred.alfredpreferences/preferences/local/``
1018 | (see ``preferences`` above for
1019 | the path to ``Alfred.alfredpreferences``)
1020 | theme ID of selected theme
1021 | theme_background Background colour of selected theme in
1022 | format ``rgba(r,g,b,a)``
1023 | theme_subtext Show result subtext.
1024 | ``0`` = Always,
1025 | ``1`` = Alternative actions only,
1026 | ``2`` = Selected result only,
1027 | ``3`` = Never
1028 | version Alfred version number, e.g. ``'2.4'``
1029 | version_build Alfred build number, e.g. ``277``
1030 | workflow_bundleid Bundle ID, e.g.
1031 | ``net.deanishe.alfred-mailto``
1032 | workflow_cache Path to workflow's cache directory
1033 | workflow_data Path to workflow's data directory
1034 | workflow_name Name of current workflow
1035 | workflow_uid UID of workflow
1036 | workflow_version The version number specified in the
1037 | workflow configuration sheet/info.plist
1038 | ============================ =========================================
1039 |
1040 | **Note:** all values are Unicode strings except ``version_build`` and
1041 | ``theme_subtext``, which are integers.
1042 |
1043 | :returns: ``dict`` of Alfred's environmental variables without the
1044 | ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``.
1045 |
1046 | """
1047 | if self._alfred_env is not None:
1048 | return self._alfred_env
1049 |
1050 | data = {}
1051 |
1052 | for key in (
1053 | 'alfred_debug',
1054 | 'alfred_preferences',
1055 | 'alfred_preferences_localhash',
1056 | 'alfred_theme',
1057 | 'alfred_theme_background',
1058 | 'alfred_theme_subtext',
1059 | 'alfred_version',
1060 | 'alfred_version_build',
1061 | 'alfred_workflow_bundleid',
1062 | 'alfred_workflow_cache',
1063 | 'alfred_workflow_data',
1064 | 'alfred_workflow_name',
1065 | 'alfred_workflow_uid',
1066 | 'alfred_workflow_version'):
1067 |
1068 | value = os.getenv(key)
1069 |
1070 | if isinstance(value, str):
1071 | if key in ('alfred_debug', 'alfred_version_build',
1072 | 'alfred_theme_subtext'):
1073 | value = int(value)
1074 | else:
1075 | value = self.decode(value)
1076 |
1077 | data[key[7:]] = value
1078 |
1079 | self._alfred_env = data
1080 |
1081 | return self._alfred_env
1082 |
1083 | @property
1084 | def info(self):
1085 | """:class:`dict` of ``info.plist`` contents."""
1086 | if not self._info_loaded:
1087 | self._load_info_plist()
1088 | return self._info
1089 |
1090 | @property
1091 | def bundleid(self):
1092 | """Workflow bundle ID from environmental vars or ``info.plist``.
1093 |
1094 | :returns: bundle ID
1095 | :rtype: ``unicode``
1096 |
1097 | """
1098 | if not self._bundleid:
1099 | if self.alfred_env.get('workflow_bundleid'):
1100 | self._bundleid = self.alfred_env.get('workflow_bundleid')
1101 | else:
1102 | self._bundleid = unicode(self.info['bundleid'], 'utf-8')
1103 |
1104 | return self._bundleid
1105 |
1106 | @property
1107 | def debugging(self):
1108 | """Whether Alfred's debugger is open.
1109 |
1110 | :returns: ``True`` if Alfred's debugger is open.
1111 | :rtype: ``bool``
1112 |
1113 | """
1114 | if self._debugging is None:
1115 | if self.alfred_env.get('debug') == 1:
1116 | self._debugging = True
1117 | else:
1118 | self._debugging = False
1119 | return self._debugging
1120 |
1121 | @property
1122 | def name(self):
1123 | """Workflow name from Alfred's environmental vars or ``info.plist``.
1124 |
1125 | :returns: workflow name
1126 | :rtype: ``unicode``
1127 |
1128 | """
1129 | if not self._name:
1130 | if self.alfred_env.get('workflow_name'):
1131 | self._name = self.decode(self.alfred_env.get('workflow_name'))
1132 | else:
1133 | self._name = self.decode(self.info['name'])
1134 |
1135 | return self._name
1136 |
1137 | @property
1138 | def version(self):
1139 | """Return the version of the workflow.
1140 |
1141 | .. versionadded:: 1.9.10
1142 |
1143 | Get the workflow version from environment variable,
1144 | the ``update_settings`` dict passed on
1145 | instantiation, the ``version`` file located in the workflow's
1146 | root directory or ``info.plist``. Return ``None`` if none
1147 | exists or :class:`ValueError` if the version number is invalid
1148 | (i.e. not semantic).
1149 |
1150 | :returns: Version of the workflow (not Alfred-Workflow)
1151 | :rtype: :class:`~workflow.update.Version` object
1152 |
1153 | """
1154 | if self._version is UNSET:
1155 |
1156 | version = None
1157 | # environment variable has priority
1158 | if self.alfred_env.get('workflow_version'):
1159 | version = self.alfred_env['workflow_version']
1160 |
1161 | # Try `update_settings`
1162 | elif self._update_settings:
1163 | version = self._update_settings.get('version')
1164 |
1165 | # `version` file
1166 | if not version:
1167 | filepath = self.workflowfile('version')
1168 |
1169 | if os.path.exists(filepath):
1170 | with open(filepath, 'rb') as fileobj:
1171 | version = fileobj.read()
1172 |
1173 | # info.plist
1174 | if not version:
1175 | version = self.info.get('version')
1176 |
1177 | if version:
1178 | from update import Version
1179 | version = Version(version)
1180 |
1181 | self._version = version
1182 |
1183 | return self._version
1184 |
1185 | # Workflow utility methods -----------------------------------------
1186 |
1187 | @property
1188 | def args(self):
1189 | """Return command line args as normalised unicode.
1190 |
1191 | Args are decoded and normalised via :meth:`~Workflow.decode`.
1192 |
1193 | The encoding and normalisation are the ``input_encoding`` and
1194 | ``normalization`` arguments passed to :class:`Workflow` (``UTF-8``
1195 | and ``NFC`` are the defaults).
1196 |
1197 | If :class:`Workflow` is called with ``capture_args=True``
1198 | (the default), :class:`Workflow` will look for certain
1199 | ``workflow:*`` args and, if found, perform the corresponding
1200 | actions and exit the workflow.
1201 |
1202 | See :ref:`Magic arguments ` for details.
1203 |
1204 | """
1205 | msg = None
1206 | args = [self.decode(arg) for arg in sys.argv[1:]]
1207 |
1208 | # Handle magic args
1209 | if len(args) and self._capture_args:
1210 | for name in self.magic_arguments:
1211 | key = '{0}{1}'.format(self.magic_prefix, name)
1212 | if key in args:
1213 | msg = self.magic_arguments[name]()
1214 |
1215 | if msg:
1216 | self.logger.debug(msg)
1217 | if not sys.stdout.isatty(): # Show message in Alfred
1218 | self.add_item(msg, valid=False, icon=ICON_INFO)
1219 | self.send_feedback()
1220 | sys.exit(0)
1221 | return args
1222 |
1223 | @property
1224 | def cachedir(self):
1225 | """Path to workflow's cache directory.
1226 |
1227 | The cache directory is a subdirectory of Alfred's own cache directory
1228 | in ``~/Library/Caches``. The full path is:
1229 |
1230 | ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/``
1231 |
1232 | ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``.
1233 |
1234 | :returns: full path to workflow's cache directory
1235 | :rtype: ``unicode``
1236 |
1237 | """
1238 | if self.alfred_env.get('workflow_cache'):
1239 | dirpath = self.alfred_env.get('workflow_cache')
1240 |
1241 | else:
1242 | dirpath = self._default_cachedir
1243 |
1244 | return self._create(dirpath)
1245 |
1246 | @property
1247 | def _default_cachedir(self):
1248 | """Alfred 2's default cache directory."""
1249 | return os.path.join(
1250 | os.path.expanduser(
1251 | '~/Library/Caches/com.runningwithcrayons.Alfred-2/'
1252 | 'Workflow Data/'),
1253 | self.bundleid)
1254 |
1255 | @property
1256 | def datadir(self):
1257 | """Path to workflow's data directory.
1258 |
1259 | The data directory is a subdirectory of Alfred's own data directory in
1260 | ``~/Library/Application Support``. The full path is:
1261 |
1262 | ``~/Library/Application Support/Alfred 2/Workflow Data/``
1263 |
1264 | :returns: full path to workflow data directory
1265 | :rtype: ``unicode``
1266 |
1267 | """
1268 | if self.alfred_env.get('workflow_data'):
1269 | dirpath = self.alfred_env.get('workflow_data')
1270 |
1271 | else:
1272 | dirpath = self._default_datadir
1273 |
1274 | return self._create(dirpath)
1275 |
1276 | @property
1277 | def _default_datadir(self):
1278 | """Alfred 2's default data directory."""
1279 | return os.path.join(os.path.expanduser(
1280 | '~/Library/Application Support/Alfred 2/Workflow Data/'),
1281 | self.bundleid)
1282 |
1283 | @property
1284 | def workflowdir(self):
1285 | """Path to workflow's root directory (where ``info.plist`` is).
1286 |
1287 | :returns: full path to workflow root directory
1288 | :rtype: ``unicode``
1289 |
1290 | """
1291 | if not self._workflowdir:
1292 | # Try the working directory first, then the directory
1293 | # the library is in. CWD will be the workflow root if
1294 | # a workflow is being run in Alfred
1295 | candidates = [
1296 | os.path.abspath(os.getcwdu()),
1297 | os.path.dirname(os.path.abspath(os.path.dirname(__file__)))]
1298 |
1299 | # climb the directory tree until we find `info.plist`
1300 | for dirpath in candidates:
1301 |
1302 | # Ensure directory path is Unicode
1303 | dirpath = self.decode(dirpath)
1304 |
1305 | while True:
1306 | if os.path.exists(os.path.join(dirpath, 'info.plist')):
1307 | self._workflowdir = dirpath
1308 | break
1309 |
1310 | elif dirpath == '/':
1311 | # no `info.plist` found
1312 | break
1313 |
1314 | # Check the parent directory
1315 | dirpath = os.path.dirname(dirpath)
1316 |
1317 | # No need to check other candidates
1318 | if self._workflowdir:
1319 | break
1320 |
1321 | if not self._workflowdir:
1322 | raise IOError("'info.plist' not found in directory tree")
1323 |
1324 | return self._workflowdir
1325 |
1326 | def cachefile(self, filename):
1327 | """Path to ``filename`` in workflow's cache directory.
1328 |
1329 | Return absolute path to ``filename`` within your workflow's
1330 | :attr:`cache directory `.
1331 |
1332 | :param filename: basename of file
1333 | :type filename: ``unicode``
1334 | :returns: full path to file within cache directory
1335 | :rtype: ``unicode``
1336 |
1337 | """
1338 | return os.path.join(self.cachedir, filename)
1339 |
1340 | def datafile(self, filename):
1341 | """Path to ``filename`` in workflow's data directory.
1342 |
1343 | Return absolute path to ``filename`` within your workflow's
1344 | :attr:`data directory `.
1345 |
1346 | :param filename: basename of file
1347 | :type filename: ``unicode``
1348 | :returns: full path to file within data directory
1349 | :rtype: ``unicode``
1350 |
1351 | """
1352 | return os.path.join(self.datadir, filename)
1353 |
1354 | def workflowfile(self, filename):
1355 | """Return full path to ``filename`` in workflow's root directory.
1356 |
1357 | :param filename: basename of file
1358 | :type filename: ``unicode``
1359 | :returns: full path to file within data directory
1360 | :rtype: ``unicode``
1361 |
1362 | """
1363 | return os.path.join(self.workflowdir, filename)
1364 |
1365 | @property
1366 | def logfile(self):
1367 | """Path to logfile.
1368 |
1369 | :returns: path to logfile within workflow's cache directory
1370 | :rtype: ``unicode``
1371 |
1372 | """
1373 | return self.cachefile('%s.log' % self.bundleid)
1374 |
1375 | @property
1376 | def logger(self):
1377 | """Logger that logs to both console and a log file.
1378 |
1379 | If Alfred's debugger is open, log level will be ``DEBUG``,
1380 | else it will be ``INFO``.
1381 |
1382 | Use :meth:`open_log` to open the log file in Console.
1383 |
1384 | :returns: an initialised :class:`~logging.Logger`
1385 |
1386 | """
1387 | if self._logger:
1388 | return self._logger
1389 |
1390 | # Initialise new logger and optionally handlers
1391 | logger = logging.getLogger('')
1392 |
1393 | # Only add one set of handlers
1394 | # Exclude from coverage, as pytest will have configured the
1395 | # root logger already
1396 | if not len(logger.handlers): # pragma: no cover
1397 |
1398 | fmt = logging.Formatter(
1399 | '%(asctime)s %(filename)s:%(lineno)s'
1400 | ' %(levelname)-8s %(message)s',
1401 | datefmt='%H:%M:%S')
1402 |
1403 | logfile = logging.handlers.RotatingFileHandler(
1404 | self.logfile,
1405 | maxBytes=1024 * 1024,
1406 | backupCount=1)
1407 | logfile.setFormatter(fmt)
1408 | logger.addHandler(logfile)
1409 |
1410 | console = logging.StreamHandler()
1411 | console.setFormatter(fmt)
1412 | logger.addHandler(console)
1413 |
1414 | if self.debugging:
1415 | logger.setLevel(logging.DEBUG)
1416 | else:
1417 | logger.setLevel(logging.INFO)
1418 |
1419 | self._logger = logger
1420 |
1421 | return self._logger
1422 |
1423 | @logger.setter
1424 | def logger(self, logger):
1425 | """Set a custom logger.
1426 |
1427 | :param logger: The logger to use
1428 | :type logger: `~logging.Logger` instance
1429 |
1430 | """
1431 | self._logger = logger
1432 |
1433 | @property
1434 | def settings_path(self):
1435 | """Path to settings file within workflow's data directory.
1436 |
1437 | :returns: path to ``settings.json`` file
1438 | :rtype: ``unicode``
1439 |
1440 | """
1441 | if not self._settings_path:
1442 | self._settings_path = self.datafile('settings.json')
1443 | return self._settings_path
1444 |
1445 | @property
1446 | def settings(self):
1447 | """Return a dictionary subclass that saves itself when changed.
1448 |
1449 | See :ref:`guide-settings` in the :ref:`user-manual` for more
1450 | information on how to use :attr:`settings` and **important
1451 | limitations** on what it can do.
1452 |
1453 | :returns: :class:`~workflow.workflow.Settings` instance
1454 | initialised from the data in JSON file at
1455 | :attr:`settings_path` or if that doesn't exist, with the
1456 | ``default_settings`` :class:`dict` passed to
1457 | :class:`Workflow` on instantiation.
1458 | :rtype: :class:`~workflow.workflow.Settings` instance
1459 |
1460 | """
1461 | if not self._settings:
1462 | self.logger.debug('reading settings from %s', self.settings_path)
1463 | self._settings = Settings(self.settings_path,
1464 | self._default_settings)
1465 | return self._settings
1466 |
1467 | @property
1468 | def cache_serializer(self):
1469 | """Name of default cache serializer.
1470 |
1471 | .. versionadded:: 1.8
1472 |
1473 | This serializer is used by :meth:`cache_data()` and
1474 | :meth:`cached_data()`
1475 |
1476 | See :class:`SerializerManager` for details.
1477 |
1478 | :returns: serializer name
1479 | :rtype: ``unicode``
1480 |
1481 | """
1482 | return self._cache_serializer
1483 |
1484 | @cache_serializer.setter
1485 | def cache_serializer(self, serializer_name):
1486 | """Set the default cache serialization format.
1487 |
1488 | .. versionadded:: 1.8
1489 |
1490 | This serializer is used by :meth:`cache_data()` and
1491 | :meth:`cached_data()`
1492 |
1493 | The specified serializer must already by registered with the
1494 | :class:`SerializerManager` at `~workflow.workflow.manager`,
1495 | otherwise a :class:`ValueError` will be raised.
1496 |
1497 | :param serializer_name: Name of default serializer to use.
1498 | :type serializer_name:
1499 |
1500 | """
1501 | if manager.serializer(serializer_name) is None:
1502 | raise ValueError(
1503 | 'Unknown serializer : `{0}`. Register your serializer '
1504 | 'with `manager` first.'.format(serializer_name))
1505 |
1506 | self.logger.debug('default cache serializer: %s', serializer_name)
1507 |
1508 | self._cache_serializer = serializer_name
1509 |
1510 | @property
1511 | def data_serializer(self):
1512 | """Name of default data serializer.
1513 |
1514 | .. versionadded:: 1.8
1515 |
1516 | This serializer is used by :meth:`store_data()` and
1517 | :meth:`stored_data()`
1518 |
1519 | See :class:`SerializerManager` for details.
1520 |
1521 | :returns: serializer name
1522 | :rtype: ``unicode``
1523 |
1524 | """
1525 | return self._data_serializer
1526 |
1527 | @data_serializer.setter
1528 | def data_serializer(self, serializer_name):
1529 | """Set the default cache serialization format.
1530 |
1531 | .. versionadded:: 1.8
1532 |
1533 | This serializer is used by :meth:`store_data()` and
1534 | :meth:`stored_data()`
1535 |
1536 | The specified serializer must already by registered with the
1537 | :class:`SerializerManager` at `~workflow.workflow.manager`,
1538 | otherwise a :class:`ValueError` will be raised.
1539 |
1540 | :param serializer_name: Name of serializer to use by default.
1541 |
1542 | """
1543 | if manager.serializer(serializer_name) is None:
1544 | raise ValueError(
1545 | 'Unknown serializer : `{0}`. Register your serializer '
1546 | 'with `manager` first.'.format(serializer_name))
1547 |
1548 | self.logger.debug('default data serializer: %s', serializer_name)
1549 |
1550 | self._data_serializer = serializer_name
1551 |
1552 | def stored_data(self, name):
1553 | """Retrieve data from data directory.
1554 |
1555 | Returns ``None`` if there are no data stored under ``name``.
1556 |
1557 | .. versionadded:: 1.8
1558 |
1559 | :param name: name of datastore
1560 |
1561 | """
1562 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
1563 |
1564 | if not os.path.exists(metadata_path):
1565 | self.logger.debug('no data stored for `%s`', name)
1566 | return None
1567 |
1568 | with open(metadata_path, 'rb') as file_obj:
1569 | serializer_name = file_obj.read().strip()
1570 |
1571 | serializer = manager.serializer(serializer_name)
1572 |
1573 | if serializer is None:
1574 | raise ValueError(
1575 | 'Unknown serializer `{0}`. Register a corresponding '
1576 | 'serializer with `manager.register()` '
1577 | 'to load this data.'.format(serializer_name))
1578 |
1579 | self.logger.debug('data `%s` stored as `%s`', name, serializer_name)
1580 |
1581 | filename = '{0}.{1}'.format(name, serializer_name)
1582 | data_path = self.datafile(filename)
1583 |
1584 | if not os.path.exists(data_path):
1585 | self.logger.debug('no data stored: %s', name)
1586 | if os.path.exists(metadata_path):
1587 | os.unlink(metadata_path)
1588 |
1589 | return None
1590 |
1591 | with open(data_path, 'rb') as file_obj:
1592 | data = serializer.load(file_obj)
1593 |
1594 | self.logger.debug('stored data loaded: %s', data_path)
1595 |
1596 | return data
1597 |
1598 | def store_data(self, name, data, serializer=None):
1599 | """Save data to data directory.
1600 |
1601 | .. versionadded:: 1.8
1602 |
1603 | If ``data`` is ``None``, the datastore will be deleted.
1604 |
1605 | Note that the datastore does NOT support mutliple threads.
1606 |
1607 | :param name: name of datastore
1608 | :param data: object(s) to store. **Note:** some serializers
1609 | can only handled certain types of data.
1610 | :param serializer: name of serializer to use. If no serializer
1611 | is specified, the default will be used. See
1612 | :class:`SerializerManager` for more information.
1613 | :returns: data in datastore or ``None``
1614 |
1615 | """
1616 | # Ensure deletion is not interrupted by SIGTERM
1617 | @uninterruptible
1618 | def delete_paths(paths):
1619 | """Clear one or more data stores"""
1620 | for path in paths:
1621 | if os.path.exists(path):
1622 | os.unlink(path)
1623 | self.logger.debug('deleted data file: %s', path)
1624 |
1625 | serializer_name = serializer or self.data_serializer
1626 |
1627 | # In order for `stored_data()` to be able to load data stored with
1628 | # an arbitrary serializer, yet still have meaningful file extensions,
1629 | # the format (i.e. extension) is saved to an accompanying file
1630 | metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
1631 | filename = '{0}.{1}'.format(name, serializer_name)
1632 | data_path = self.datafile(filename)
1633 |
1634 | if data_path == self.settings_path:
1635 | raise ValueError(
1636 | 'Cannot save data to' +
1637 | '`{0}` with format `{1}`. '.format(name, serializer_name) +
1638 | "This would overwrite Alfred-Workflow's settings file.")
1639 |
1640 | serializer = manager.serializer(serializer_name)
1641 |
1642 | if serializer is None:
1643 | raise ValueError(
1644 | 'Invalid serializer `{0}`. Register your serializer with '
1645 | '`manager.register()` first.'.format(serializer_name))
1646 |
1647 | if data is None: # Delete cached data
1648 | delete_paths((metadata_path, data_path))
1649 | return
1650 |
1651 | # Ensure write is not interrupted by SIGTERM
1652 | @uninterruptible
1653 | def _store():
1654 | # Save file extension
1655 | with atomic_writer(metadata_path, 'wb') as file_obj:
1656 | file_obj.write(serializer_name)
1657 |
1658 | with atomic_writer(data_path, 'wb') as file_obj:
1659 | serializer.dump(data, file_obj)
1660 |
1661 | _store()
1662 |
1663 | self.logger.debug('saved data: %s', data_path)
1664 |
1665 | def cached_data(self, name, data_func=None, max_age=60):
1666 | """Return cached data if younger than ``max_age`` seconds.
1667 |
1668 | Retrieve data from cache or re-generate and re-cache data if
1669 | stale/non-existant. If ``max_age`` is 0, return cached data no
1670 | matter how old.
1671 |
1672 | :param name: name of datastore
1673 | :param data_func: function to (re-)generate data.
1674 | :type data_func: ``callable``
1675 | :param max_age: maximum age of cached data in seconds
1676 | :type max_age: ``int``
1677 | :returns: cached data, return value of ``data_func`` or ``None``
1678 | if ``data_func`` is not set
1679 |
1680 | """
1681 | serializer = manager.serializer(self.cache_serializer)
1682 |
1683 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
1684 | age = self.cached_data_age(name)
1685 |
1686 | if (age < max_age or max_age == 0) and os.path.exists(cache_path):
1687 |
1688 | with open(cache_path, 'rb') as file_obj:
1689 | self.logger.debug('loading cached data: %s', cache_path)
1690 | return serializer.load(file_obj)
1691 |
1692 | if not data_func:
1693 | return None
1694 |
1695 | data = data_func()
1696 | self.cache_data(name, data)
1697 |
1698 | return data
1699 |
1700 | def cache_data(self, name, data):
1701 | """Save ``data`` to cache under ``name``.
1702 |
1703 | If ``data`` is ``None``, the corresponding cache file will be
1704 | deleted.
1705 |
1706 | :param name: name of datastore
1707 | :param data: data to store. This may be any object supported by
1708 | the cache serializer
1709 |
1710 | """
1711 | serializer = manager.serializer(self.cache_serializer)
1712 |
1713 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
1714 |
1715 | if data is None:
1716 | if os.path.exists(cache_path):
1717 | os.unlink(cache_path)
1718 | self.logger.debug('deleted cache file: %s', cache_path)
1719 | return
1720 |
1721 | with atomic_writer(cache_path, 'wb') as file_obj:
1722 | serializer.dump(data, file_obj)
1723 |
1724 | self.logger.debug('cached data: %s', cache_path)
1725 |
1726 | def cached_data_fresh(self, name, max_age):
1727 | """Whether cache `name` is less than `max_age` seconds old.
1728 |
1729 | :param name: name of datastore
1730 | :param max_age: maximum age of data in seconds
1731 | :type max_age: ``int``
1732 | :returns: ``True`` if data is less than ``max_age`` old, else
1733 | ``False``
1734 |
1735 | """
1736 | age = self.cached_data_age(name)
1737 |
1738 | if not age:
1739 | return False
1740 |
1741 | return age < max_age
1742 |
1743 | def cached_data_age(self, name):
1744 | """Return age in seconds of cache `name` or 0 if cache doesn't exist.
1745 |
1746 | :param name: name of datastore
1747 | :type name: ``unicode``
1748 | :returns: age of datastore in seconds
1749 | :rtype: ``int``
1750 |
1751 | """
1752 | cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
1753 |
1754 | if not os.path.exists(cache_path):
1755 | return 0
1756 |
1757 | return time.time() - os.stat(cache_path).st_mtime
1758 |
1759 | def filter(self, query, items, key=lambda x: x, ascending=False,
1760 | include_score=False, min_score=0, max_results=0,
1761 | match_on=MATCH_ALL, fold_diacritics=True):
1762 | """Fuzzy search filter. Returns list of ``items`` that match ``query``.
1763 |
1764 | ``query`` is case-insensitive. Any item that does not contain the
1765 | entirety of ``query`` is rejected.
1766 |
1767 | If ``query`` is an empty string or contains only whitespace,
1768 | all items will match.
1769 |
1770 | :param query: query to test items against
1771 | :type query: ``unicode``
1772 | :param items: iterable of items to test
1773 | :type items: ``list`` or ``tuple``
1774 | :param key: function to get comparison key from ``items``.
1775 | Must return a ``unicode`` string. The default simply returns
1776 | the item.
1777 | :type key: ``callable``
1778 | :param ascending: set to ``True`` to get worst matches first
1779 | :type ascending: ``Boolean``
1780 | :param include_score: Useful for debugging the scoring algorithm.
1781 | If ``True``, results will be a list of tuples
1782 | ``(item, score, rule)``.
1783 | :type include_score: ``Boolean``
1784 | :param min_score: If non-zero, ignore results with a score lower
1785 | than this.
1786 | :type min_score: ``int``
1787 | :param max_results: If non-zero, prune results list to this length.
1788 | :type max_results: ``int``
1789 | :param match_on: Filter option flags. Bitwise-combined list of
1790 | ``MATCH_*`` constants (see below).
1791 | :type match_on: ``int``
1792 | :param fold_diacritics: Convert search keys to ASCII-only
1793 | characters if ``query`` only contains ASCII characters.
1794 | :type fold_diacritics: ``Boolean``
1795 | :returns: list of ``items`` matching ``query`` or list of
1796 | ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``.
1797 | ``rule`` is the ``MATCH_*`` rule that matched the item.
1798 | :rtype: ``list``
1799 |
1800 | **Matching rules**
1801 |
1802 | By default, :meth:`filter` uses all of the following flags (i.e.
1803 | :const:`MATCH_ALL`). The tests are always run in the given order:
1804 |
1805 | 1. :const:`MATCH_STARTSWITH`
1806 | Item search key starts with ``query`` (case-insensitive).
1807 | 2. :const:`MATCH_CAPITALS`
1808 | The list of capital letters in item search key starts with
1809 | ``query`` (``query`` may be lower-case). E.g., ``of``
1810 | would match ``OmniFocus``, ``gc`` would match ``Google Chrome``.
1811 | 3. :const:`MATCH_ATOM`
1812 | Search key is split into "atoms" on non-word characters
1813 | (.,-,' etc.). Matches if ``query`` is one of these atoms
1814 | (case-insensitive).
1815 | 4. :const:`MATCH_INITIALS_STARTSWITH`
1816 | Initials are the first characters of the above-described
1817 | "atoms" (case-insensitive).
1818 | 5. :const:`MATCH_INITIALS_CONTAIN`
1819 | ``query`` is a substring of the above-described initials.
1820 | 6. :const:`MATCH_INITIALS`
1821 | Combination of (4) and (5).
1822 | 7. :const:`MATCH_SUBSTRING`
1823 | ``query`` is a substring of item search key (case-insensitive).
1824 | 8. :const:`MATCH_ALLCHARS`
1825 | All characters in ``query`` appear in item search key in
1826 | the same order (case-insensitive).
1827 | 9. :const:`MATCH_ALL`
1828 | Combination of all the above.
1829 |
1830 |
1831 | :const:`MATCH_ALLCHARS` is considerably slower than the other
1832 | tests and provides much less accurate results.
1833 |
1834 | **Examples:**
1835 |
1836 | To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst
1837 | matches and is expensive to run), use
1838 | ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``.
1839 |
1840 | To match only on capitals, use ``match_on=MATCH_CAPITALS``.
1841 |
1842 | To match only on startswith and substring, use
1843 | ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``.
1844 |
1845 | **Diacritic folding**
1846 |
1847 | .. versionadded:: 1.3
1848 |
1849 | If ``fold_diacritics`` is ``True`` (the default), and ``query``
1850 | contains only ASCII characters, non-ASCII characters in search keys
1851 | will be converted to ASCII equivalents (e.g. **ü** -> **u**,
1852 | **ß** -> **ss**, **é** -> **e**).
1853 |
1854 | See :const:`ASCII_REPLACEMENTS` for all replacements.
1855 |
1856 | If ``query`` contains non-ASCII characters, search keys will not be
1857 | altered.
1858 |
1859 | """
1860 | if not query:
1861 | return items
1862 |
1863 | # Remove preceding/trailing spaces
1864 | query = query.strip()
1865 |
1866 | if not query:
1867 | return items
1868 |
1869 | # Use user override if there is one
1870 | fold_diacritics = self.settings.get('__workflow_diacritic_folding',
1871 | fold_diacritics)
1872 |
1873 | results = []
1874 |
1875 | for item in items:
1876 | skip = False
1877 | score = 0
1878 | words = [s.strip() for s in query.split(' ')]
1879 | value = key(item).strip()
1880 | if value == '':
1881 | continue
1882 | for word in words:
1883 | if word == '':
1884 | continue
1885 | s, rule = self._filter_item(value, word, match_on,
1886 | fold_diacritics)
1887 |
1888 | if not s: # Skip items that don't match part of the query
1889 | skip = True
1890 | score += s
1891 |
1892 | if skip:
1893 | continue
1894 |
1895 | if score:
1896 | # use "reversed" `score` (i.e. highest becomes lowest) and
1897 | # `value` as sort key. This means items with the same score
1898 | # will be sorted in alphabetical not reverse alphabetical order
1899 | results.append(((100.0 / score, value.lower(), score),
1900 | (item, score, rule)))
1901 |
1902 | # sort on keys, then discard the keys
1903 | results.sort(reverse=ascending)
1904 | results = [t[1] for t in results]
1905 |
1906 | if min_score:
1907 | results = [r for r in results if r[1] > min_score]
1908 |
1909 | if max_results and len(results) > max_results:
1910 | results = results[:max_results]
1911 |
1912 | # return list of ``(item, score, rule)``
1913 | if include_score:
1914 | return results
1915 | # just return list of items
1916 | return [t[0] for t in results]
1917 |
1918 | def _filter_item(self, value, query, match_on, fold_diacritics):
1919 | """Filter ``value`` against ``query`` using rules ``match_on``.
1920 |
1921 | :returns: ``(score, rule)``
1922 |
1923 | """
1924 | query = query.lower()
1925 |
1926 | if not isascii(query):
1927 | fold_diacritics = False
1928 |
1929 | if fold_diacritics:
1930 | value = self.fold_to_ascii(value)
1931 |
1932 | # pre-filter any items that do not contain all characters
1933 | # of ``query`` to save on running several more expensive tests
1934 | if not set(query) <= set(value.lower()):
1935 |
1936 | return (0, None)
1937 |
1938 | # item starts with query
1939 | if match_on & MATCH_STARTSWITH and value.lower().startswith(query):
1940 | score = 100.0 - (len(value) / len(query))
1941 |
1942 | return (score, MATCH_STARTSWITH)
1943 |
1944 | # query matches capitalised letters in item,
1945 | # e.g. of = OmniFocus
1946 | if match_on & MATCH_CAPITALS:
1947 | initials = ''.join([c for c in value if c in INITIALS])
1948 | if initials.lower().startswith(query):
1949 | score = 100.0 - (len(initials) / len(query))
1950 |
1951 | return (score, MATCH_CAPITALS)
1952 |
1953 | # split the item into "atoms", i.e. words separated by
1954 | # spaces or other non-word characters
1955 | if (match_on & MATCH_ATOM or
1956 | match_on & MATCH_INITIALS_CONTAIN or
1957 | match_on & MATCH_INITIALS_STARTSWITH):
1958 | atoms = [s.lower() for s in split_on_delimiters(value)]
1959 | # print('atoms : %s --> %s' % (value, atoms))
1960 | # initials of the atoms
1961 | initials = ''.join([s[0] for s in atoms if s])
1962 |
1963 | if match_on & MATCH_ATOM:
1964 | # is `query` one of the atoms in item?
1965 | # similar to substring, but scores more highly, as it's
1966 | # a word within the item
1967 | if query in atoms:
1968 | score = 100.0 - (len(value) / len(query))
1969 |
1970 | return (score, MATCH_ATOM)
1971 |
1972 | # `query` matches start (or all) of the initials of the
1973 | # atoms, e.g. ``himym`` matches "How I Met Your Mother"
1974 | # *and* "how i met your mother" (the ``capitals`` rule only
1975 | # matches the former)
1976 | if (match_on & MATCH_INITIALS_STARTSWITH and
1977 | initials.startswith(query)):
1978 | score = 100.0 - (len(initials) / len(query))
1979 |
1980 | return (score, MATCH_INITIALS_STARTSWITH)
1981 |
1982 | # `query` is a substring of initials, e.g. ``doh`` matches
1983 | # "The Dukes of Hazzard"
1984 | elif (match_on & MATCH_INITIALS_CONTAIN and
1985 | query in initials):
1986 | score = 95.0 - (len(initials) / len(query))
1987 |
1988 | return (score, MATCH_INITIALS_CONTAIN)
1989 |
1990 | # `query` is a substring of item
1991 | if match_on & MATCH_SUBSTRING and query in value.lower():
1992 | score = 90.0 - (len(value) / len(query))
1993 |
1994 | return (score, MATCH_SUBSTRING)
1995 |
1996 | # finally, assign a score based on how close together the
1997 | # characters in `query` are in item.
1998 | if match_on & MATCH_ALLCHARS:
1999 | search = self._search_for_query(query)
2000 | match = search(value)
2001 | if match:
2002 | score = 100.0 / ((1 + match.start()) *
2003 | (match.end() - match.start() + 1))
2004 |
2005 | return (score, MATCH_ALLCHARS)
2006 |
2007 | # Nothing matched
2008 | return (0, None)
2009 |
2010 | def _search_for_query(self, query):
2011 | if query in self._search_pattern_cache:
2012 | return self._search_pattern_cache[query]
2013 |
2014 | # Build pattern: include all characters
2015 | pattern = []
2016 | for c in query:
2017 | # pattern.append('[^{0}]*{0}'.format(re.escape(c)))
2018 | pattern.append('.*?{0}'.format(re.escape(c)))
2019 | pattern = ''.join(pattern)
2020 | search = re.compile(pattern, re.IGNORECASE).search
2021 |
2022 | self._search_pattern_cache[query] = search
2023 | return search
2024 |
2025 | def run(self, func, text_errors=False):
2026 | """Call ``func`` to run your workflow.
2027 |
2028 | :param func: Callable to call with ``self`` (i.e. the :class:`Workflow`
2029 | instance) as first argument.
2030 | :param text_errors: Emit error messages in plain text, not in
2031 | Alfred's XML/JSON feedback format. Use this when you're not
2032 | running Alfred-Workflow in a Script Filter and would like
2033 | to pass the error message to, say, a notification.
2034 | :type text_errors: ``Boolean``
2035 |
2036 | ``func`` will be called with :class:`Workflow` instance as first
2037 | argument.
2038 |
2039 | ``func`` should be the main entry point to your workflow.
2040 |
2041 | Any exceptions raised will be logged and an error message will be
2042 | output to Alfred.
2043 |
2044 | """
2045 | start = time.time()
2046 |
2047 | # Write to debugger to ensure "real" output starts on a new line
2048 | print('.', file=sys.stderr)
2049 |
2050 | # Call workflow's entry function/method within a try-except block
2051 | # to catch any errors and display an error message in Alfred
2052 | try:
2053 | if self.version:
2054 | self.logger.debug('---------- %s (%s) ----------',
2055 | self.name, self.version)
2056 | else:
2057 | self.logger.debug('---------- %s ----------', self.name)
2058 |
2059 | # Run update check if configured for self-updates.
2060 | # This call has to go in the `run` try-except block, as it will
2061 | # initialise `self.settings`, which will raise an exception
2062 | # if `settings.json` isn't valid.
2063 | if self._update_settings:
2064 | self.check_update()
2065 |
2066 | # Run workflow's entry function/method
2067 | func(self)
2068 |
2069 | # Set last version run to current version after a successful
2070 | # run
2071 | self.set_last_version()
2072 |
2073 | except Exception as err:
2074 | self.logger.exception(err)
2075 | if self.help_url:
2076 | self.logger.info('for assistance, see: %s', self.help_url)
2077 |
2078 | if not sys.stdout.isatty(): # Show error in Alfred
2079 | if text_errors:
2080 | print(unicode(err).encode('utf-8'), end='')
2081 | else:
2082 | self._items = []
2083 | if self._name:
2084 | name = self._name
2085 | elif self._bundleid: # pragma: no cover
2086 | name = self._bundleid
2087 | else: # pragma: no cover
2088 | name = os.path.dirname(__file__)
2089 | self.add_item("Error in workflow '%s'" % name,
2090 | unicode(err),
2091 | icon=ICON_ERROR)
2092 | self.send_feedback()
2093 | return 1
2094 |
2095 | finally:
2096 | self.logger.debug('---------- finished in %0.3fs ----------',
2097 | time.time() - start)
2098 |
2099 | return 0
2100 |
2101 | # Alfred feedback methods ------------------------------------------
2102 |
2103 | def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None,
2104 | autocomplete=None, valid=False, uid=None, icon=None,
2105 | icontype=None, type=None, largetext=None, copytext=None,
2106 | quicklookurl=None):
2107 | """Add an item to be output to Alfred.
2108 |
2109 | :param title: Title shown in Alfred
2110 | :type title: ``unicode``
2111 | :param subtitle: Subtitle shown in Alfred
2112 | :type subtitle: ``unicode``
2113 | :param modifier_subtitles: Subtitles shown when modifier
2114 | (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase
2115 | keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn``
2116 | :type modifier_subtitles: ``dict``
2117 | :param arg: Argument passed by Alfred as ``{query}`` when item is
2118 | actioned
2119 | :type arg: ``unicode``
2120 | :param autocomplete: Text expanded in Alfred when item is TABbed
2121 | :type autocomplete: ``unicode``
2122 | :param valid: Whether or not item can be actioned
2123 | :type valid: ``Boolean``
2124 | :param uid: Used by Alfred to remember/sort items
2125 | :type uid: ``unicode``
2126 | :param icon: Filename of icon to use
2127 | :type icon: ``unicode``
2128 | :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'``
2129 | or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype
2130 | such as ``'public.folder'``. Use ``'fileicon'`` when you wish to
2131 | use the icon of the file specified as ``icon``, e.g.
2132 | ``icon='/Applications/Safari.app', icontype='fileicon'``.
2133 | Leave as `None` if ``icon`` points to an actual
2134 | icon file.
2135 | :type icontype: ``unicode``
2136 | :param type: Result type. Currently only ``'file'`` is supported
2137 | (by Alfred). This will tell Alfred to enable file actions for
2138 | this item.
2139 | :type type: ``unicode``
2140 | :param largetext: Text to be displayed in Alfred's large text box
2141 | if user presses CMD+L on item.
2142 | :type largetext: ``unicode``
2143 | :param copytext: Text to be copied to pasteboard if user presses
2144 | CMD+C on item.
2145 | :type copytext: ``unicode``
2146 | :param quicklookurl: URL to be displayed using Alfred's Quick Look
2147 | feature (tapping ``SHIFT`` or ``⌘+Y`` on a result).
2148 | :type quicklookurl: ``unicode``
2149 | :returns: :class:`Item` instance
2150 |
2151 | See :ref:`icons` for a list of the supported system icons.
2152 |
2153 | .. note::
2154 |
2155 | Although this method returns an :class:`Item` instance, you don't
2156 | need to hold onto it or worry about it. All generated :class:`Item`
2157 | instances are also collected internally and sent to Alfred when
2158 | :meth:`send_feedback` is called.
2159 |
2160 | The generated :class:`Item` is only returned in case you want to
2161 | edit it or do something with it other than send it to Alfred.
2162 |
2163 | """
2164 | item = self.item_class(title, subtitle, modifier_subtitles, arg,
2165 | autocomplete, valid, uid, icon, icontype, type,
2166 | largetext, copytext, quicklookurl)
2167 | self._items.append(item)
2168 | return item
2169 |
2170 | def send_feedback(self):
2171 | """Print stored items to console/Alfred as XML."""
2172 | root = ET.Element('items')
2173 | for item in self._items:
2174 | root.append(item.elem)
2175 | sys.stdout.write('\n')
2176 | sys.stdout.write(ET.tostring(root).encode('utf-8'))
2177 | sys.stdout.flush()
2178 |
2179 | ####################################################################
2180 | # Updating methods
2181 | ####################################################################
2182 |
2183 | @property
2184 | def first_run(self):
2185 | """Return ``True`` if it's the first time this version has run.
2186 |
2187 | .. versionadded:: 1.9.10
2188 |
2189 | Raises a :class:`ValueError` if :attr:`version` isn't set.
2190 |
2191 | """
2192 | if not self.version:
2193 | raise ValueError('No workflow version set')
2194 |
2195 | if not self.last_version_run:
2196 | return True
2197 |
2198 | return self.version != self.last_version_run
2199 |
2200 | @property
2201 | def last_version_run(self):
2202 | """Return version of last version to run (or ``None``).
2203 |
2204 | .. versionadded:: 1.9.10
2205 |
2206 | :returns: :class:`~workflow.update.Version` instance
2207 | or ``None``
2208 |
2209 | """
2210 | if self._last_version_run is UNSET:
2211 |
2212 | version = self.settings.get('__workflow_last_version')
2213 | if version:
2214 | from update import Version
2215 | version = Version(version)
2216 |
2217 | self._last_version_run = version
2218 |
2219 | self.logger.debug('last run version: %s', self._last_version_run)
2220 |
2221 | return self._last_version_run
2222 |
2223 | def set_last_version(self, version=None):
2224 | """Set :attr:`last_version_run` to current version.
2225 |
2226 | .. versionadded:: 1.9.10
2227 |
2228 | :param version: version to store (default is current version)
2229 | :type version: :class:`~workflow.update.Version` instance
2230 | or ``unicode``
2231 | :returns: ``True`` if version is saved, else ``False``
2232 |
2233 | """
2234 | if not version:
2235 | if not self.version:
2236 | self.logger.warning(
2237 | "Can't save last version: workflow has no version")
2238 | return False
2239 |
2240 | version = self.version
2241 |
2242 | if isinstance(version, basestring):
2243 | from update import Version
2244 | version = Version(version)
2245 |
2246 | self.settings['__workflow_last_version'] = str(version)
2247 |
2248 | self.logger.debug('set last run version: %s', version)
2249 |
2250 | return True
2251 |
2252 | @property
2253 | def update_available(self):
2254 | """Whether an update is available.
2255 |
2256 | .. versionadded:: 1.9
2257 |
2258 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed
2259 | information on how to enable your workflow to update itself.
2260 |
2261 | :returns: ``True`` if an update is available, else ``False``
2262 |
2263 | """
2264 | # Create a new workflow object to ensure standard serialiser
2265 | # is used (update.py is called without the user's settings)
2266 | update_data = Workflow().cached_data('__workflow_update_status',
2267 | max_age=0)
2268 |
2269 | self.logger.debug('update_data: %r', update_data)
2270 |
2271 | if not update_data or not update_data.get('available'):
2272 | return False
2273 |
2274 | return update_data['available']
2275 |
2276 | @property
2277 | def prereleases(self):
2278 | """Whether workflow should update to pre-release versions.
2279 |
2280 | .. versionadded:: 1.16
2281 |
2282 | :returns: ``True`` if pre-releases are enabled with the :ref:`magic
2283 | argument ` or the ``update_settings`` dict, else
2284 | ``False``.
2285 |
2286 | """
2287 | if self._update_settings.get('prereleases'):
2288 | return True
2289 |
2290 | return self.settings.get('__workflow_prereleases') or False
2291 |
2292 | def check_update(self, force=False):
2293 | """Call update script if it's time to check for a new release.
2294 |
2295 | .. versionadded:: 1.9
2296 |
2297 | The update script will be run in the background, so it won't
2298 | interfere in the execution of your workflow.
2299 |
2300 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed
2301 | information on how to enable your workflow to update itself.
2302 |
2303 | :param force: Force update check
2304 | :type force: ``Boolean``
2305 |
2306 | """
2307 | frequency = self._update_settings.get('frequency',
2308 | DEFAULT_UPDATE_FREQUENCY)
2309 |
2310 | if not force and not self.settings.get('__workflow_autoupdate', True):
2311 | self.logger.debug('Auto update turned off by user')
2312 | return
2313 |
2314 | # Check for new version if it's time
2315 | if (force or not self.cached_data_fresh(
2316 | '__workflow_update_status', frequency * 86400)):
2317 |
2318 | github_slug = self._update_settings['github_slug']
2319 | # version = self._update_settings['version']
2320 | version = str(self.version)
2321 |
2322 | from background import run_in_background
2323 |
2324 | # update.py is adjacent to this file
2325 | update_script = os.path.join(os.path.dirname(__file__),
2326 | b'update.py')
2327 |
2328 | cmd = ['/usr/bin/python', update_script, 'check', github_slug,
2329 | version]
2330 |
2331 | if self.prereleases:
2332 | cmd.append('--prereleases')
2333 |
2334 | self.logger.info('checking for update ...')
2335 |
2336 | run_in_background('__workflow_update_check', cmd)
2337 |
2338 | else:
2339 | self.logger.debug('update check not due')
2340 |
2341 | def start_update(self):
2342 | """Check for update and download and install new workflow file.
2343 |
2344 | .. versionadded:: 1.9
2345 |
2346 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed
2347 | information on how to enable your workflow to update itself.
2348 |
2349 | :returns: ``True`` if an update is available and will be
2350 | installed, else ``False``
2351 |
2352 | """
2353 | import update
2354 |
2355 | github_slug = self._update_settings['github_slug']
2356 | # version = self._update_settings['version']
2357 | version = str(self.version)
2358 |
2359 | if not update.check_update(github_slug, version, self.prereleases):
2360 | return False
2361 |
2362 | from background import run_in_background
2363 |
2364 | # update.py is adjacent to this file
2365 | update_script = os.path.join(os.path.dirname(__file__),
2366 | b'update.py')
2367 |
2368 | cmd = ['/usr/bin/python', update_script, 'install', github_slug,
2369 | version]
2370 |
2371 | if self.prereleases:
2372 | cmd.append('--prereleases')
2373 |
2374 | self.logger.debug('downloading update ...')
2375 | run_in_background('__workflow_update_install', cmd)
2376 |
2377 | return True
2378 |
2379 | ####################################################################
2380 | # Keychain password storage methods
2381 | ####################################################################
2382 |
2383 | def save_password(self, account, password, service=None):
2384 | """Save account credentials.
2385 |
2386 | If the account exists, the old password will first be deleted
2387 | (Keychain throws an error otherwise).
2388 |
2389 | If something goes wrong, a :class:`KeychainError` exception will
2390 | be raised.
2391 |
2392 | :param account: name of the account the password is for, e.g.
2393 | "Pinboard"
2394 | :type account: ``unicode``
2395 | :param password: the password to secure
2396 | :type password: ``unicode``
2397 | :param service: Name of the service. By default, this is the
2398 | workflow's bundle ID
2399 | :type service: ``unicode``
2400 |
2401 | """
2402 | if not service:
2403 | service = self.bundleid
2404 |
2405 | try:
2406 | self._call_security('add-generic-password', service, account,
2407 | '-w', password)
2408 | self.logger.debug('saved password : %s:%s', service, account)
2409 |
2410 | except PasswordExists:
2411 | self.logger.debug('password exists : %s:%s', service, account)
2412 | current_password = self.get_password(account, service)
2413 |
2414 | if current_password == password:
2415 | self.logger.debug('password unchanged')
2416 |
2417 | else:
2418 | self.delete_password(account, service)
2419 | self._call_security('add-generic-password', service,
2420 | account, '-w', password)
2421 | self.logger.debug('save_password : %s:%s', service, account)
2422 |
2423 | def get_password(self, account, service=None):
2424 | """Retrieve the password saved at ``service/account``.
2425 |
2426 | Raise :class:`PasswordNotFound` exception if password doesn't exist.
2427 |
2428 | :param account: name of the account the password is for, e.g.
2429 | "Pinboard"
2430 | :type account: ``unicode``
2431 | :param service: Name of the service. By default, this is the workflow's
2432 | bundle ID
2433 | :type service: ``unicode``
2434 | :returns: account password
2435 | :rtype: ``unicode``
2436 |
2437 | """
2438 | if not service:
2439 | service = self.bundleid
2440 |
2441 | output = self._call_security('find-generic-password', service,
2442 | account, '-g')
2443 |
2444 | # Parsing of `security` output is adapted from python-keyring
2445 | # by Jason R. Coombs
2446 | # https://pypi.python.org/pypi/keyring
2447 | m = re.search(
2448 | r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?',
2449 | output)
2450 |
2451 | if m:
2452 | groups = m.groupdict()
2453 | h = groups.get('hex')
2454 | password = groups.get('pw')
2455 | if h:
2456 | password = unicode(binascii.unhexlify(h), 'utf-8')
2457 |
2458 | self.logger.debug('got password : %s:%s', service, account)
2459 |
2460 | return password
2461 |
2462 | def delete_password(self, account, service=None):
2463 | """Delete the password stored at ``service/account``.
2464 |
2465 | Raise :class:`PasswordNotFound` if account is unknown.
2466 |
2467 | :param account: name of the account the password is for, e.g.
2468 | "Pinboard"
2469 | :type account: ``unicode``
2470 | :param service: Name of the service. By default, this is the workflow's
2471 | bundle ID
2472 | :type service: ``unicode``
2473 |
2474 | """
2475 | if not service:
2476 | service = self.bundleid
2477 |
2478 | self._call_security('delete-generic-password', service, account)
2479 |
2480 | self.logger.debug('deleted password : %s:%s', service, account)
2481 |
2482 | ####################################################################
2483 | # Methods for workflow:* magic args
2484 | ####################################################################
2485 |
2486 | def _register_default_magic(self):
2487 | """Register the built-in magic arguments."""
2488 | # TODO: refactor & simplify
2489 | # Wrap callback and message with callable
2490 | def callback(func, msg):
2491 | def wrapper():
2492 | func()
2493 | return msg
2494 |
2495 | return wrapper
2496 |
2497 | self.magic_arguments['delcache'] = callback(self.clear_cache,
2498 | 'Deleted workflow cache')
2499 | self.magic_arguments['deldata'] = callback(self.clear_data,
2500 | 'Deleted workflow data')
2501 | self.magic_arguments['delsettings'] = callback(
2502 | self.clear_settings, 'Deleted workflow settings')
2503 | self.magic_arguments['reset'] = callback(self.reset,
2504 | 'Reset workflow')
2505 | self.magic_arguments['openlog'] = callback(self.open_log,
2506 | 'Opening workflow log file')
2507 | self.magic_arguments['opencache'] = callback(
2508 | self.open_cachedir, 'Opening workflow cache directory')
2509 | self.magic_arguments['opendata'] = callback(
2510 | self.open_datadir, 'Opening workflow data directory')
2511 | self.magic_arguments['openworkflow'] = callback(
2512 | self.open_workflowdir, 'Opening workflow directory')
2513 | self.magic_arguments['openterm'] = callback(
2514 | self.open_terminal, 'Opening workflow root directory in Terminal')
2515 |
2516 | # Diacritic folding
2517 | def fold_on():
2518 | self.settings['__workflow_diacritic_folding'] = True
2519 | return 'Diacritics will always be folded'
2520 |
2521 | def fold_off():
2522 | self.settings['__workflow_diacritic_folding'] = False
2523 | return 'Diacritics will never be folded'
2524 |
2525 | def fold_default():
2526 | if '__workflow_diacritic_folding' in self.settings:
2527 | del self.settings['__workflow_diacritic_folding']
2528 | return 'Diacritics folding reset'
2529 |
2530 | self.magic_arguments['foldingon'] = fold_on
2531 | self.magic_arguments['foldingoff'] = fold_off
2532 | self.magic_arguments['foldingdefault'] = fold_default
2533 |
2534 | # Updates
2535 | def update_on():
2536 | self.settings['__workflow_autoupdate'] = True
2537 | return 'Auto update turned on'
2538 |
2539 | def update_off():
2540 | self.settings['__workflow_autoupdate'] = False
2541 | return 'Auto update turned off'
2542 |
2543 | def prereleases_on():
2544 | self.settings['__workflow_prereleases'] = True
2545 | return 'Prerelease updates turned on'
2546 |
2547 | def prereleases_off():
2548 | self.settings['__workflow_prereleases'] = False
2549 | return 'Prerelease updates turned off'
2550 |
2551 | def do_update():
2552 | if self.start_update():
2553 | return 'Downloading and installing update ...'
2554 | else:
2555 | return 'No update available'
2556 |
2557 | self.magic_arguments['autoupdate'] = update_on
2558 | self.magic_arguments['noautoupdate'] = update_off
2559 | self.magic_arguments['prereleases'] = prereleases_on
2560 | self.magic_arguments['noprereleases'] = prereleases_off
2561 | self.magic_arguments['update'] = do_update
2562 |
2563 | # Help
2564 | def do_help():
2565 | if self.help_url:
2566 | self.open_help()
2567 | return 'Opening workflow help URL in browser'
2568 | else:
2569 | return 'Workflow has no help URL'
2570 |
2571 | def show_version():
2572 | if self.version:
2573 | return 'Version: {0}'.format(self.version)
2574 | else:
2575 | return 'This workflow has no version number'
2576 |
2577 | def list_magic():
2578 | """Display all available magic args in Alfred."""
2579 | isatty = sys.stderr.isatty()
2580 | for name in sorted(self.magic_arguments.keys()):
2581 | if name == 'magic':
2582 | continue
2583 | arg = self.magic_prefix + name
2584 | self.logger.debug(arg)
2585 |
2586 | if not isatty:
2587 | self.add_item(arg, icon=ICON_INFO)
2588 |
2589 | if not isatty:
2590 | self.send_feedback()
2591 |
2592 | self.magic_arguments['help'] = do_help
2593 | self.magic_arguments['magic'] = list_magic
2594 | self.magic_arguments['version'] = show_version
2595 |
2596 | def clear_cache(self, filter_func=lambda f: True):
2597 | """Delete all files in workflow's :attr:`cachedir`.
2598 |
2599 | :param filter_func: Callable to determine whether a file should be
2600 | deleted or not. ``filter_func`` is called with the filename
2601 | of each file in the data directory. If it returns ``True``,
2602 | the file will be deleted.
2603 | By default, *all* files will be deleted.
2604 | :type filter_func: ``callable``
2605 | """
2606 | self._delete_directory_contents(self.cachedir, filter_func)
2607 |
2608 | def clear_data(self, filter_func=lambda f: True):
2609 | """Delete all files in workflow's :attr:`datadir`.
2610 |
2611 | :param filter_func: Callable to determine whether a file should be
2612 | deleted or not. ``filter_func`` is called with the filename
2613 | of each file in the data directory. If it returns ``True``,
2614 | the file will be deleted.
2615 | By default, *all* files will be deleted.
2616 | :type filter_func: ``callable``
2617 | """
2618 | self._delete_directory_contents(self.datadir, filter_func)
2619 |
2620 | def clear_settings(self):
2621 | """Delete workflow's :attr:`settings_path`."""
2622 | if os.path.exists(self.settings_path):
2623 | os.unlink(self.settings_path)
2624 | self.logger.debug('deleted : %r', self.settings_path)
2625 |
2626 | def reset(self):
2627 | """Delete workflow settings, cache and data.
2628 |
2629 | File :attr:`settings ` and directories
2630 | :attr:`cache ` and :attr:`data ` are deleted.
2631 |
2632 | """
2633 | self.clear_cache()
2634 | self.clear_data()
2635 | self.clear_settings()
2636 |
2637 | def open_log(self):
2638 | """Open :attr:`logfile` in default app (usually Console.app)."""
2639 | subprocess.call(['open', self.logfile])
2640 |
2641 | def open_cachedir(self):
2642 | """Open the workflow's :attr:`cachedir` in Finder."""
2643 | subprocess.call(['open', self.cachedir])
2644 |
2645 | def open_datadir(self):
2646 | """Open the workflow's :attr:`datadir` in Finder."""
2647 | subprocess.call(['open', self.datadir])
2648 |
2649 | def open_workflowdir(self):
2650 | """Open the workflow's :attr:`workflowdir` in Finder."""
2651 | subprocess.call(['open', self.workflowdir])
2652 |
2653 | def open_terminal(self):
2654 | """Open a Terminal window at workflow's :attr:`workflowdir`."""
2655 | subprocess.call(['open', '-a', 'Terminal',
2656 | self.workflowdir])
2657 |
2658 | def open_help(self):
2659 | """Open :attr:`help_url` in default browser."""
2660 | subprocess.call(['open', self.help_url])
2661 |
2662 | return 'Opening workflow help URL in browser'
2663 |
2664 | ####################################################################
2665 | # Helper methods
2666 | ####################################################################
2667 |
2668 | def decode(self, text, encoding=None, normalization=None):
2669 | """Return ``text`` as normalised unicode.
2670 |
2671 | If ``encoding`` and/or ``normalization`` is ``None``, the
2672 | ``input_encoding``and ``normalization`` parameters passed to
2673 | :class:`Workflow` are used.
2674 |
2675 | :param text: string
2676 | :type text: encoded or Unicode string. If ``text`` is already a
2677 | Unicode string, it will only be normalised.
2678 | :param encoding: The text encoding to use to decode ``text`` to
2679 | Unicode.
2680 | :type encoding: ``unicode`` or ``None``
2681 | :param normalization: The nomalisation form to apply to ``text``.
2682 | :type normalization: ``unicode`` or ``None``
2683 | :returns: decoded and normalised ``unicode``
2684 |
2685 | :class:`Workflow` uses "NFC" normalisation by default. This is the
2686 | standard for Python and will work well with data from the web (via
2687 | :mod:`~workflow.web` or :mod:`json`).
2688 |
2689 | macOS, on the other hand, uses "NFD" normalisation (nearly), so data
2690 | coming from the system (e.g. via :mod:`subprocess` or
2691 | :func:`os.listdir`/:mod:`os.path`) may not match. You should either
2692 | normalise this data, too, or change the default normalisation used by
2693 | :class:`Workflow`.
2694 |
2695 | """
2696 | encoding = encoding or self._input_encoding
2697 | normalization = normalization or self._normalizsation
2698 | if not isinstance(text, unicode):
2699 | text = unicode(text, encoding)
2700 | return unicodedata.normalize(normalization, text)
2701 |
2702 | def fold_to_ascii(self, text):
2703 | """Convert non-ASCII characters to closest ASCII equivalent.
2704 |
2705 | .. versionadded:: 1.3
2706 |
2707 | .. note:: This only works for a subset of European languages.
2708 |
2709 | :param text: text to convert
2710 | :type text: ``unicode``
2711 | :returns: text containing only ASCII characters
2712 | :rtype: ``unicode``
2713 |
2714 | """
2715 | if isascii(text):
2716 | return text
2717 | text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text])
2718 | return unicode(unicodedata.normalize('NFKD',
2719 | text).encode('ascii', 'ignore'))
2720 |
2721 | def dumbify_punctuation(self, text):
2722 | """Convert non-ASCII punctuation to closest ASCII equivalent.
2723 |
2724 | This method replaces "smart" quotes and n- or m-dashes with their
2725 | workaday ASCII equivalents. This method is currently not used
2726 | internally, but exists as a helper method for workflow authors.
2727 |
2728 | .. versionadded: 1.9.7
2729 |
2730 | :param text: text to convert
2731 | :type text: ``unicode``
2732 | :returns: text with only ASCII punctuation
2733 | :rtype: ``unicode``
2734 |
2735 | """
2736 | if isascii(text):
2737 | return text
2738 |
2739 | text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text])
2740 | return text
2741 |
2742 | def _delete_directory_contents(self, dirpath, filter_func):
2743 | """Delete all files in a directory.
2744 |
2745 | :param dirpath: path to directory to clear
2746 | :type dirpath: ``unicode`` or ``str``
2747 | :param filter_func function to determine whether a file shall be
2748 | deleted or not.
2749 | :type filter_func ``callable``
2750 |
2751 | """
2752 | if os.path.exists(dirpath):
2753 | for filename in os.listdir(dirpath):
2754 | if not filter_func(filename):
2755 | continue
2756 | path = os.path.join(dirpath, filename)
2757 | if os.path.isdir(path):
2758 | shutil.rmtree(path)
2759 | else:
2760 | os.unlink(path)
2761 | self.logger.debug('deleted : %r', path)
2762 |
2763 | def _load_info_plist(self):
2764 | """Load workflow info from ``info.plist``."""
2765 | # info.plist should be in the directory above this one
2766 | self._info = plistlib.readPlist(self.workflowfile('info.plist'))
2767 | self._info_loaded = True
2768 |
2769 | def _create(self, dirpath):
2770 | """Create directory `dirpath` if it doesn't exist.
2771 |
2772 | :param dirpath: path to directory
2773 | :type dirpath: ``unicode``
2774 | :returns: ``dirpath`` argument
2775 | :rtype: ``unicode``
2776 |
2777 | """
2778 | if not os.path.exists(dirpath):
2779 | os.makedirs(dirpath)
2780 | return dirpath
2781 |
2782 | def _call_security(self, action, service, account, *args):
2783 | """Call ``security`` CLI program that provides access to keychains.
2784 |
2785 | May raise `PasswordNotFound`, `PasswordExists` or `KeychainError`
2786 | exceptions (the first two are subclasses of `KeychainError`).
2787 |
2788 | :param action: The ``security`` action to call, e.g.
2789 | ``add-generic-password``
2790 | :type action: ``unicode``
2791 | :param service: Name of the service.
2792 | :type service: ``unicode``
2793 | :param account: name of the account the password is for, e.g.
2794 | "Pinboard"
2795 | :type account: ``unicode``
2796 | :param password: the password to secure
2797 | :type password: ``unicode``
2798 | :param *args: list of command line arguments to be passed to
2799 | ``security``
2800 | :type *args: `list` or `tuple`
2801 | :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a
2802 | ``unicode`` string.
2803 | :rtype: `tuple` (`int`, ``unicode``)
2804 |
2805 | """
2806 | cmd = ['security', action, '-s', service, '-a', account] + list(args)
2807 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
2808 | stderr=subprocess.STDOUT)
2809 | stdout, _ = p.communicate()
2810 | if p.returncode == 44: # password does not exist
2811 | raise PasswordNotFound()
2812 | elif p.returncode == 45: # password already exists
2813 | raise PasswordExists()
2814 | elif p.returncode > 0:
2815 | err = KeychainError('Unknown Keychain error : %s' % stdout)
2816 | err.retcode = p.returncode
2817 | raise err
2818 | return stdout.strip().decode('utf-8')
2819 |
--------------------------------------------------------------------------------
/Effective IP/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):
526 | """Set a "global" workflow variable.
527 |
528 | These variables are always passed to downstream workflow objects.
529 |
530 | If you have set :attr:`rerun`, these variables are also passed
531 | back to the script when Alfred runs it again.
532 |
533 | Args:
534 | name (unicode): Name of variable.
535 | value (unicode): Value of variable.
536 |
537 | """
538 | self.variables[name] = value
539 |
540 | def getvar(self, name, default=None):
541 | """Return value of workflow variable for ``name`` or ``default``.
542 |
543 | Args:
544 | name (unicode): Variable name.
545 | default (None, optional): Value to return if variable is unset.
546 |
547 | Returns:
548 | unicode or ``default``: Value of variable if set or ``default``.
549 |
550 | """
551 | return self.variables.get(name, default)
552 |
553 | def add_item(self, title, subtitle='', arg=None, autocomplete=None,
554 | valid=False, uid=None, icon=None, icontype=None, type=None,
555 | largetext=None, copytext=None, quicklookurl=None, match=None):
556 | """Add an item to be output to Alfred.
557 |
558 | Args:
559 | match (unicode, optional): If you have "Alfred filters results"
560 | turned on for your Script Filter, Alfred (version 3.5 and
561 | above) will filter against this field, not ``title``.
562 |
563 | See :meth:`Workflow.add_item() ` for
564 | the main documentation and other parameters.
565 |
566 | The key difference is that this method does not support the
567 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
568 | method instead on the returned item instead.
569 |
570 | Returns:
571 | Item3: Alfred feedback item.
572 |
573 | """
574 | item = self.item_class(title, subtitle, arg, autocomplete,
575 | match, valid, uid, icon, icontype, type,
576 | largetext, copytext, quicklookurl)
577 |
578 | # Add variables to child item
579 | item.variables.update(self.variables)
580 |
581 | self._items.append(item)
582 | return item
583 |
584 | @property
585 | def _session_prefix(self):
586 | """Filename prefix for current session."""
587 | return '_wfsess-{0}-'.format(self.session_id)
588 |
589 | def _mk_session_name(self, name):
590 | """New cache name/key based on session ID."""
591 | return self._session_prefix + name
592 |
593 | def cache_data(self, name, data, session=False):
594 | """Cache API with session-scoped expiry.
595 |
596 | .. versionadded:: 1.25
597 |
598 | Args:
599 | name (str): Cache key
600 | data (object): Data to cache
601 | session (bool, optional): Whether to scope the cache
602 | to the current session.
603 |
604 | ``name`` and ``data`` are the same as for the
605 | :meth:`~workflow.Workflow.cache_data` method on
606 | :class:`~workflow.Workflow`.
607 |
608 | If ``session`` is ``True``, then ``name`` is prefixed
609 | with :attr:`session_id`.
610 |
611 | """
612 | if session:
613 | name = self._mk_session_name(name)
614 |
615 | return super(Workflow3, self).cache_data(name, data)
616 |
617 | def cached_data(self, name, data_func=None, max_age=60, session=False):
618 | """Cache API with session-scoped expiry.
619 |
620 | .. versionadded:: 1.25
621 |
622 | Args:
623 | name (str): Cache key
624 | data_func (callable): Callable that returns fresh data. It
625 | is called if the cache has expired or doesn't exist.
626 | max_age (int): Maximum allowable age of cache in seconds.
627 | session (bool, optional): Whether to scope the cache
628 | to the current session.
629 |
630 | ``name``, ``data_func`` and ``max_age`` are the same as for the
631 | :meth:`~workflow.Workflow.cached_data` method on
632 | :class:`~workflow.Workflow`.
633 |
634 | If ``session`` is ``True``, then ``name`` is prefixed
635 | with :attr:`session_id`.
636 |
637 | """
638 | if session:
639 | name = self._mk_session_name(name)
640 |
641 | return super(Workflow3, self).cached_data(name, data_func, max_age)
642 |
643 | def clear_session_cache(self, current=False):
644 | """Remove session data from the cache.
645 |
646 | .. versionadded:: 1.25
647 | .. versionchanged:: 1.27
648 |
649 | By default, data belonging to the current session won't be
650 | deleted. Set ``current=True`` to also clear current session.
651 |
652 | Args:
653 | current (bool, optional): If ``True``, also remove data for
654 | current session.
655 |
656 | """
657 | def _is_session_file(filename):
658 | if current:
659 | return filename.startswith('_wfsess-')
660 | return filename.startswith('_wfsess-') \
661 | and not filename.startswith(self._session_prefix)
662 |
663 | self.clear_cache(_is_session_file)
664 |
665 | @property
666 | def obj(self):
667 | """Feedback formatted for JSON serialization.
668 |
669 | Returns:
670 | dict: Data suitable for Alfred 3 feedback.
671 |
672 | """
673 | items = []
674 | for item in self._items:
675 | items.append(item.obj)
676 |
677 | o = {'items': items}
678 | if self.variables:
679 | o['variables'] = self.variables
680 | if self.rerun:
681 | o['rerun'] = self.rerun
682 | return o
683 |
684 | def warn_empty(self, title, subtitle=u'', icon=None):
685 | """Add a warning to feedback if there are no items.
686 |
687 | .. versionadded:: 1.31
688 |
689 | Add a "warning" item to Alfred feedback if no other items
690 | have been added. This is a handy shortcut to prevent Alfred
691 | from showing its fallback searches, which is does if no
692 | items are returned.
693 |
694 | Args:
695 | title (unicode): Title of feedback item.
696 | subtitle (unicode, optional): Subtitle of feedback item.
697 | icon (str, optional): Icon for feedback item. If not
698 | specified, ``ICON_WARNING`` is used.
699 |
700 | Returns:
701 | Item3: Newly-created item.
702 | """
703 | if len(self._items):
704 | return
705 |
706 | icon = icon or ICON_WARNING
707 | return self.add_item(title, subtitle, icon=icon)
708 |
709 | def send_feedback(self):
710 | """Print stored items to console/Alfred as JSON."""
711 | json.dump(self.obj, sys.stdout)
712 | sys.stdout.flush()
713 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Alfred Workflow教程与实例
2 | =======
3 |
4 | 小帽子[Alfred](https://www.alfredapp.com)作为macOS上的最佳效率软件应该没太大争议([排名](https://github.com/hzlzh/Best-App)),而其中最强大的部分即为Alfred 2.0推出的[Workflow](https://www.alfredapp.com/workflows/)特性;其允许你将日常重复性的工作使用脚本语言(目前支持:bash, zsh, php, python, ruby, perl, osascript(AppleScript, JavaScript))封装起来,以Alfred作为统一的入口和呈现来使用,大大提高效率;本文将对其开发的一般流程进行讲述,并最终实现两个实例:
5 |
6 | > * `CDto`: 打开Terminal并转到任意文件夹或文件所在目录,使用 *bash+osascript* 实现 [点此下载](https://raw.githubusercontent.com/stidio/Alfred-Workflow/master/CDto.alfredworkflow)
7 | >
8 | > 
9 | >
10 | > * `Effective IP`: 查询本机和外网IP地址,解析任意URL和域名的IP地址,同时进行归属地和运营商查询,使用 *python* 实现 [点此下载](https://raw.githubusercontent.com/stidio/Alfred-Workflow/master/Effective%20IP.alfredworkflow)
11 | >
12 | > 
13 | > 
14 | > 
15 | > 
16 | >
17 |
18 | 本文源代码地址:[https://github.com/stidio/Alfred-Workflow](https://github.com/stidio/Alfred-Workflow),如果喜欢请[Star!](https://github.com/stidio/Alfred-Workflow),谢谢!
19 |
20 | ### 概述 ###
21 |
22 | Alfred Workflow的整体架构,极度类似于Windows中的Direct Show,首先由一个Input开始,中间经过一堆filter,然后到一个Output结束,中间通过Pin连接,上一个Output Pin作为输入传递给下一个Input Pin,从而形成一个完整的Graph,而最终传递给Alfred做输出呈现的内容必须符合下面的形式:
23 |
24 | > ```xml
25 | >
26 | >
27 | >
-
28 | > 10.0.2.11
29 | > 45.76.65.119 美国新泽西州皮斯卡特维 choopa.com
30 | > Info.icns
31 | >
32 | >
33 | > ```
34 | >
35 | > Alfred上每一行显示对应一个*item*,如果显示多行,那就在*items*下放入多个*item*即可
36 | > * *valid* 表现为可不可以选择,点击,再次传递
37 | > * *title* 主标题
38 | > * *subtitle* 副标题
39 | > * *icon* 图标
40 |
41 | ### 开发准备 ###
42 |
43 | 1. 使用[Option+空格]调出Alfred,输入alfred打开Alfred Preferences:
44 |
45 | 
46 |
47 | 2. 点击Workflows按钮,然后点击最下面的 **+** 按钮,创建一个Blank Workflow,按照提示填入信息:
48 |
49 | 
50 |
51 | > **Bundle Id** 作为该Workflow的标识为必填内容,如果不填或与其他重复,有可能造成其不能正常运行
52 |
53 | ### Workflow - CDto ###
54 |
55 | 使用Terminal的一般步骤大概是运行Terminal,然后一路cd到目标文件夹后开始使用;虽然Finder有cd to插件,但也需要你一路点到指定文件夹后,才能调起来;虽然Alfred的Right Arrow按键里面有Open Terminal Here操作,但排在太后面了,打开的操作路径至少需要:Right Arrow -> 输入o -> [Command + 3]三步才能完成:
56 |
57 | 
58 |
59 | 作为一个需要频繁和Terminal交互的码农这完全不能忍,下面我们就利用Workflow做个一步到位的CDto神器
60 |
61 | 1. 在Alfred Workflows的工作区点右键,选择菜单[Inputs -> File Filter],并按下图设置好,其他两个选项卡使用默认设置即可:
62 |
63 | 
64 |
65 | 2. 在刚才插入的[File Filter]上点击右键,选择菜单[Insert After -> Actions -> Run Script],并按照下图设置好,最下面的Escaping表示对指定字符进行转义,比如说:/Users/$a1,如果不对$转义,那外部会把$a1一起当做一个变量,而这个变量未定义也就是为空,传递进来的参数最终变成:/Users/,[点此查看代码](https://github.com/stidio/Alfred-Workflow/blob/master/CDto/cdto.bash):
66 |
67 | 
68 |
69 | ### Workflow - Effective IP ###
70 |
71 | 现在我们使用Python来做个更复杂的例子,[点此查看源码](https://github.com/stidio/Alfred-Workflow/blob/master/Effective%20IP/effectiveip.py),具体分析见下图:
72 |
73 | 
74 |
75 | 我们基于[Full-featured python library for writing Alfred workflows](https://github.com/deanishe/alfred-workflow/)进行开发,具体的内容请参考前面的内容和[官方教程](http://www.deanishe.net/alfred-workflow/tutorial_1.html), 这里我只对两个设置界面进行必要的解释:
76 |
77 | 1. 主设置界面
78 |
79 | 
80 |
81 | > 1. 直接输入ip无参形式是查询本机的本地和公网地址,有参形式是进行DNS解析,因此参数是可选的,需要设置为:[Argument Optional]
82 | > 2. 点击Run Behaviour按钮,进行运行行为设置
83 |
84 | 2. 运行行为设置
85 |
86 | 
87 |
88 | > 1. 如果输入发生变化,我们肯定是希望得到之后的结果,因此我们需要即时结束掉之前的查询
89 | > 2. 在输入过程中不进行查询,Alfred通过最后一个字符输入延迟来判断输入结束后才进行查询
90 |
91 | ### Workflow - UpdateAllNPM/UpdateAllPIP ###
92 |
93 | 2018-05-01 新添两个Workflow
94 | > * UpdateAllNPM 更新所有的全局Node.js模块
95 | > * UpdateAllPIP 更新所有的Python模块
96 |
97 | ### 其他事项 ###
98 |
99 | 
100 |
101 | > 1. 左边列表区域里点右键选择[Open in Finder]可以打开该Workflow的目录进行文件查看和编辑
102 | > 2. 点此可以调出调试窗口,查看调试信息
103 |
104 | ### 参考资料 ###
105 |
106 | [神兵利器 — Alfred](http://macshuo.com/?p=625)
107 | [Alfred workflow 开发指南](http://myg0u.com/python/2015/05/23/tutorial-alfred-workflow.html)
108 | [JavaScript for OS X Automation by Example](http://developer.telerik.com/featured/javascript-os-x-automation-example/)
109 | [Full-featured python library for writing Alfred workflows](http://www.deanishe.net/alfred-workflow/)
--------------------------------------------------------------------------------
/UpdateAllNPM.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/UpdateAllNPM.alfredworkflow
--------------------------------------------------------------------------------
/UpdateAllPIP.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/UpdateAllPIP.alfredworkflow
--------------------------------------------------------------------------------
/有道翻译.alfredworkflow:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stidio/Alfred-Workflow/f506e8445c57e4a5b53d66831c4e9eaeaa1ee4ff/有道翻译.alfredworkflow
--------------------------------------------------------------------------------