Tutorial Part 2: Creating a Distribution-Ready Pinboard Workflow¶
In which we create a Pinboard.in workflow ready for mass consumption.
In the first part of the tutorial, we built a useable workflow to view, search and open your recent Pinboard posts. The workflow isn’t quite ready to be distributed to other users, however: we can’t expect them to go grubbing around in the source code like an animal to set their own API keys.
What’s more, an update to the workflow would overwrite their changes.
So now we’re going to edit the workflow so users can add their API key from the
comfort of Alfred’s friendly query box and use
Workflow.settings
to save it in the workflow’s data directory where it won’t get overwritten.
Performing multiple actions from one script¶
To set the user’s API key, we’re going to need a new action. We could write a second script to do this, but we’re going to stick with one script and make it smart enough to do two things, instead. The advantage of using one script is that if you build a workflow with lots of actions, you don’t have a dozen or more scripts to manage.
We’ll start by adding an argument parser (using argparse
[1]) to
main()
and some if-clauses to alter the script’s behaviour depending on the
arguments passed to it by Alfred.
1 # encoding: utf-8
2
3 import sys
4 import argparse
5 from workflow import Workflow, ICON_WEB, ICON_WARNING, web
6
7
8 def get_recent_posts(api_key):
9 """Retrieve recent posts from Pinboard.in
10
11 Returns a list of post dictionaries.
12
13 """
14 url = 'https://api.pinboard.in/v1/posts/recent'
15 params = dict(auth_token=api_key, count=100, format='json')
16 r = web.get(url, params)
17
18 # throw an error if request failed
19 # Workflow will catch this and show it to the user
20 r.raise_for_status()
21
22 # Parse the JSON returned by pinboard and extract the posts
23 result = r.json()
24 posts = result['posts']
25 return posts
26
27
28 def search_key_for_post(post):
29 """Generate a string search key for a post"""
30 elements = []
31 elements.append(post['description']) # title of post
32 elements.append(post['tags']) # post tags
33 elements.append(post['extended']) # description
34 return ' '.join(elements)
35
36
37 def main(wf):
38
39 # build argument parser to parse script args and collect their
40 # values
41 parser = argparse.ArgumentParser()
42 # add an optional (nargs='?') --setkey argument and save its
43 # value to 'apikey' (dest). This will be called from a separate "Run Script"
44 # action with the API key
45 parser.add_argument('--setkey', dest='apikey', nargs='?', default=None)
46 # add an optional query and save it to 'query'
47 parser.add_argument('query', nargs='?', default=None)
48 # parse the script's arguments
49 args = parser.parse_args(wf.args)
50
51 ####################################################################
52 # Save the provided API key
53 ####################################################################
54
55 # decide what to do based on arguments
56 if args.apikey: # Script was passed an API key
57 # save the key
58 wf.settings['api_key'] = args.apikey
59 return 0 # 0 means script exited cleanly
60
61 ####################################################################
62 # Check that we have an API key saved
63 ####################################################################
64
65 api_key = wf.settings.get('api_key', None)
66 if not api_key: # API key has not yet been set
67 wf.add_item('No API key set.',
68 'Please use pbsetkey to set your Pinboard API key.',
69 valid=False,
70 icon=ICON_WARNING)
71 wf.send_feedback()
72 return 0
73
74 ####################################################################
75 # View/filter Pinboard posts
76 ####################################################################
77
78 query = args.query
79 # Retrieve posts from cache if available and no more than 600
80 # seconds old
81
82 def wrapper():
83 """`cached_data` can only take a bare callable (no args),
84 so we need to wrap callables needing arguments in a function
85 that needs none.
86 """
87 return get_recent_posts(api_key)
88
89 posts = wf.cached_data('posts', wrapper, max_age=600)
90
91 # If script was passed a query, use it to filter posts
92 if query:
93 posts = wf.filter(query, posts, key=search_key_for_post, min_score=20)
94
95 # Loop through the returned posts and add a item for each to
96 # the list of results for Alfred
97 for post in posts:
98 wf.add_item(title=post['description'],
99 subtitle=post['href'],
100 arg=post['href'],
101 valid=True,
102 icon=ICON_WEB)
103
104 # Send the results to Alfred as XML
105 wf.send_feedback()
106 return 0
107
108
109 if __name__ == u"__main__":
110 wf = Workflow()
111 sys.exit(wf.run(main))
Quite a lot has happened here: at the top in line 5, we’re importing a couple
more icons that we use in main()
to notify the user that their API key is
missing and that they should set it (lines 65–72).
(You can see a list of all supported icons here.)
We’ve adapted get_recent_posts()
to accept an api_key
argument. We could
continue to use the API_KEY
global variable, but that’d be bad form.
As a result of this, we’ve had to alter the way
Workflow.cached_data()
is
called. It can’t call a function that requires any arguments, so we’ve added a
wrapper()
function within main()
(lines 82–87) that calls
get_recent_posts()
with the necessary api_key
arguments, and we pass
this wrapper()
function (which needs no arguments) to
Workflow.cached_data()
instead
(line 89).
At the top of main()
(lines 39–49), we’ve added an argument parser using
argparse
that can take an optional --apikey APIKEY
argument
and an optional query
argument (remember the script doesn’t require a query).
Then, in lines 55–59, we check if an API key was passed using --apikey
.
If it was, we save it using settings
(see below).
Once this is done, we exit the script.
If no API key was specified with --apikey
, we try to show/filter Pinboard
posts as before. But first of all, we now have to check to see if we already
have an API key saved (lines 65–72). If not, we show the user a warning
(No API key set) and exit the script.
Finally, if we have an API key saved, we retrieve it and show/filter the Pinboard posts just as before (lines 78–107).
Of course, we don’t have an API key saved, and we haven’t yet set up our workflow in Alfred to save one, so the workflow currently won’t work. Try to run it, and you’ll see the warning we just implemented:
So let’s add that functionality now.
Multi-step actions¶
Asking the user for input and saving it is best done in two steps:
Ask for the data.
Pass it to a second action to save it.
A Script Filter is designed to be called constantly by Alfred and return results. This time, we just want to get some data, so we’ll use a Keyword input instead.
Go back to your workflow in Alfred’s Preferences and add a Keyword input:
And set it up as follows (we’ll use the keyword pbsetkey
because that’s what we told the user to use
in the above warning message):
You can now enter pbsetkey
in Alfred and see the following:
It won’t do anything yet, though, as we haven’t connected its output to anything.
Back in Alfred’s Preferences, add a Run Script action:
and point it at our pinboard.py
script with the --setkey
argument:
Finally, connect the pbsetkey
Keyword to the new Run Script action:
Now you can call pbsetkey
in Alfred, paste in your Pinboard API key and hit
ENTER. It will be saved by the workflow and pbrecent
will once again
work as expected. Try it.
It’s a little confusing receiving no feedback on whether the key was saved or not, so go back into Alfred’s Preferences, and add an Output > Post Notification action to your workflow:
In the resulting pop-up, enter a message to be shown in Notification Center:
and connect the Run Script we just added to it:
Try setting your API key again with pbsetkey
and this time you’ll get a
notification that it was saved.
Saving settings¶
Saving the API key was pretty easy (1 line of code). Settings
is a special dictionary that automatically saves itself when you change its
contents. It can be used much like a normal dictionary with the caveat that all
values must be serializable to JSON as the settings are saved as a JSON file in
the workflow’s data directory.
Very simple, yes, but secure? No. A better place to save the API key would be in the user’s Keychain. Let’s do that.
Saving settings securely¶
Workflow
provides three methods for managing data
saved in macOS’s Keychain: get_password()
,
save_password()
and delete_password()
.
They are all called with an account
name and an optional service
name
(by default, this is your workflow’s bundle ID
).
Change your pinboard.py
script as follows to use Keychain instead of a JSON
file to store your API key:
1 # encoding: utf-8
2
3 import sys
4 import argparse
5 from workflow import Workflow, ICON_WEB, ICON_WARNING, web, PasswordNotFound
6
7
8 def get_recent_posts(api_key):
9 """Retrieve recent posts from Pinboard.in
10
11 Returns a list of post dictionaries.
12
13 """
14 url = 'https://api.pinboard.in/v1/posts/recent'
15 params = dict(auth_token=api_key, count=100, format='json')
16 r = web.get(url, params)
17
18 # throw an error if request failed
19 # Workflow will catch this and show it to the user
20 r.raise_for_status()
21
22 # Parse the JSON returned by pinboard and extract the posts
23 result = r.json()
24 posts = result['posts']
25 return posts
26
27
28 def search_key_for_post(post):
29 """Generate a string search key for a post"""
30 elements = []
31 elements.append(post['description']) # title of post
32 elements.append(post['tags']) # post tags
33 elements.append(post['extended']) # description
34 return ' '.join(elements)
35
36
37 def main(wf):
38
39 # build argument parser to parse script args and collect their
40 # values
41 parser = argparse.ArgumentParser()
42 # add an optional (nargs='?') --apikey argument and save its
43 # value to 'apikey' (dest). This will be called from a separate "Run Script"
44 # action with the API key
45 parser.add_argument('--setkey', dest='apikey', nargs='?', default=None)
46 # add an optional query and save it to 'query'
47 parser.add_argument('query', nargs='?', default=None)
48 # parse the script's arguments
49 args = parser.parse_args(wf.args)
50
51 ####################################################################
52 # Save the provided API key
53 ####################################################################
54
55 # decide what to do based on arguments
56 if args.apikey: # Script was passed an API key
57 # save the key
58 wf.save_password('pinboard_api_key', args.apikey)
59 return 0 # 0 means script exited cleanly
60
61 ####################################################################
62 # Check that we have an API key saved
63 ####################################################################
64
65 try:
66 api_key = wf.get_password('pinboard_api_key')
67 except PasswordNotFound: # API key has not yet been set
68 wf.add_item('No API key set.',
69 'Please use pbsetkey to set your Pinboard API key.',
70 valid=False,
71 icon=ICON_WARNING)
72 wf.send_feedback()
73 return 0
74
75 ####################################################################
76 # View/filter Pinboard posts
77 ####################################################################
78
79 query = args.query
80 # Retrieve posts from cache if available and no more than 600
81 # seconds old
82
83 def wrapper():
84 """`cached_data` can only take a bare callable (no args),
85 so we need to wrap callables needing arguments in a function
86 that needs none.
87 """
88 return get_recent_posts(api_key)
89
90 posts = wf.cached_data('posts', wrapper, max_age=600)
91
92 # If script was passed a query, use it to filter posts
93 if query:
94 posts = wf.filter(query, posts, key=search_key_for_post, min_score=20)
95
96 # Loop through the returned posts and add an item for each to
97 # the list of results for Alfred
98 for post in posts:
99 wf.add_item(title=post['description'],
100 subtitle=post['href'],
101 arg=post['href'],
102 valid=True,
103 icon=ICON_WEB)
104
105 # Send the results to Alfred as XML
106 wf.send_feedback()
107 return 0
108
109
110 if __name__ == u"__main__":
111 wf = Workflow()
112 sys.exit(wf.run(main))
get_password()
raises a
PasswordNotFound
exception if the requested
password isn’t in your Keychain, so we import PasswordNotFound
and change if not api_key:
to a try ... except
clause (lines 65–72).
Try running your workflow again. It will complain that you haven’t saved your API key (it’s looking in Keychain now, not the settings), so set your API key once again, and you should be able to browse your recent posts in Alfred once more.
And if you open Keychain Access, you’ll find the API key safely tucked away in your Keychain:
As a bonus, if you have multiple Macs and use iCloud Keychain, the API key will be seamlessly synced across machines, saving you the trouble of setting up the workflow multiple times.
“Magic” arguments¶
Now that the API key is stored in Keychain, we don’t need it saved in the
workflow’s settings any more (and having it there that kind of defeats the
purpose of using Keychain). To get rid of it, we can use one of Alfred-PyWorkflow’s
“magic” arguments: workflow:delsettings
.
Open up Alfred, and enter pbrecent workflow:delsettings
. You should see the
following message:
Alfred-PyWorkflow has recognised one of its “magic” arguments, performed the corresponding action, logged it to the log file, notified the user via Alfred and exited the workflow.
Magic arguments are designed to help coders develop and debug workflows. See “Magic” arguments for more details.
Logging¶
There’s a log, you say? Yup. There’s a logging.Logger
instance at Workflow.logger
configured to output to both the Terminal (in case you’re running your workflow
script in Terminal) and your workflow’s log file. Normally, I use it like this:
1 from workflow import Workflow
2
3 log = None
4
5
6 def main(wf):
7 log.debug('Started')
8
9 if __name__ == '__main__':
10 wf = Workflow()
11 log = wf.logger
12 wf.run(main)
Assigning Workflow.logger
to the
module global log
is just a convenience. You could use wf.logger
in
its place.
Spit and polish¶
So far, the workflow’s looking pretty good. But there are still a few of things that could be better. For one, it’s not necessarily obvious to a user where to find their Pinboard API key (it took me a good, hard Googling to find it while writing these tutorials). For another, it can be confusing if there are no results from a workflow and Alfred shows its fallback Google/Amazon searches instead. Finally, the workflow is unresponsive while updating the list of recent posts from Pinboard. That can’t be helped if we don’t have any posts cached, but apart from the very first run, we always will, so why don’t we show what we have and update in the background?
Let’s fix those issues. The easy ones first.
Two actions, one keyword¶
To solve the first issue (Pinboard API keys being hard to find), we’ll add a
second Keyword input that responds to the same pbsetkey
keyword as our
other action, but this one will just send the user to the Pinboard
password settings page where the API keys are kept.
Go back to your workflow in Alfred’s Preferences and add a new Keyword with the following settings:
Now when you type pbsetkey
into Alfred, you should see two options:
The second action doesn’t do anything yet, of course, because we haven’t connected it to anything. So add an Open URL action in Alfred, enter this URL:
https://pinboard.in/settings/password
and leave all the settings at their defaults.
Finally, connect your new Keyword to the new Open URL action:
Enter pbsetkey
into Alfred once more and try out the new action. Pinboard
should open in your default browser.
Easy peasy.
Notifying the user if there are no results¶
Alfred’s default behaviour when a Script Filter returns no results is to show its fallback searches. This is also what it does if a workflow crashes. So, the best thing to do when a user is explicitly using your workflow is to show a message indicating that no results were found.
Change pinboard.py
to the following:
1 # encoding: utf-8
2
3 import sys
4 import argparse
5 from workflow import Workflow, ICON_WEB, ICON_WARNING, web, PasswordNotFound
6
7
8 def get_recent_posts(api_key):
9 """Retrieve recent posts from Pinboard.in
10
11 Returns a list of post dictionaries.
12
13 """
14 url = 'https://api.pinboard.in/v1/posts/recent'
15 params = dict(auth_token=api_key, count=100, format='json')
16 r = web.get(url, params)
17
18 # throw an error if request failed
19 # Workflow will catch this and show it to the user
20 r.raise_for_status()
21
22 # Parse the JSON returned by pinboard and extract the posts
23 result = r.json()
24 posts = result['posts']
25 return posts
26
27
28 def search_key_for_post(post):
29 """Generate a string search key for a post"""
30 elements = []
31 elements.append(post['description']) # title of post
32 elements.append(post['tags']) # post tags
33 elements.append(post['extended']) # description
34 return ' '.join(elements)
35
36
37 def main(wf):
38
39 # build argument parser to parse script args and collect their
40 # values
41 parser = argparse.ArgumentParser()
42 # add an optional (nargs='?') --apikey argument and save its
43 # value to 'apikey' (dest). This will be called from a separate "Run Script"
44 # action with the API key
45 parser.add_argument('--setkey', dest='apikey', nargs='?', default=None)
46 # add an optional query and save it to 'query'
47 parser.add_argument('query', nargs='?', default=None)
48 # parse the script's arguments
49 args = parser.parse_args(wf.args)
50
51 ####################################################################
52 # Save the provided API key
53 ####################################################################
54
55 # decide what to do based on arguments
56 if args.apikey: # Script was passed an API key
57 # save the key
58 wf.save_password('pinboard_api_key', args.apikey)
59 return 0 # 0 means script exited cleanly
60
61 ####################################################################
62 # Check that we have an API key saved
63 ####################################################################
64
65 try:
66 api_key = wf.get_password('pinboard_api_key')
67 except PasswordNotFound: # API key has not yet been set
68 wf.add_item('No API key set.',
69 'Please use pbsetkey to set your Pinboard API key.',
70 valid=False,
71 icon=ICON_WARNING)
72 wf.send_feedback()
73 return 0
74
75 ####################################################################
76 # View/filter Pinboard posts
77 ####################################################################
78
79 query = args.query
80 # Retrieve posts from cache if available and no more than 600
81 # seconds old
82
83 def wrapper():
84 """`cached_data` can only take a bare callable (no args),
85 so we need to wrap callables needing arguments in a function
86 that needs none.
87 """
88 return get_recent_posts(api_key)
89
90 posts = wf.cached_data('posts', wrapper, max_age=600)
91
92 # If script was passed a query, use it to filter posts
93 if query:
94 posts = wf.filter(query, posts, key=search_key_for_post, min_score=20)
95
96 if not posts: # we have no data to show, so show a warning and stop
97 wf.add_item('No posts found', icon=ICON_WARNING)
98 wf.send_feedback()
99 return 0
100
101 # Loop through the returned posts and add an item for each to
102 # the list of results for Alfred
103 for post in posts:
104 wf.add_item(title=post['description'],
105 subtitle=post['href'],
106 arg=post['href'],
107 valid=True,
108 icon=ICON_WEB)
109
110 # Send the results to Alfred as XML
111 wf.send_feedback()
112 return 0
113
114
115 if __name__ == u"__main__":
116 wf = Workflow()
117 sys.exit(wf.run(main))
In lines 96-99, we check to see it there are any posts, and if not, we show the user a warning, send the results to Alfred and exit. This does away with Alfred’s distracting default searches and lets the user know exactly what’s going on.
Greased lightning: background updates¶
All that remains is for our workflow to provide the blazing fast results Alfred users have come to expect. No waiting around for glacial web services for the likes of us. As long as we have some posts saved in the cache, we can show those while grabbing an updated list in the background (and notifying the user of the update, of course).
Now, there are a few different ways to start a background process. We could ask
the user to set up a cron
job, but cron
isn’t the easiest software to
use. We could add and load a Launch Agent, but that’d run indefinitely,
whether or not the workflow is being used, and even if the workflow were
uninstalled. So we’d best start our background process from within the workflow
itself.
Normally, you’d use subprocess.Popen
to start a background process,
but that doesn’t necessarily work quite as you might expect in Alfred: it
treats your workflow as still running till the subprocess has finished,
too, so it won’t call your workflow with a new query till the update is done.
Which is exactly what happens now and the behaviour we want to avoid.
Fortunately, Alfred-PyWorkflow provides the background
module
to solve this problem.
Using the background.run_in_background()
and background.is_running()
functions,
we can easily run a script in the background while our workflow remains
responsive to Alfred’s queries.
Alfred-PyWorkflow’s background
module is based on, and uses the
same API as subprocess.call()
, but it runs the command as a background
daemon process (consequently, it won’t return anything). So, our updater script
will be called from our main workflow script, but background
will run it as a background process. This way, it will appear to exit
immediately, so Alfred will keep on calling our workflow every time the query
changes.
Meanwhile, our main workflow script will check if the background updater is running and post a useful, friendly notification if it is.
Let’s have at it.
Background updater script¶
Create a new file in the workflow root directory called update.py
with these
contents:
1 # encoding: utf-8
2
3
4 from workflow import web, Workflow, PasswordNotFound
5
6
7 def get_recent_posts(api_key):
8 """Retrieve recent posts from Pinboard.in
9
10 Returns a list of post dictionaries.
11
12 """
13 url = 'https://api.pinboard.in/v1/posts/recent'
14 params = dict(auth_token=api_key, count=100, format='json')
15 r = web.get(url, params)
16
17 # throw an error if request failed
18 # Workflow will catch this and show it to the user
19 r.raise_for_status()
20
21 # Parse the JSON returned by pinboard and extract the posts
22 result = r.json()
23 posts = result['posts']
24 return posts
25
26
27 def main(wf):
28 try:
29 # Get API key from Keychain
30 api_key = wf.get_password('pinboard_api_key')
31
32 # Retrieve posts from cache if available and no more than 600
33 # seconds old
34
35 def wrapper():
36 """`cached_data` can only take a bare callable (no args),
37 so we need to wrap callables needing arguments in a function
38 that needs none.
39 """
40 return get_recent_posts(api_key)
41
42 posts = wf.cached_data('posts', wrapper, max_age=600)
43 # Record our progress in the log file
44 wf.logger.debug('{} Pinboard posts cached'.format(len(posts)))
45
46 except PasswordNotFound: # API key has not yet been set
47 # Nothing we can do about this, so just log it
48 wf.logger.error('No API key saved')
49
50 if __name__ == '__main__':
51 wf = Workflow()
52 wf.run(main)
At the top of the file (line 7), we’ve copied the get_recent_posts()
function from pinboard.py
(we won’t need it there any more).
The contents of the try
block in main()
(lines 29–44) are once again
copied straight from pinboard.py
(where we won’t be needing them any more).
The except
clause (lines 46–48) is to trap the
PasswordNotFound
error that Workflow.get_password()
will raise if the user hasn’t set their API key via Alfred yet. update.py
can quietly die if no API key has been set because pinboard.py
takes care
of notifying the user to set their API key.
Let’s try out update.py
. Open a Terminal window at the workflow root directory
and run the following:
python3 update.py
If it works, you should see something like this:
1 21:59:59 workflow.py:855 DEBUG get_password : net.deanishe.alfred-pinboard-recent:pinboard_api_key
2 21:59:59 workflow.py:544 DEBUG Loading cached data from : /Users/dean/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/net.deanishe.alfred-pinboard-recent/posts.cache
3 21:59:59 update.py:111 DEBUG 100 Pinboard posts cached
4 22:19:25 workflow.py:371 INFO Opening workflow log file
As you can see in the 3rd line, update.py
did its job.
Running update.py
from pinboard.py
¶
So now let’s update pinboard.py
to call update.py
instead of doing the
update itself:
1 # encoding: utf-8
2
3 import sys
4 import argparse
5 from workflow import (Workflow, ICON_WEB, ICON_INFO, ICON_WARNING,
6 PasswordNotFound)
7 from workflow.background import run_in_background, is_running
8
9
10 def search_key_for_post(post):
11 """Generate a string search key for a post"""
12 elements = []
13 elements.append(post['description']) # title of post
14 elements.append(post['tags']) # post tags
15 elements.append(post['extended']) # description
16 return ' '.join(elements)
17
18
19 def main(wf):
20
21 # build argument parser to parse script args and collect their
22 # values
23 parser = argparse.ArgumentParser()
24 # add an optional (nargs='?') --apikey argument and save its
25 # value to 'apikey' (dest). This will be called from a separate "Run Script"
26 # action with the API key
27 parser.add_argument('--setkey', dest='apikey', nargs='?', default=None)
28 # add an optional query and save it to 'query'
29 parser.add_argument('query', nargs='?', default=None)
30 # parse the script's arguments
31 args = parser.parse_args(wf.args)
32
33 ####################################################################
34 # Save the provided API key
35 ####################################################################
36
37 # decide what to do based on arguments
38 if args.apikey: # Script was passed an API key
39 # save the key
40 wf.save_password('pinboard_api_key', args.apikey)
41 return 0 # 0 means script exited cleanly
42
43 ####################################################################
44 # Check that we have an API key saved
45 ####################################################################
46
47 try:
48 wf.get_password('pinboard_api_key')
49 except PasswordNotFound: # API key has not yet been set
50 wf.add_item('No API key set.',
51 'Please use pbsetkey to set your Pinboard API key.',
52 valid=False,
53 icon=ICON_WARNING)
54 wf.send_feedback()
55 return 0
56
57 ####################################################################
58 # View/filter Pinboard posts
59 ####################################################################
60
61 query = args.query
62
63 # Get posts from cache. Set `data_func` to None, as we don't want to
64 # update the cache in this script and `max_age` to 0 because we want
65 # the cached data regardless of age
66 posts = wf.cached_data('posts', None, max_age=0)
67
68 # Start update script if cached data are too old (or doesn't exist)
69 if not wf.cached_data_fresh('posts', max_age=600):
70 cmd = ['/usr/bin/env', 'python3', wf.workflowfile('update.py')]
71 run_in_background('update', cmd)
72
73 # Notify the user if the cache is being updated
74 if is_running('update'):
75 wf.add_item('Getting new posts from Pinboard',
76 valid=False,
77 icon=ICON_INFO)
78
79 # If script was passed a query, use it to filter posts if we have some
80 if query and posts:
81 posts = wf.filter(query, posts, key=search_key_for_post, min_score=20)
82
83 if not posts: # we have no data to show, so show a warning and stop
84 wf.add_item('No posts found', icon=ICON_WARNING)
85 wf.send_feedback()
86 return 0
87
88 # Loop through the returned posts and add a item for each to
89 # the list of results for Alfred
90 for post in posts:
91 wf.add_item(title=post['description'],
92 subtitle=post['href'],
93 arg=post['href'],
94 valid=True,
95 icon=ICON_WEB)
96
97 # Send the results to Alfred as XML
98 wf.send_feedback()
99 return 0
100
101
102 if __name__ == u"__main__":
103 wf = Workflow()
104 sys.exit(wf.run(main))
First of all, we’ve changed the imports a bit. We no longer need
workflow.web
, because we’ll use the functions
run_in_background()
from workflow.background
to call update.py
instead, and we’ve also imported another icon
(ICON_INFO
) to show our update message.
As noted before, get_recent_posts()
has now moved to update.py
, as has
the wrapper()
function inside main()
.
Also in main()
, we no longer need api_key
. However, we still want to
know if it has been saved, so we can show a warning if not, so we still call
Workflow.get_password()
, but
without saving the result.
Most importantly, we’ve now expanded the update code to check if our cached
data are fresh with
Workflow.cached_data_fresh()
and to run the update.py
script via
background.run_in_background()
if not (Workflow.workflowfile()
returns the full path to a file in the workflow’s root directory).
Then we check if the update process is running via
background.is_running()
using the
name we assigned to the process (update
), and notify the user via Alfred’s
results if it is.
Finally, we call Workflow.cached_data()
with None
as the data-retrieval function (line 66) because we don’t want to
run an update from this script, blocking Alfred. As a consequence, it’s
possible that we’ll get back None
instead of a list of posts if there are
no cached data, so we check for this before trying to filter None
in line
80.
The fruits of your labour¶
Now let’s give it a spin. Open up Alfred and enter pbrecent
workflow:delcache
to clear the cached data. Then enter pbrecent
and start
typing a query. You should see the “Getting new posts from Pinboard” message
appear. Unfortunately, we won’t see any results at the moment because we just
deleted the cached data.
To see our background updater weave its magic, we can change the max_age
parameter
passed to Workflow.cached_data()
in update.py
on line 42 and to
Workflow.cached_data_fresh()
in pinboard.py
on line 69 to 60
. Open up Alfred, enter pbrecent
and
a couple of letters, then twiddle your thumbs for ~55 seconds. Type another letter
or two and you should see the “Getting new posts…” message and search
results. Cool, huh?
Updating your workflow¶
Software, like plans, never survives contact with the enemy, err, user.
It’s likely that a bug or two will be found and some sweet improvements will be suggested, and so you’ll probably want to release a new and improved version of your workflow somewhere down the line.
Instead of requiring your users to regularly visit a forum thread or a website to check for an update, there are a couple of ways you can have your workflow (semi-)automatically updated.
The Packal Updater¶
The simplest way in terms of implementation is to upload your workflow to Packal.org. If you release a new version, any user who also uses the Packal Updater workflow will then be notified of the updated version. The disadvantage of this method is it only works if a user installs and uses the Packal Updater workflow.
GitHub releases¶
A slightly more complex to implement method is to use Alfred-PyWorkflow’s
built-in support for updates via GitHub releases. If you tell your
Workflow
object the name of your GitHub repo and
the installed workflow’s version number, Alfred-PyWorkflow will automatically
check for a new version every day.
By default, Alfred-PyWorkflow won’t inform the user of the new version or
update the workflow unless the user explicitly uses the workflow:update
“magic” argument, but you can check the
Workflow.update_available
attribute and inform the user of the availability of an update if it’s
True
.
See Self-updating in the User Guide for information on how to enable your workflow to update itself from GitHub.