├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── README.md
├── icon.png
├── icons
├── bitbucket.png
├── github.png
├── gitlab.png
└── repo.png
├── info.plist
└── src
├── browse.py
├── git.py
├── history.py
├── scrape_clipboard.py
└── workflow
├── Notify.tgz
├── __init__.py
├── background.py
├── notify.py
├── update.py
├── util.py
├── version
├── web.py
├── workflow.py
└── workflow3.py
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Build Alfred workflow
16 | id: builder
17 | uses: almibarss/build-alfred-workflow@main
18 | with:
19 | workflow_dir: .
20 | exclude_patterns: .git/* .github/*
21 |
22 | - name: Release
23 | uses: softprops/action-gh-release@v1
24 | with:
25 | files: ${{ steps.builder.outputs.workflow_file }}
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/mperezi/alfred-git-clone/actions?query=workflow%3ARelease)
2 |
3 | # alfred-git-clone
4 |
5 | An [Alfred workflow](https://www.alfredapp.com/workflows/) to clone git repos like a *master* 🐑🐑
6 |
7 | 
8 |
9 | ## Install
10 |
11 | Download the latest version from [Releases](https://github.com/almibarss/alfred-git-clone/releases) and double click the downloaded file to install it.
12 |
13 | ## Usage
14 |
15 | > **Note**: Clipboard history must be enabled in order for us to scrape. Enable this via Alfred Preferences > Features > Clipboard History.
16 |
17 | 1. Copy the repo URL to the clipboard (ending in `.git`).
18 | 2. Open Alfred and start typing the keyword *clone* to trigger the workflow.
19 | 3. Confirm the repo you're about to clone by pressing enter or press ⌥+enter to choose a different name.
20 | 4. Browse your *workspace* and pick a destination folder. Start typing for filtering and then hit tab to drill into the selected folder or enter to clone into it.
21 | 5. Wait until a notification pops up letting you know about the outcome of the operation. In case of success a terminal window will open inside the repo you just cloned.
22 |
23 | ## Features
24 |
25 | ### Forgiveness
26 |
27 | If you carelessly copy over some items on top of your URL your repo will still be found. More specifically the last ten entries of your clipboard are scraped to look for valid git repos.
28 |
29 | ### History
30 |
31 | After some usage you will start seeing your most frequently used directories when browsing your workspace.
32 |
33 | ## Settings
34 |
35 | The following variables are meant to be set in the [workflow configuration sheet](https://www.alfredapp.com/help/workflows/advanced/variables/#environment):
36 |
37 | * `workspace_dir`: the path of the root folder where all your projects live (default:`$HOME`)
38 | * `max_recent_items`: max number of history items to show (default: 2)
39 |
40 | ## Acknowledgements
41 |
42 | This workflow uses the awesome 😎 [Alfred-Workflow](http://www.deanishe.net/alfred-workflow/) Python library by [deanishe](https://www.alfredforum.com/profile/5235-deanishe/).
43 |
44 | Sheep icon provided by [Stockio.com](https://www.stockio.com/).
45 |
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikybars/alfred-git-clone/20874579c509347ab0924666c8669731b4eb880c/icon.png
--------------------------------------------------------------------------------
/icons/bitbucket.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikybars/alfred-git-clone/20874579c509347ab0924666c8669731b4eb880c/icons/bitbucket.png
--------------------------------------------------------------------------------
/icons/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikybars/alfred-git-clone/20874579c509347ab0924666c8669731b4eb880c/icons/github.png
--------------------------------------------------------------------------------
/icons/gitlab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikybars/alfred-git-clone/20874579c509347ab0924666c8669731b4eb880c/icons/gitlab.png
--------------------------------------------------------------------------------
/icons/repo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikybars/alfred-git-clone/20874579c509347ab0924666c8669731b4eb880c/icons/repo.png
--------------------------------------------------------------------------------
/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | bundleid
6 | com.github.almibarss.alfred.clone
7 | category
8 | Productivity
9 | connections
10 |
11 | 08192DEC-8424-4500-8198-C2F4526827E6
12 |
13 |
14 | destinationuid
15 | 3B51BB8D-96DF-4A4D-A743-8B09696F20A5
16 | modifiers
17 | 0
18 | modifiersubtext
19 |
20 | vitoclose
21 |
22 |
23 |
24 | 2288EBFD-99B5-42B1-A4FC-3666A1E0A408
25 |
26 |
27 | destinationuid
28 | C4CBA25F-2064-48D9-8241-99B4BD3621F7
29 | modifiers
30 | 0
31 | modifiersubtext
32 |
33 | vitoclose
34 |
35 |
36 |
37 | 26090269-5DE9-4134-9EAC-6259F44738B0
38 |
39 |
40 | destinationuid
41 | 57E9F230-5FE4-4076-9A81-470297570812
42 | modifiers
43 | 0
44 | modifiersubtext
45 |
46 | vitoclose
47 |
48 |
49 |
50 | 3B51BB8D-96DF-4A4D-A743-8B09696F20A5
51 |
52 |
53 | destinationuid
54 | CD53B59E-FB0B-43B1-BA55-4F37E167C17F
55 | modifiers
56 | 0
57 | modifiersubtext
58 |
59 | vitoclose
60 |
61 |
62 |
63 | 55780938-7DD8-4507-9B3C-567A7ABAACB7
64 |
65 |
66 | destinationuid
67 | 08192DEC-8424-4500-8198-C2F4526827E6
68 | modifiers
69 | 0
70 | modifiersubtext
71 |
72 | vitoclose
73 |
74 |
75 |
76 | 57E9F230-5FE4-4076-9A81-470297570812
77 |
78 |
79 | destinationuid
80 | E0B458E8-83CF-4793-8EB4-3FEA86AE288E
81 | modifiers
82 | 0
83 | modifiersubtext
84 |
85 | sourceoutputuid
86 | D9D1CFBC-376E-4608-9EDC-CFA5347BB9F2
87 | vitoclose
88 |
89 |
90 |
91 | destinationuid
92 | 812E053F-F37B-4A59-B6F7-8B98B04953FC
93 | modifiers
94 | 0
95 | modifiersubtext
96 |
97 | vitoclose
98 |
99 |
100 |
101 | 73193C93-51D9-4972-8C15-29E173B224EE
102 |
103 |
104 | destinationuid
105 | 55780938-7DD8-4507-9B3C-567A7ABAACB7
106 | modifiers
107 | 0
108 | modifiersubtext
109 |
110 | vitoclose
111 |
112 |
113 |
114 | C4CBA25F-2064-48D9-8241-99B4BD3621F7
115 |
116 |
117 | destinationuid
118 | 3B51BB8D-96DF-4A4D-A743-8B09696F20A5
119 | modifiers
120 | 0
121 | modifiersubtext
122 |
123 | vitoclose
124 |
125 |
126 |
127 | destinationuid
128 | 73193C93-51D9-4972-8C15-29E173B224EE
129 | modifiers
130 | 524288
131 | modifiersubtext
132 | Clone with different name
133 | vitoclose
134 |
135 |
136 |
137 | CD53B59E-FB0B-43B1-BA55-4F37E167C17F
138 |
139 |
140 | destinationuid
141 | EF9DBDDA-4C95-4F70-BCD5-341FAC08AAB0
142 | modifiers
143 | 0
144 | modifiersubtext
145 |
146 | vitoclose
147 |
148 |
149 |
150 | destinationuid
151 | 26090269-5DE9-4134-9EAC-6259F44738B0
152 | modifiers
153 | 0
154 | modifiersubtext
155 |
156 | vitoclose
157 |
158 |
159 |
160 | E0B458E8-83CF-4793-8EB4-3FEA86AE288E
161 |
162 |
163 | destinationuid
164 | 321ECACA-9D61-4CBB-9E85-E434A6719431
165 | modifiers
166 | 0
167 | modifiersubtext
168 |
169 | vitoclose
170 |
171 |
172 |
173 | E0CFE6EC-A434-46BC-AA6F-1094A431798A
174 |
175 |
176 | destinationuid
177 | 2288EBFD-99B5-42B1-A4FC-3666A1E0A408
178 | modifiers
179 | 0
180 | modifiersubtext
181 |
182 | vitoclose
183 |
184 |
185 |
186 |
187 | createdby
188 | almibarss
189 | description
190 |
191 | disabled
192 |
193 | name
194 | Clone git repo
195 | objects
196 |
197 |
198 | config
199 |
200 | concurrently
201 |
202 | escaping
203 | 102
204 | script
205 | if [[ -z ${workspace_dir+x} ]]; then
206 | workspace_dir="~"
207 | workspace_undefined=1
208 | elif [[ ! -d ${workspace_dir/#\~/$HOME} ]]; then
209 | workspace_dir="~"
210 | workspace_doesnt_exist=1
211 | fi
212 |
213 |
214 | [[ $workspace_dir =~ /$ ]] || workspace_dir="${workspace_dir}/"
215 |
216 | cat <<EOF
217 | {
218 | "alfredworkflow": {
219 | "arg": "$workspace_dir",
220 | "variables": {
221 | "workspace_undefined": "$workspace_undefined",
222 | "workspace_doesnt_exist": "$workspace_doesnt_exist"
223 | }
224 | }
225 | }
226 | EOF
227 | scriptargtype
228 | 1
229 | scriptfile
230 |
231 | type
232 | 0
233 |
234 | type
235 | alfred.workflow.action.script
236 | uid
237 | 3B51BB8D-96DF-4A4D-A743-8B09696F20A5
238 | version
239 | 2
240 |
241 |
242 | config
243 |
244 | concurrently
245 |
246 | escaping
247 | 102
248 | script
249 | /usr/bin/python src/history.py
250 | scriptargtype
251 | 0
252 | scriptfile
253 |
254 | type
255 | 0
256 |
257 | type
258 | alfred.workflow.action.script
259 | uid
260 | EF9DBDDA-4C95-4F70-BCD5-341FAC08AAB0
261 | version
262 | 2
263 |
264 |
265 | config
266 |
267 | alfredfiltersresults
268 |
269 | alfredfiltersresultsmatchmode
270 | 0
271 | argumenttreatemptyqueryasnil
272 |
273 | argumenttrimmode
274 | 0
275 | argumenttype
276 | 2
277 | escaping
278 | 102
279 | keyword
280 | into
281 | queuedelaycustom
282 | 3
283 | queuedelayimmediatelyinitially
284 |
285 | queuedelaymode
286 | 0
287 | queuemode
288 | 1
289 | runningsubtext
290 |
291 | script
292 | /usr/bin/python src/browse.py "{query}"
293 | scriptargtype
294 | 0
295 | scriptfile
296 |
297 | subtext
298 |
299 | title
300 |
301 | type
302 | 0
303 | withspace
304 |
305 |
306 | type
307 | alfred.workflow.input.scriptfilter
308 | uid
309 | CD53B59E-FB0B-43B1-BA55-4F37E167C17F
310 | version
311 | 3
312 |
313 |
314 | config
315 |
316 | alfredfiltersresults
317 |
318 | alfredfiltersresultsmatchmode
319 | 0
320 | argumenttreatemptyqueryasnil
321 |
322 | argumenttrimmode
323 | 0
324 | argumenttype
325 | 2
326 | escaping
327 | 102
328 | keyword
329 | clone
330 | queuedelaycustom
331 | 3
332 | queuedelayimmediatelyinitially
333 |
334 | queuedelaymode
335 | 0
336 | queuemode
337 | 1
338 | runningsubtext
339 |
340 | script
341 | /usr/bin/python src/scrape_clipboard.py
342 | scriptargtype
343 | 0
344 | scriptfile
345 |
346 | subtext
347 |
348 | title
349 |
350 | type
351 | 0
352 | withspace
353 |
354 |
355 | type
356 | alfred.workflow.input.scriptfilter
357 | uid
358 | C4CBA25F-2064-48D9-8241-99B4BD3621F7
359 | version
360 | 3
361 |
362 |
363 | config
364 |
365 | argumenttype
366 | 2
367 | keyword
368 | clone
369 | subtext
370 | Grab the repo url from the clipboard and browse destination
371 | text
372 | Clone git repo
373 | withspace
374 |
375 |
376 | type
377 | alfred.workflow.input.keyword
378 | uid
379 | E0CFE6EC-A434-46BC-AA6F-1094A431798A
380 | version
381 | 1
382 |
383 |
384 | config
385 |
386 | argument
387 |
388 | passthroughargument
389 |
390 | variables
391 |
392 | clip0
393 | {clipboard:0}
394 | clip1
395 | {clipboard:1}
396 | clip2
397 | {clipboard:2}
398 | clip3
399 | {clipboard:3}
400 | clip4
401 | {clipboard:4}
402 | clip5
403 | {clipboard:5}
404 | clip6
405 | {clipboard:6}
406 | clip7
407 | {clipboard:7}
408 | clip8
409 | {clipboard:8}
410 | clip9
411 | {clipboard:9}
412 |
413 |
414 | type
415 | alfred.workflow.utility.argument
416 | uid
417 | 2288EBFD-99B5-42B1-A4FC-3666A1E0A408
418 | version
419 | 1
420 |
421 |
422 | config
423 |
424 | argumenttype
425 | 0
426 | subtext
427 |
428 | text
429 |
430 | withspace
431 |
432 |
433 | type
434 | alfred.workflow.input.keyword
435 | uid
436 | 55780938-7DD8-4507-9B3C-567A7ABAACB7
437 | version
438 | 1
439 |
440 |
441 | config
442 |
443 | argument
444 | {query}
445 | passthroughargument
446 |
447 | variables
448 |
449 | repo_name
450 | {query}
451 |
452 |
453 | type
454 | alfred.workflow.utility.argument
455 | uid
456 | 08192DEC-8424-4500-8198-C2F4526827E6
457 | version
458 | 1
459 |
460 |
461 | config
462 |
463 | argument
464 | {var:repo_name}
465 | passthroughargument
466 |
467 | variables
468 |
469 |
470 | type
471 | alfred.workflow.utility.argument
472 | uid
473 | 73193C93-51D9-4972-8C15-29E173B224EE
474 | version
475 | 1
476 |
477 |
478 | config
479 |
480 | path
481 | {var:clone_path}
482 |
483 | type
484 | alfred.workflow.action.browseinterminal
485 | uid
486 | 321ECACA-9D61-4CBB-9E85-E434A6719431
487 | version
488 | 1
489 |
490 |
491 | config
492 |
493 | lastpathcomponent
494 |
495 | onlyshowifquerypopulated
496 |
497 | removeextension
498 |
499 | text
500 | Repo cloned to '{var:clone_path}'
501 | title
502 | 🎉🎉🎉
503 |
504 | type
505 | alfred.workflow.output.notification
506 | uid
507 | E0B458E8-83CF-4793-8EB4-3FEA86AE288E
508 | version
509 | 1
510 |
511 |
512 | config
513 |
514 | concurrently
515 |
516 | escaping
517 | 102
518 | script
519 | err=$(git clone -q "$repo_url" "$clone_path" 2>&1)
520 |
521 | if (( $? != 0 )); then
522 | echo $err
523 | fi
524 | scriptargtype
525 | 1
526 | scriptfile
527 |
528 | type
529 | 0
530 |
531 | type
532 | alfred.workflow.action.script
533 | uid
534 | 26090269-5DE9-4134-9EAC-6259F44738B0
535 | version
536 | 2
537 |
538 |
539 | config
540 |
541 | conditions
542 |
543 |
544 | inputstring
545 | {query}
546 | matchcasesensitive
547 |
548 | matchmode
549 | 0
550 | matchstring
551 |
552 | outputlabel
553 | ok
554 | uid
555 | D9D1CFBC-376E-4608-9EDC-CFA5347BB9F2
556 |
557 |
558 | elselabel
559 | fail
560 |
561 | type
562 | alfred.workflow.utility.conditional
563 | uid
564 | 57E9F230-5FE4-4076-9A81-470297570812
565 | version
566 | 1
567 |
568 |
569 | config
570 |
571 | lastpathcomponent
572 |
573 | onlyshowifquerypopulated
574 |
575 | removeextension
576 |
577 | text
578 | {query}
579 | title
580 | 😭 Clone failed
581 |
582 | type
583 | alfred.workflow.output.notification
584 | uid
585 | 812E053F-F37B-4A59-B6F7-8B98B04953FC
586 | version
587 | 1
588 |
589 |
590 | readme
591 |
592 | uidata
593 |
594 | 08192DEC-8424-4500-8198-C2F4526827E6
595 |
596 | xpos
597 | 720
598 | ypos
599 | 235
600 |
601 | 2288EBFD-99B5-42B1-A4FC-3666A1E0A408
602 |
603 | note
604 | set clipboard vars
605 | xpos
606 | 205
607 | ypos
608 | 85
609 |
610 | 26090269-5DE9-4134-9EAC-6259F44738B0
611 |
612 | note
613 | GIT CLONE
614 | xpos
615 | 925
616 | ypos
617 | 275
618 |
619 | 321ECACA-9D61-4CBB-9E85-E434A6719431
620 |
621 | xpos
622 | 1395
623 | ypos
624 | 240
625 |
626 | 3B51BB8D-96DF-4A4D-A743-8B09696F20A5
627 |
628 | note
629 | feed workspace dir
630 | xpos
631 | 845
632 | ypos
633 | 55
634 |
635 | 55780938-7DD8-4507-9B3C-567A7ABAACB7
636 |
637 | note
638 | enter new name for repo
639 | xpos
640 | 565
641 | ypos
642 | 205
643 |
644 | 57E9F230-5FE4-4076-9A81-470297570812
645 |
646 | xpos
647 | 1130
648 | ypos
649 | 315
650 |
651 | 73193C93-51D9-4972-8C15-29E173B224EE
652 |
653 | xpos
654 | 495
655 | ypos
656 | 235
657 |
658 | 812E053F-F37B-4A59-B6F7-8B98B04953FC
659 |
660 | colorindex
661 | 1
662 | xpos
663 | 1225
664 | ypos
665 | 355
666 |
667 | C4CBA25F-2064-48D9-8241-99B4BD3621F7
668 |
669 | note
670 | find valid urls in the clipboard
671 | xpos
672 | 305
673 | ypos
674 | 55
675 |
676 | CD53B59E-FB0B-43B1-BA55-4F37E167C17F
677 |
678 | note
679 | browse target dir
680 | xpos
681 | 1015
682 | ypos
683 | 55
684 |
685 | E0B458E8-83CF-4793-8EB4-3FEA86AE288E
686 |
687 | colorindex
688 | 4
689 | xpos
690 | 1225
691 | ypos
692 | 240
693 |
694 | E0CFE6EC-A434-46BC-AA6F-1094A431798A
695 |
696 | xpos
697 | 30
698 | ypos
699 | 55
700 |
701 | EF9DBDDA-4C95-4F70-BCD5-341FAC08AAB0
702 |
703 | note
704 | update history
705 | xpos
706 | 1230
707 | ypos
708 | 55
709 |
710 |
711 | variables
712 |
713 | workspace_dir
714 | ~/projects
715 |
716 | variablesdontexport
717 |
718 | version
719 | 1.0
720 | webaddress
721 | https://github.com/almibarss/alfred-git-clone
722 |
723 |
724 |
--------------------------------------------------------------------------------
/src/browse.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | # encoding: utf-8
3 |
4 | import os, sys
5 | from base64 import b64encode
6 | import git, history
7 |
8 | from workflow import Workflow3, ICON_WARNING, ICON_ERROR, ICON_CLOCK
9 |
10 |
11 | log = None
12 |
13 | def isdir(d):
14 | return os.path.isdir(os.path.expanduser(d))
15 |
16 | def by_repo_name(item):
17 | return item['title']
18 |
19 | def join_dirs(dir1, dir2):
20 | joint_path = os.path.join(dir1, dir2)
21 | return os.path.join(joint_path, '') # append the trailing slash if necessary
22 |
23 | def hidden(dir):
24 | return dir.startswith('.')
25 |
26 | def make_wf_item(parent_dir, subdir):
27 | full_path = join_dirs(parent_dir, subdir)
28 |
29 | item = { 'title': subdir,
30 | 'autocomplete': full_path,
31 | 'icon': 'public.folder',
32 | 'icontype': 'filetype' }
33 |
34 | if git.dir_is_a_git_repo(full_path):
35 | remote_url = git.get_remote_url(full_path)
36 | item['subtitle'] = remote_url
37 | item['icon'] = git.get_icon_from_url(remote_url)
38 | item['icontype'] = None
39 |
40 | return item
41 |
42 | def listdir(dir):
43 | canonical_path = os.path.expanduser(dir) # handle ~
44 | root, subdirs, files = next(os.walk(canonical_path))
45 | return [make_wf_item(dir, subdir)
46 | for subdir in sorted(subdirs, key=lambda s: s.lower())
47 | if not hidden(subdir)]
48 |
49 | def listdir_cached(wf, dir):
50 | canonical_path = os.path.expanduser(dir) # handle ~
51 | cache_key = b64encode(canonical_path)
52 | return wf.cached_data(cache_key, lambda: listdir(dir), max_age=30)
53 |
54 | def split_path_and_submatch(input):
55 | separator = input.rindex('/')
56 | return input[:separator], input[separator+1:]
57 |
58 | def check_workspace_dir(wf):
59 | if os.getenv('workspace_undefined', default='0') == '1':
60 | wf.add_item(title='Workspace not defined',
61 | subtitle="Set the 'workspace_dir' in the workflow configuration sheet",
62 | icon=ICON_WARNING)
63 | elif os.getenv('workspace_doesnt_exist', default='0') == '1':
64 | wf.add_item(title='Workspace not valid',
65 | subtitle="Path '{}' does not exist or is not a directory".format(os.getenv('workspace_dir')),
66 | icon=ICON_ERROR)
67 |
68 | def add_result(wf, target_dir, title, icon=None, subtitle='', autocomplete=None, icontype=None):
69 | item = wf.add_item(title=title, subtitle=subtitle, autocomplete=autocomplete, icon=icon, icontype=icontype, valid=True)
70 | item.setvar('target_dir', target_dir)
71 | item.setvar('clone_path', os.path.join(os.path.expanduser(target_dir), os.getenv('repo_name')))
72 |
73 | def check_history(wf):
74 | max_recent_items = int(os.getenv('max_recent_items', 2))
75 | for recent_dir in history.get_top_ranked(wf, max_recent_items):
76 | add_result(wf, recent_dir, title=recent_dir, icon=ICON_CLOCK)
77 |
78 | def first_run_checks(wf):
79 | if 'rerun' not in os.environ:
80 | check_workspace_dir(wf)
81 | check_history(wf)
82 | wf.setvar('rerun', 'true')
83 |
84 | def main(wf):
85 | first_run_checks(wf)
86 |
87 | query = wf.args[0]
88 | wf_items = []
89 | if isdir(query):
90 | add_result(wf, target_dir=query, title='Clone here', icon=None, subtitle=query)
91 | wf_items = listdir_cached(wf, query)
92 | elif '/' in query:
93 | path, submatch = split_path_and_submatch(query)
94 | wf_items = listdir_cached(wf, path)
95 | wf_items = wf.filter(submatch, wf_items, by_repo_name)
96 |
97 | for item in wf_items:
98 | add_result(wf, target_dir=item['autocomplete'], **item)
99 |
100 | wf.send_feedback()
101 |
102 |
103 | if __name__ == '__main__':
104 | wf = Workflow3()
105 | log = wf.logger
106 | sys.exit(wf.run(main))
107 |
--------------------------------------------------------------------------------
/src/git.py:
--------------------------------------------------------------------------------
1 | import os, re, subprocess
2 |
3 | GIT_REPO_URL_PATTERN = '^((https|ssh)://|git@).*/(?P.*)\.git'
4 | ICONS_PATH = 'icons'
5 |
6 | def is_a_valid_git_url(url):
7 | return re.match(GIT_REPO_URL_PATTERN, url)
8 |
9 | def get_repo_name_from_url(url):
10 | return re.search(GIT_REPO_URL_PATTERN, url).group('repo')
11 |
12 | def dir_is_a_git_repo(path):
13 | with open(os.devnull, 'w') as devnull:
14 | git_cmd = "git rev-parse --is-inside-work-tree"
15 | p = subprocess.Popen(git_cmd.split(), cwd=os.path.expanduser(path),
16 | stdout=devnull, stderr=devnull)
17 | p.wait()
18 | return p.returncode == 0
19 |
20 | def get_remote_url(path):
21 | with open(os.devnull, 'w') as devnull:
22 | git_cmd = "git config --get remote.origin.url"
23 | p = subprocess.Popen(git_cmd.split(), cwd=os.path.expanduser(path),
24 | stdout=subprocess.PIPE, stderr=devnull)
25 | stdout, stderr = p.communicate()
26 | return stdout
27 |
28 | def icon(name):
29 | return os.path.join(ICONS_PATH, name + '.png')
30 |
31 | def get_icon_from_url(url):
32 | if 'github.com' in url:
33 | return icon('github')
34 | elif 'bitbucket' in url:
35 | return icon('bitbucket')
36 | elif 'gitlab' in url:
37 | return icon('gitlab')
38 | else:
39 | return icon('repo')
40 |
--------------------------------------------------------------------------------
/src/history.py:
--------------------------------------------------------------------------------
1 | HISTORY_HITS_THRESHOLD = 2
2 |
3 | from collections import defaultdict
4 | from pprint import pformat
5 | from workflow import Workflow
6 | import os, sys
7 |
8 | def get_top_ranked(wf, max_results):
9 | log = wf.logger
10 | history = wf.stored_data('history')
11 | if history == None:
12 | return []
13 | rank_by_use = [dir for dir in sorted(history, key=history.get, reverse=True)
14 | if history[dir] >= HISTORY_HITS_THRESHOLD]
15 |
16 | log.debug("Rank by use:\n" + pformat({dir:history[dir] for dir in rank_by_use[:10]}))
17 |
18 | return rank_by_use[:max_results]
19 |
20 | def increment(wf, dir):
21 | log = wf.logger
22 | history = wf.stored_data('history')
23 | if history == None:
24 | history = defaultdict(int)
25 |
26 | history[dir] += 1
27 | wf.store_data('history', history)
28 |
29 | log.debug("New usage of '{}' ({})".format(dir, history[dir]))
30 |
31 | def update(wf):
32 | target_dir = os.getenv('target_dir')
33 | increment(wf, target_dir)
34 |
35 |
36 | if __name__ == '__main__':
37 | wf = Workflow()
38 | log = wf.logger
39 | sys.exit(wf.run(update))
40 |
--------------------------------------------------------------------------------
/src/scrape_clipboard.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | # encoding: utf-8
3 |
4 | import os, sys
5 | import git
6 |
7 | from pprint import pformat
8 | from workflow import Workflow3
9 |
10 |
11 | log = None
12 |
13 |
14 | def clipboard_items():
15 | return [os.getenv(clip(x))
16 | for x in range(0, 100)
17 | if clip(x) in os.environ]
18 |
19 | def clip(x):
20 | return 'clip' + str(x)
21 |
22 | def make_wf_item(repo_url):
23 | repo = git.get_repo_name_from_url(repo_url)
24 | return {'title': repo,
25 | 'subtitle': repo_url,
26 | 'autocomplete': repo,
27 | 'icon': git.get_icon_from_url(repo_url),
28 | 'valid': True}
29 |
30 | def main(wf):
31 | wf_items = [make_wf_item(clip_item)
32 | for clip_item in clipboard_items()
33 | if git.is_a_valid_git_url(clip_item)]
34 |
35 | log.debug('Git repos from clipboard:\n' + pformat(wf_items))
36 |
37 | for item in wf_items:
38 | wf_item = wf.add_item(**item)
39 | wf_item.setvar('repo_name', item['title'])
40 | wf_item.setvar('repo_url', item['subtitle'])
41 |
42 | wf.warn_empty('No git repos found in the clipboard')
43 | wf.send_feedback()
44 |
45 |
46 | if __name__ == '__main__':
47 | wf = Workflow3()
48 | log = wf.logger
49 | sys.exit(wf.run(main))
50 |
--------------------------------------------------------------------------------
/src/workflow/Notify.tgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikybars/alfred-git-clone/20874579c509347ab0924666c8669731b4eb880c/src/workflow/Notify.tgz
--------------------------------------------------------------------------------
/src/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-2019 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 |
--------------------------------------------------------------------------------
/src/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 | """This module provides an API to run commands in background processes.
12 |
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 | os.unlink(pidfile)
106 |
107 |
108 | def is_running(name):
109 | """Test whether task ``name`` is currently running.
110 |
111 | :param name: name of task
112 | :type name: unicode
113 | :returns: ``True`` if task with name ``name`` is running, else ``False``
114 | :rtype: bool
115 |
116 | """
117 | if _job_pid(name) is not None:
118 | return True
119 |
120 | return False
121 |
122 |
123 | def _background(pidfile, stdin='/dev/null', stdout='/dev/null',
124 | stderr='/dev/null'): # pragma: no cover
125 | """Fork the current process into a background daemon.
126 |
127 | :param pidfile: file to write PID of daemon process to.
128 | :type pidfile: filepath
129 | :param stdin: where to read input
130 | :type stdin: filepath
131 | :param stdout: where to write stdout output
132 | :type stdout: filepath
133 | :param stderr: where to write stderr output
134 | :type stderr: filepath
135 |
136 | """
137 | def _fork_and_exit_parent(errmsg, wait=False, write=False):
138 | try:
139 | pid = os.fork()
140 | if pid > 0:
141 | if write: # write PID of child process to `pidfile`
142 | tmp = pidfile + '.tmp'
143 | with open(tmp, 'wb') as fp:
144 | fp.write(str(pid))
145 | os.rename(tmp, pidfile)
146 | if wait: # wait for child process to exit
147 | os.waitpid(pid, 0)
148 | os._exit(0)
149 | except OSError as err:
150 | _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror)
151 | raise err
152 |
153 | # Do first fork and wait for second fork to finish.
154 | _fork_and_exit_parent('fork #1 failed', wait=True)
155 |
156 | # Decouple from parent environment.
157 | os.chdir(wf().workflowdir)
158 | os.setsid()
159 |
160 | # Do second fork and write PID to pidfile.
161 | _fork_and_exit_parent('fork #2 failed', write=True)
162 |
163 | # Now I am a daemon!
164 | # Redirect standard file descriptors.
165 | si = open(stdin, 'r', 0)
166 | so = open(stdout, 'a+', 0)
167 | se = open(stderr, 'a+', 0)
168 | if hasattr(sys.stdin, 'fileno'):
169 | os.dup2(si.fileno(), sys.stdin.fileno())
170 | if hasattr(sys.stdout, 'fileno'):
171 | os.dup2(so.fileno(), sys.stdout.fileno())
172 | if hasattr(sys.stderr, 'fileno'):
173 | os.dup2(se.fileno(), sys.stderr.fileno())
174 |
175 |
176 | def kill(name, sig=signal.SIGTERM):
177 | """Send a signal to job ``name`` via :func:`os.kill`.
178 |
179 | .. versionadded:: 1.29
180 |
181 | Args:
182 | name (str): Name of the job
183 | sig (int, optional): Signal to send (default: SIGTERM)
184 |
185 | Returns:
186 | bool: `False` if job isn't running, `True` if signal was sent.
187 | """
188 | pid = _job_pid(name)
189 | if pid is None:
190 | return False
191 |
192 | os.kill(pid, sig)
193 | return True
194 |
195 |
196 | def run_in_background(name, args, **kwargs):
197 | r"""Cache arguments then call this script again via :func:`subprocess.call`.
198 |
199 | :param name: name of job
200 | :type name: unicode
201 | :param args: arguments passed as first argument to :func:`subprocess.call`
202 | :param \**kwargs: keyword arguments to :func:`subprocess.call`
203 | :returns: exit code of sub-process
204 | :rtype: int
205 |
206 | When you call this function, it caches its arguments and then calls
207 | ``background.py`` in a subprocess. The Python subprocess will load the
208 | cached arguments, fork into the background, and then run the command you
209 | specified.
210 |
211 | This function will return as soon as the ``background.py`` subprocess has
212 | forked, returning the exit code of *that* process (i.e. not of the command
213 | you're trying to run).
214 |
215 | If that process fails, an error will be written to the log file.
216 |
217 | If a process is already running under the same name, this function will
218 | return immediately and will not run the specified command.
219 |
220 | """
221 | if is_running(name):
222 | _log().info('[%s] job already running', name)
223 | return
224 |
225 | argcache = _arg_cache(name)
226 |
227 | # Cache arguments
228 | with open(argcache, 'wb') as fp:
229 | pickle.dump({'args': args, 'kwargs': kwargs}, fp)
230 | _log().debug('[%s] command cached: %s', name, argcache)
231 |
232 | # Call this script
233 | cmd = ['/usr/bin/python', __file__, name]
234 | _log().debug('[%s] passing job to background runner: %r', name, cmd)
235 | retcode = subprocess.call(cmd)
236 |
237 | if retcode: # pragma: no cover
238 | _log().error('[%s] background runner failed with %d', name, retcode)
239 | else:
240 | _log().debug('[%s] background job started', name)
241 |
242 | return retcode
243 |
244 |
245 | def main(wf): # pragma: no cover
246 | """Run command in a background process.
247 |
248 | Load cached arguments, fork into background, then call
249 | :meth:`subprocess.call` with cached arguments.
250 |
251 | """
252 | log = wf.logger
253 | name = wf.args[0]
254 | argcache = _arg_cache(name)
255 | if not os.path.exists(argcache):
256 | msg = '[{0}] command cache not found: {1}'.format(name, argcache)
257 | log.critical(msg)
258 | raise IOError(msg)
259 |
260 | # Fork to background and run command
261 | pidfile = _pid_file(name)
262 | _background(pidfile)
263 |
264 | # Load cached arguments
265 | with open(argcache, 'rb') as fp:
266 | data = pickle.load(fp)
267 |
268 | # Cached arguments
269 | args = data['args']
270 | kwargs = data['kwargs']
271 |
272 | # Delete argument cache file
273 | os.unlink(argcache)
274 |
275 | try:
276 | # Run the command
277 | log.debug('[%s] running command: %r', name, args)
278 |
279 | retcode = subprocess.call(args, **kwargs)
280 |
281 | if retcode:
282 | log.error('[%s] command failed with status %d', name, retcode)
283 | finally:
284 | os.unlink(pidfile)
285 |
286 | log.debug('[%s] job complete', name)
287 |
288 |
289 | if __name__ == '__main__': # pragma: no cover
290 | wf().run(main)
291 |
--------------------------------------------------------------------------------
/src/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.
15 |
16 | This feature is only available on Mountain Lion (10.8) and later.
17 | It will silently fail on older systems.
18 |
19 | The main API is a single function, :func:`~workflow.notify.notify`.
20 |
21 | It works by copying a simple application to your workflow's data
22 | directory. It replaces the application's icon with your workflow's
23 | icon and then calls the application to post notifications.
24 | """
25 |
26 | from __future__ import print_function, unicode_literals
27 |
28 | import os
29 | import plistlib
30 | import shutil
31 | import subprocess
32 | import sys
33 | import tarfile
34 | import tempfile
35 | import uuid
36 |
37 | import workflow
38 |
39 |
40 | _wf = None
41 | _log = None
42 |
43 |
44 | #: Available system sounds from System Preferences > Sound > Sound Effects
45 | SOUNDS = (
46 | 'Basso',
47 | 'Blow',
48 | 'Bottle',
49 | 'Frog',
50 | 'Funk',
51 | 'Glass',
52 | 'Hero',
53 | 'Morse',
54 | 'Ping',
55 | 'Pop',
56 | 'Purr',
57 | 'Sosumi',
58 | 'Submarine',
59 | 'Tink',
60 | )
61 |
62 |
63 | def wf():
64 | """Return Workflow object for this module.
65 |
66 | Returns:
67 | workflow.Workflow: Workflow object for current workflow.
68 | """
69 | global _wf
70 | if _wf is None:
71 | _wf = workflow.Workflow()
72 | return _wf
73 |
74 |
75 | def log():
76 | """Return logger for this module.
77 |
78 | Returns:
79 | logging.Logger: Logger for this module.
80 | """
81 | global _log
82 | if _log is None:
83 | _log = wf().logger
84 | return _log
85 |
86 |
87 | def notifier_program():
88 | """Return path to notifier applet executable.
89 |
90 | Returns:
91 | unicode: Path to Notify.app ``applet`` executable.
92 | """
93 | return wf().datafile('Notify.app/Contents/MacOS/applet')
94 |
95 |
96 | def notifier_icon_path():
97 | """Return path to icon file in installed Notify.app.
98 |
99 | Returns:
100 | unicode: Path to ``applet.icns`` within the app bundle.
101 | """
102 | return wf().datafile('Notify.app/Contents/Resources/applet.icns')
103 |
104 |
105 | def install_notifier():
106 | """Extract ``Notify.app`` from the workflow to data directory.
107 |
108 | Changes the bundle ID of the installed app and gives it the
109 | workflow's icon.
110 | """
111 | archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz')
112 | destdir = wf().datadir
113 | app_path = os.path.join(destdir, 'Notify.app')
114 | n = notifier_program()
115 | log().debug('installing Notify.app to %r ...', destdir)
116 | # z = zipfile.ZipFile(archive, 'r')
117 | # z.extractall(destdir)
118 | tgz = tarfile.open(archive, 'r:gz')
119 | tgz.extractall(destdir)
120 | if not os.path.exists(n): # pragma: nocover
121 | raise RuntimeError('Notify.app could not be installed in ' + destdir)
122 |
123 | # Replace applet icon
124 | icon = notifier_icon_path()
125 | workflow_icon = wf().workflowfile('icon.png')
126 | if os.path.exists(icon):
127 | os.unlink(icon)
128 |
129 | png_to_icns(workflow_icon, icon)
130 |
131 | # Set file icon
132 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually,
133 | # none of this code will "work" on pre-10.8 systems. Let it run
134 | # until I figure out a better way of excluding this module
135 | # from coverage in py2.6.
136 | if sys.version_info >= (2, 7): # pragma: no cover
137 | from AppKit import NSWorkspace, NSImage
138 |
139 | ws = NSWorkspace.sharedWorkspace()
140 | img = NSImage.alloc().init()
141 | img.initWithContentsOfFile_(icon)
142 | ws.setIcon_forFile_options_(img, app_path, 0)
143 |
144 | # Change bundle ID of installed app
145 | ip_path = os.path.join(app_path, 'Contents/Info.plist')
146 | bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex)
147 | data = plistlib.readPlist(ip_path)
148 | log().debug('changing bundle ID to %r', bundle_id)
149 | data['CFBundleIdentifier'] = bundle_id
150 | plistlib.writePlist(data, ip_path)
151 |
152 |
153 | def validate_sound(sound):
154 | """Coerce ``sound`` to valid sound name.
155 |
156 | Returns ``None`` for invalid sounds. Sound names can be found
157 | in ``System Preferences > Sound > Sound Effects``.
158 |
159 | Args:
160 | sound (str): Name of system sound.
161 |
162 | Returns:
163 | str: Proper name of sound or ``None``.
164 | """
165 | if not sound:
166 | return None
167 |
168 | # Case-insensitive comparison of `sound`
169 | if sound.lower() in [s.lower() for s in SOUNDS]:
170 | # Title-case is correct for all system sounds as of macOS 10.11
171 | return sound.title()
172 | return None
173 |
174 |
175 | def notify(title='', text='', sound=None):
176 | """Post notification via Notify.app helper.
177 |
178 | Args:
179 | title (str, optional): Notification title.
180 | text (str, optional): Notification body text.
181 | sound (str, optional): Name of sound to play.
182 |
183 | Raises:
184 | ValueError: Raised if both ``title`` and ``text`` are empty.
185 |
186 | Returns:
187 | bool: ``True`` if notification was posted, else ``False``.
188 | """
189 | if title == text == '':
190 | raise ValueError('Empty notification')
191 |
192 | sound = validate_sound(sound) or ''
193 |
194 | n = notifier_program()
195 |
196 | if not os.path.exists(n):
197 | install_notifier()
198 |
199 | env = os.environ.copy()
200 | enc = 'utf-8'
201 | env['NOTIFY_TITLE'] = title.encode(enc)
202 | env['NOTIFY_MESSAGE'] = text.encode(enc)
203 | env['NOTIFY_SOUND'] = sound.encode(enc)
204 | cmd = [n]
205 | retcode = subprocess.call(cmd, env=env)
206 | if retcode == 0:
207 | return True
208 |
209 | log().error('Notify.app exited with status {0}.'.format(retcode))
210 | return False
211 |
212 |
213 | def convert_image(inpath, outpath, size):
214 | """Convert an image file using ``sips``.
215 |
216 | Args:
217 | inpath (str): Path of source file.
218 | outpath (str): Path to destination file.
219 | size (int): Width and height of destination image in pixels.
220 |
221 | Raises:
222 | RuntimeError: Raised if ``sips`` exits with non-zero status.
223 | """
224 | cmd = [
225 | b'sips',
226 | b'-z', str(size), str(size),
227 | inpath,
228 | b'--out', outpath]
229 | # log().debug(cmd)
230 | with open(os.devnull, 'w') as pipe:
231 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
232 |
233 | if retcode != 0:
234 | raise RuntimeError('sips exited with %d' % retcode)
235 |
236 |
237 | def png_to_icns(png_path, icns_path):
238 | """Convert PNG file to ICNS using ``iconutil``.
239 |
240 | Create an iconset from the source PNG file. Generate PNG files
241 | in each size required by macOS, then call ``iconutil`` to turn
242 | them into a single ICNS file.
243 |
244 | Args:
245 | png_path (str): Path to source PNG file.
246 | icns_path (str): Path to destination ICNS file.
247 |
248 | Raises:
249 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail.
250 | """
251 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir)
252 |
253 | try:
254 | iconset = os.path.join(tempdir, 'Icon.iconset')
255 |
256 | if os.path.exists(iconset): # pragma: nocover
257 | raise RuntimeError('iconset already exists: ' + iconset)
258 |
259 | os.makedirs(iconset)
260 |
261 | # Copy source icon to icon set and generate all the other
262 | # sizes needed
263 | configs = []
264 | for i in (16, 32, 128, 256, 512):
265 | configs.append(('icon_{0}x{0}.png'.format(i), i))
266 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2)))
267 |
268 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png'))
269 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png'))
270 |
271 | for name, size in configs:
272 | outpath = os.path.join(iconset, name)
273 | if os.path.exists(outpath):
274 | continue
275 | convert_image(png_path, outpath, size)
276 |
277 | cmd = [
278 | b'iconutil',
279 | b'-c', b'icns',
280 | b'-o', icns_path,
281 | iconset]
282 |
283 | retcode = subprocess.call(cmd)
284 | if retcode != 0:
285 | raise RuntimeError('iconset exited with %d' % retcode)
286 |
287 | if not os.path.exists(icns_path): # pragma: nocover
288 | raise ValueError(
289 | 'generated ICNS file not found: ' + repr(icns_path))
290 | finally:
291 | try:
292 | shutil.rmtree(tempdir)
293 | except OSError: # pragma: no cover
294 | pass
295 |
296 |
297 | if __name__ == '__main__': # pragma: nocover
298 | # Simple command-line script to test module with
299 | # This won't work on 2.6, as `argparse` isn't available
300 | # by default.
301 | import argparse
302 |
303 | from unicodedata import normalize
304 |
305 | def ustr(s):
306 | """Coerce `s` to normalised Unicode."""
307 | return normalize('NFD', s.decode('utf-8'))
308 |
309 | p = argparse.ArgumentParser()
310 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.")
311 | p.add_argument('-l', '--list-sounds', help="Show available sounds.",
312 | action='store_true')
313 | p.add_argument('-t', '--title',
314 | help="Notification title.", type=ustr,
315 | default='')
316 | p.add_argument('-s', '--sound', type=ustr,
317 | help="Optional notification sound.", default='')
318 | p.add_argument('text', type=ustr,
319 | help="Notification body text.", default='', nargs='?')
320 | o = p.parse_args()
321 |
322 | # List available sounds
323 | if o.list_sounds:
324 | for sound in SOUNDS:
325 | print(sound)
326 | sys.exit(0)
327 |
328 | # Convert PNG to ICNS
329 | if o.png:
330 | icns = os.path.join(
331 | os.path.dirname(o.png),
332 | os.path.splitext(os.path.basename(o.png))[0] + '.icns')
333 |
334 | print('converting {0!r} to {1!r} ...'.format(o.png, icns),
335 | file=sys.stderr)
336 |
337 | if os.path.exists(icns):
338 | raise ValueError('destination file already exists: ' + icns)
339 |
340 | png_to_icns(o.png, icns)
341 | sys.exit(0)
342 |
343 | # Post notification
344 | if o.title == o.text == '':
345 | print('ERROR: empty notification.', file=sys.stderr)
346 | sys.exit(1)
347 | else:
348 | notify(o.title, o.text, o.sound)
349 |
--------------------------------------------------------------------------------
/src/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 | from collections import defaultdict
27 | from functools import total_ordering
28 | import json
29 | import os
30 | import tempfile
31 | import re
32 | import subprocess
33 |
34 | import workflow
35 | import web
36 |
37 | # __all__ = []
38 |
39 |
40 | RELEASES_BASE = 'https://api.github.com/repos/{}/releases'
41 | match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search
42 |
43 | _wf = None
44 |
45 |
46 | def wf():
47 | """Lazy `Workflow` object."""
48 | global _wf
49 | if _wf is None:
50 | _wf = workflow.Workflow()
51 | return _wf
52 |
53 |
54 | @total_ordering
55 | class Download(object):
56 | """A workflow file that is available for download.
57 |
58 | .. versionadded: 1.37
59 |
60 | Attributes:
61 | url (str): URL of workflow file.
62 | filename (str): Filename of workflow file.
63 | version (Version): Semantic version of workflow.
64 | prerelease (bool): Whether version is a pre-release.
65 | alfred_version (Version): Minimum compatible version
66 | of Alfred.
67 |
68 | """
69 |
70 | @classmethod
71 | def from_dict(cls, d):
72 | """Create a `Download` from a `dict`."""
73 | return cls(url=d['url'], filename=d['filename'],
74 | version=Version(d['version']),
75 | prerelease=d['prerelease'])
76 |
77 | @classmethod
78 | def from_releases(cls, js):
79 | """Extract downloads from GitHub releases.
80 |
81 | Searches releases with semantic tags for assets with
82 | file extension .alfredworkflow or .alfredXworkflow where
83 | X is a number.
84 |
85 | Files are returned sorted by latest version first. Any
86 | releases containing multiple files with the same (workflow)
87 | extension are rejected as ambiguous.
88 |
89 | Args:
90 | js (str): JSON response from GitHub's releases endpoint.
91 |
92 | Returns:
93 | list: Sequence of `Download`.
94 | """
95 | releases = json.loads(js)
96 | downloads = []
97 | for release in releases:
98 | tag = release['tag_name']
99 | dupes = defaultdict(int)
100 | try:
101 | version = Version(tag)
102 | except ValueError as err:
103 | wf().logger.debug('ignored release: bad version "%s": %s',
104 | tag, err)
105 | continue
106 |
107 | dls = []
108 | for asset in release.get('assets', []):
109 | url = asset.get('browser_download_url')
110 | filename = os.path.basename(url)
111 | m = match_workflow(filename)
112 | if not m:
113 | wf().logger.debug('unwanted file: %s', filename)
114 | continue
115 |
116 | ext = m.group(0)
117 | dupes[ext] = dupes[ext] + 1
118 | dls.append(Download(url, filename, version,
119 | release['prerelease']))
120 |
121 | valid = True
122 | for ext, n in dupes.items():
123 | if n > 1:
124 | wf().logger.debug('ignored release "%s": multiple assets '
125 | 'with extension "%s"', tag, ext)
126 | valid = False
127 | break
128 |
129 | if valid:
130 | downloads.extend(dls)
131 |
132 | downloads.sort(reverse=True)
133 | return downloads
134 |
135 | def __init__(self, url, filename, version, prerelease=False):
136 | """Create a new Download.
137 |
138 | Args:
139 | url (str): URL of workflow file.
140 | filename (str): Filename of workflow file.
141 | version (Version): Version of workflow.
142 | prerelease (bool, optional): Whether version is
143 | pre-release. Defaults to False.
144 |
145 | """
146 | if isinstance(version, basestring):
147 | version = Version(version)
148 |
149 | self.url = url
150 | self.filename = filename
151 | self.version = version
152 | self.prerelease = prerelease
153 |
154 | @property
155 | def alfred_version(self):
156 | """Minimum Alfred version based on filename extension."""
157 | m = match_workflow(self.filename)
158 | if not m or not m.group(1):
159 | return Version('0')
160 | return Version(m.group(1))
161 |
162 | @property
163 | def dict(self):
164 | """Convert `Download` to `dict`."""
165 | return dict(url=self.url, filename=self.filename,
166 | version=str(self.version), prerelease=self.prerelease)
167 |
168 | def __str__(self):
169 | """Format `Download` for printing."""
170 | u = ('Download(url={dl.url!r}, '
171 | 'filename={dl.filename!r}, '
172 | 'version={dl.version!r}, '
173 | 'prerelease={dl.prerelease!r})'.format(dl=self))
174 |
175 | return u.encode('utf-8')
176 |
177 | def __repr__(self):
178 | """Code-like representation of `Download`."""
179 | return str(self)
180 |
181 | def __eq__(self, other):
182 | """Compare Downloads based on version numbers."""
183 | if self.url != other.url \
184 | or self.filename != other.filename \
185 | or self.version != other.version \
186 | or self.prerelease != other.prerelease:
187 | return False
188 | return True
189 |
190 | def __ne__(self, other):
191 | """Compare Downloads based on version numbers."""
192 | return not self.__eq__(other)
193 |
194 | def __lt__(self, other):
195 | """Compare Downloads based on version numbers."""
196 | if self.version != other.version:
197 | return self.version < other.version
198 | return self.alfred_version < other.alfred_version
199 |
200 |
201 | class Version(object):
202 | """Mostly semantic versioning.
203 |
204 | The main difference to proper :ref:`semantic versioning `
205 | is that this implementation doesn't require a minor or patch version.
206 |
207 | Version strings may also be prefixed with "v", e.g.:
208 |
209 | >>> v = Version('v1.1.1')
210 | >>> v.tuple
211 | (1, 1, 1, '')
212 |
213 | >>> v = Version('2.0')
214 | >>> v.tuple
215 | (2, 0, 0, '')
216 |
217 | >>> Version('3.1-beta').tuple
218 | (3, 1, 0, 'beta')
219 |
220 | >>> Version('1.0.1') > Version('0.0.1')
221 | True
222 | """
223 |
224 | #: Match version and pre-release/build information in version strings
225 | match_version = re.compile(r'([0-9][0-9\.]*)(.+)?').match
226 |
227 | def __init__(self, vstr):
228 | """Create new `Version` object.
229 |
230 | Args:
231 | vstr (basestring): Semantic version string.
232 | """
233 | if not vstr:
234 | raise ValueError('invalid version number: {!r}'.format(vstr))
235 |
236 | self.vstr = vstr
237 | self.major = 0
238 | self.minor = 0
239 | self.patch = 0
240 | self.suffix = ''
241 | self.build = ''
242 | self._parse(vstr)
243 |
244 | def _parse(self, vstr):
245 | if vstr.startswith('v'):
246 | m = self.match_version(vstr[1:])
247 | else:
248 | m = self.match_version(vstr)
249 | if not m:
250 | raise ValueError('invalid version number: ' + vstr)
251 |
252 | version, suffix = m.groups()
253 | parts = self._parse_dotted_string(version)
254 | self.major = parts.pop(0)
255 | if len(parts):
256 | self.minor = parts.pop(0)
257 | if len(parts):
258 | self.patch = parts.pop(0)
259 | if not len(parts) == 0:
260 | raise ValueError('version number too long: ' + vstr)
261 |
262 | if suffix:
263 | # Build info
264 | idx = suffix.find('+')
265 | if idx > -1:
266 | self.build = suffix[idx+1:]
267 | suffix = suffix[:idx]
268 | if suffix:
269 | if not suffix.startswith('-'):
270 | raise ValueError(
271 | 'suffix must start with - : ' + suffix)
272 | self.suffix = suffix[1:]
273 |
274 | def _parse_dotted_string(self, s):
275 | """Parse string ``s`` into list of ints and strings."""
276 | parsed = []
277 | parts = s.split('.')
278 | for p in parts:
279 | if p.isdigit():
280 | p = int(p)
281 | parsed.append(p)
282 | return parsed
283 |
284 | @property
285 | def tuple(self):
286 | """Version number as a tuple of major, minor, patch, pre-release."""
287 | return (self.major, self.minor, self.patch, self.suffix)
288 |
289 | def __lt__(self, other):
290 | """Implement comparison."""
291 | if not isinstance(other, Version):
292 | raise ValueError('not a Version instance: {0!r}'.format(other))
293 | t = self.tuple[:3]
294 | o = other.tuple[:3]
295 | if t < o:
296 | return True
297 | if t == o: # We need to compare suffixes
298 | if self.suffix and not other.suffix:
299 | return True
300 | if other.suffix and not self.suffix:
301 | return False
302 | return self._parse_dotted_string(self.suffix) \
303 | < self._parse_dotted_string(other.suffix)
304 | # t > o
305 | return False
306 |
307 | def __eq__(self, other):
308 | """Implement comparison."""
309 | if not isinstance(other, Version):
310 | raise ValueError('not a Version instance: {0!r}'.format(other))
311 | return self.tuple == other.tuple
312 |
313 | def __ne__(self, other):
314 | """Implement comparison."""
315 | return not self.__eq__(other)
316 |
317 | def __gt__(self, other):
318 | """Implement comparison."""
319 | if not isinstance(other, Version):
320 | raise ValueError('not a Version instance: {0!r}'.format(other))
321 | return other.__lt__(self)
322 |
323 | def __le__(self, other):
324 | """Implement comparison."""
325 | if not isinstance(other, Version):
326 | raise ValueError('not a Version instance: {0!r}'.format(other))
327 | return not other.__lt__(self)
328 |
329 | def __ge__(self, other):
330 | """Implement comparison."""
331 | return not self.__lt__(other)
332 |
333 | def __str__(self):
334 | """Return semantic version string."""
335 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch)
336 | if self.suffix:
337 | vstr = '{0}-{1}'.format(vstr, self.suffix)
338 | if self.build:
339 | vstr = '{0}+{1}'.format(vstr, self.build)
340 | return vstr
341 |
342 | def __repr__(self):
343 | """Return 'code' representation of `Version`."""
344 | return "Version('{0}')".format(str(self))
345 |
346 |
347 | def retrieve_download(dl):
348 | """Saves a download to a temporary file and returns path.
349 |
350 | .. versionadded: 1.37
351 |
352 | Args:
353 | url (unicode): URL to .alfredworkflow file in GitHub repo
354 |
355 | Returns:
356 | unicode: path to downloaded file
357 |
358 | """
359 | if not match_workflow(dl.filename):
360 | raise ValueError('attachment not a workflow: ' + dl.filename)
361 |
362 | path = os.path.join(tempfile.gettempdir(), dl.filename)
363 | wf().logger.debug('downloading update from '
364 | '%r to %r ...', dl.url, path)
365 |
366 | r = web.get(dl.url)
367 | r.raise_for_status()
368 |
369 | r.save_to_path(path)
370 |
371 | return path
372 |
373 |
374 | def build_api_url(repo):
375 | """Generate releases URL from GitHub repo.
376 |
377 | Args:
378 | repo (unicode): Repo name in form ``username/repo``
379 |
380 | Returns:
381 | unicode: URL to the API endpoint for the repo's releases
382 |
383 | """
384 | if len(repo.split('/')) != 2:
385 | raise ValueError('invalid GitHub repo: {!r}'.format(repo))
386 |
387 | return RELEASES_BASE.format(repo)
388 |
389 |
390 | def get_downloads(repo):
391 | """Load available ``Download``s for GitHub repo.
392 |
393 | .. versionadded: 1.37
394 |
395 | Args:
396 | repo (unicode): GitHub repo to load releases for.
397 |
398 | Returns:
399 | list: Sequence of `Download` contained in GitHub releases.
400 | """
401 | url = build_api_url(repo)
402 |
403 | def _fetch():
404 | wf().logger.info('retrieving releases for %r ...', repo)
405 | r = web.get(url)
406 | r.raise_for_status()
407 | return r.content
408 |
409 | key = 'github-releases-' + repo.replace('/', '-')
410 | js = wf().cached_data(key, _fetch, max_age=60)
411 |
412 | return Download.from_releases(js)
413 |
414 |
415 | def latest_download(dls, alfred_version=None, prereleases=False):
416 | """Return newest `Download`."""
417 | alfred_version = alfred_version or os.getenv('alfred_version')
418 | version = None
419 | if alfred_version:
420 | version = Version(alfred_version)
421 |
422 | dls.sort(reverse=True)
423 | for dl in dls:
424 | if dl.prerelease and not prereleases:
425 | wf().logger.debug('ignored prerelease: %s', dl.version)
426 | continue
427 | if version and dl.alfred_version > version:
428 | wf().logger.debug('ignored incompatible (%s > %s): %s',
429 | dl.alfred_version, version, dl.filename)
430 | continue
431 |
432 | wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename)
433 | return dl
434 |
435 | return None
436 |
437 |
438 | def check_update(repo, current_version, prereleases=False,
439 | alfred_version=None):
440 | """Check whether a newer release is available on GitHub.
441 |
442 | Args:
443 | repo (unicode): ``username/repo`` for workflow's GitHub repo
444 | current_version (unicode): the currently installed version of the
445 | workflow. :ref:`Semantic versioning ` is required.
446 | prereleases (bool): Whether to include pre-releases.
447 | alfred_version (unicode): version of currently-running Alfred.
448 | if empty, defaults to ``$alfred_version`` environment variable.
449 |
450 | Returns:
451 | bool: ``True`` if an update is available, else ``False``
452 |
453 | If an update is available, its version number and download URL will
454 | be cached.
455 |
456 | """
457 | key = '__workflow_latest_version'
458 | # data stored when no update is available
459 | no_update = {
460 | 'available': False,
461 | 'download': None,
462 | 'version': None,
463 | }
464 | current = Version(current_version)
465 |
466 | dls = get_downloads(repo)
467 | if not len(dls):
468 | wf().logger.warning('no valid downloads for %s', repo)
469 | wf().cache_data(key, no_update)
470 | return False
471 |
472 | wf().logger.info('%d download(s) for %s', len(dls), repo)
473 |
474 | dl = latest_download(dls, alfred_version, prereleases)
475 |
476 | if not dl:
477 | wf().logger.warning('no compatible downloads for %s', repo)
478 | wf().cache_data(key, no_update)
479 | return False
480 |
481 | wf().logger.debug('latest=%r, installed=%r', dl.version, current)
482 |
483 | if dl.version > current:
484 | wf().cache_data(key, {
485 | 'version': str(dl.version),
486 | 'download': dl.dict,
487 | 'available': True,
488 | })
489 | return True
490 |
491 | wf().cache_data(key, no_update)
492 | return False
493 |
494 |
495 | def install_update():
496 | """If a newer release is available, download and install it.
497 |
498 | :returns: ``True`` if an update is installed, else ``False``
499 |
500 | """
501 | key = '__workflow_latest_version'
502 | # data stored when no update is available
503 | no_update = {
504 | 'available': False,
505 | 'download': None,
506 | 'version': None,
507 | }
508 | status = wf().cached_data(key, max_age=0)
509 |
510 | if not status or not status.get('available'):
511 | wf().logger.info('no update available')
512 | return False
513 |
514 | dl = status.get('download')
515 | if not dl:
516 | wf().logger.info('no download information')
517 | return False
518 |
519 | path = retrieve_download(Download.from_dict(dl))
520 |
521 | wf().logger.info('installing updated workflow ...')
522 | subprocess.call(['open', path]) # nosec
523 |
524 | wf().cache_data(key, no_update)
525 | return True
526 |
527 |
528 | if __name__ == '__main__': # pragma: nocover
529 | import sys
530 |
531 | prereleases = False
532 |
533 | def show_help(status=0):
534 | """Print help message."""
535 | print('usage: update.py (check|install) '
536 | '[--prereleases] ')
537 | sys.exit(status)
538 |
539 | argv = sys.argv[:]
540 | if '-h' in argv or '--help' in argv:
541 | show_help()
542 |
543 | if '--prereleases' in argv:
544 | argv.remove('--prereleases')
545 | prereleases = True
546 |
547 | if len(argv) != 4:
548 | show_help(1)
549 |
550 | action = argv[1]
551 | repo = argv[2]
552 | version = argv[3]
553 |
554 | try:
555 |
556 | if action == 'check':
557 | check_update(repo, version, prereleases)
558 | elif action == 'install':
559 | install_update()
560 | else:
561 | show_help(1)
562 |
563 | except Exception as err: # ensure traceback is in log file
564 | wf().logger.exception(err)
565 | raise err
566 |
--------------------------------------------------------------------------------
/src/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 json
22 | import os
23 | import signal
24 | import subprocess
25 | import sys
26 | from threading import Event
27 | import time
28 |
29 | # JXA scripts to call Alfred's API via the Scripting Bridge
30 | # {app} is automatically replaced with "Alfred 3" or
31 | # "com.runningwithcrayons.Alfred" depending on version.
32 | #
33 | # Open Alfred in search (regular) mode
34 | JXA_SEARCH = 'Application({app}).search({arg});'
35 | # Open Alfred's File Actions on an argument
36 | JXA_ACTION = 'Application({app}).action({arg});'
37 | # Open Alfred's navigation mode at path
38 | JXA_BROWSE = 'Application({app}).browse({arg});'
39 | # Set the specified theme
40 | JXA_SET_THEME = 'Application({app}).setTheme({arg});'
41 | # Call an External Trigger
42 | JXA_TRIGGER = 'Application({app}).runTrigger({arg}, {opts});'
43 | # Save a variable to the workflow configuration sheet/info.plist
44 | JXA_SET_CONFIG = 'Application({app}).setConfiguration({arg}, {opts});'
45 | # Delete a variable from the workflow configuration sheet/info.plist
46 | JXA_UNSET_CONFIG = 'Application({app}).removeConfiguration({arg}, {opts});'
47 | # Tell Alfred to reload a workflow from disk
48 | JXA_RELOAD_WORKFLOW = 'Application({app}).reloadWorkflow({arg});'
49 |
50 |
51 | class AcquisitionError(Exception):
52 | """Raised if a lock cannot be acquired."""
53 |
54 |
55 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid'])
56 | """Information about an installed application.
57 |
58 | Returned by :func:`appinfo`. All attributes are Unicode.
59 |
60 | .. py:attribute:: name
61 |
62 | Name of the application, e.g. ``u'Safari'``.
63 |
64 | .. py:attribute:: path
65 |
66 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.
67 |
68 | .. py:attribute:: bundleid
69 |
70 | Application's bundle ID, e.g. ``u'com.apple.Safari'``.
71 |
72 | """
73 |
74 |
75 | def jxa_app_name():
76 | """Return name of application to call currently running Alfred.
77 |
78 | .. versionadded: 1.37
79 |
80 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending
81 | on which version of Alfred is running.
82 |
83 | This name is suitable for use with ``Application(name)`` in JXA.
84 |
85 | Returns:
86 | unicode: Application name or ID.
87 |
88 | """
89 | if os.getenv('alfred_version', '').startswith('3'):
90 | # Alfred 3
91 | return u'Alfred 3'
92 | # Alfred 4+
93 | return u'com.runningwithcrayons.Alfred'
94 |
95 |
96 | def unicodify(s, encoding='utf-8', norm=None):
97 | """Ensure string is Unicode.
98 |
99 | .. versionadded:: 1.31
100 |
101 | Decode encoded strings using ``encoding`` and normalise Unicode
102 | to form ``norm`` if specified.
103 |
104 | Args:
105 | s (str): String to decode. May also be Unicode.
106 | encoding (str, optional): Encoding to use on bytestrings.
107 | norm (None, optional): Normalisation form to apply to Unicode string.
108 |
109 | Returns:
110 | unicode: Decoded, optionally normalised, Unicode string.
111 |
112 | """
113 | if not isinstance(s, unicode):
114 | s = unicode(s, encoding)
115 |
116 | if norm:
117 | from unicodedata import normalize
118 | s = normalize(norm, s)
119 |
120 | return s
121 |
122 |
123 | def utf8ify(s):
124 | """Ensure string is a bytestring.
125 |
126 | .. versionadded:: 1.31
127 |
128 | Returns `str` objects unchanced, encodes `unicode` objects to
129 | UTF-8, and calls :func:`str` on anything else.
130 |
131 | Args:
132 | s (object): A Python object
133 |
134 | Returns:
135 | str: UTF-8 string or string representation of s.
136 |
137 | """
138 | if isinstance(s, str):
139 | return s
140 |
141 | if isinstance(s, unicode):
142 | return s.encode('utf-8')
143 |
144 | return str(s)
145 |
146 |
147 | def applescriptify(s):
148 | """Escape string for insertion into an AppleScript string.
149 |
150 | .. versionadded:: 1.31
151 |
152 | Replaces ``"`` with `"& quote &"`. Use this function if you want
153 | to insert a string into an AppleScript script:
154 |
155 | >>> applescriptify('g "python" test')
156 | 'g " & quote & "python" & quote & "test'
157 |
158 | Args:
159 | s (unicode): Unicode string to escape.
160 |
161 | Returns:
162 | unicode: Escaped string.
163 |
164 | """
165 | return s.replace(u'"', u'" & quote & "')
166 |
167 |
168 | def run_command(cmd, **kwargs):
169 | """Run a command and return the output.
170 |
171 | .. versionadded:: 1.31
172 |
173 | A thin wrapper around :func:`subprocess.check_output` that ensures
174 | all arguments are encoded to UTF-8 first.
175 |
176 | Args:
177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`.
178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`.
179 |
180 | Returns:
181 | str: Output returned by :func:`~subprocess.check_output`.
182 |
183 | """
184 | cmd = [utf8ify(s) for s in cmd]
185 | return subprocess.check_output(cmd, **kwargs)
186 |
187 |
188 | def run_applescript(script, *args, **kwargs):
189 | """Execute an AppleScript script and return its output.
190 |
191 | .. versionadded:: 1.31
192 |
193 | Run AppleScript either by filepath or code. If ``script`` is a valid
194 | filepath, that script will be run, otherwise ``script`` is treated
195 | as code.
196 |
197 | Args:
198 | script (str, optional): Filepath of script or code to run.
199 | *args: Optional command-line arguments to pass to the script.
200 | **kwargs: Pass ``lang`` to run a language other than AppleScript.
201 | Any other keyword arguments are passed to :func:`run_command`.
202 |
203 | Returns:
204 | str: Output of run command.
205 |
206 | """
207 | lang = 'AppleScript'
208 | if 'lang' in kwargs:
209 | lang = kwargs['lang']
210 | del kwargs['lang']
211 |
212 | cmd = ['/usr/bin/osascript', '-l', lang]
213 |
214 | if os.path.exists(script):
215 | cmd += [script]
216 | else:
217 | cmd += ['-e', script]
218 |
219 | cmd.extend(args)
220 |
221 | return run_command(cmd, **kwargs)
222 |
223 |
224 | def run_jxa(script, *args):
225 | """Execute a JXA script and return its output.
226 |
227 | .. versionadded:: 1.31
228 |
229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.
230 |
231 | Args:
232 | script (str): Filepath of script or code to run.
233 | *args: Optional command-line arguments to pass to script.
234 |
235 | Returns:
236 | str: Output of script.
237 |
238 | """
239 | return run_applescript(script, *args, lang='JavaScript')
240 |
241 |
242 | def run_trigger(name, bundleid=None, arg=None):
243 | """Call an Alfred External Trigger.
244 |
245 | .. versionadded:: 1.31
246 |
247 | If ``bundleid`` is not specified, the bundle ID of the calling
248 | workflow is used.
249 |
250 | Args:
251 | name (str): Name of External Trigger to call.
252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to.
253 | arg (str, optional): Argument to pass to trigger.
254 |
255 | """
256 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
257 | appname = jxa_app_name()
258 | opts = {'inWorkflow': bundleid}
259 | if arg:
260 | opts['withArgument'] = arg
261 |
262 | script = JXA_TRIGGER.format(app=json.dumps(appname),
263 | arg=json.dumps(name),
264 | opts=json.dumps(opts, sort_keys=True))
265 |
266 | run_applescript(script, lang='JavaScript')
267 |
268 |
269 | def set_theme(theme_name):
270 | """Change Alfred's theme.
271 |
272 | .. versionadded:: 1.39.0
273 |
274 | Args:
275 | theme_name (unicode): Name of theme Alfred should use.
276 |
277 | """
278 | appname = jxa_app_name()
279 | script = JXA_SET_THEME.format(app=json.dumps(appname),
280 | arg=json.dumps(theme_name))
281 | run_applescript(script, lang='JavaScript')
282 |
283 |
284 | def set_config(name, value, bundleid=None, exportable=False):
285 | """Set a workflow variable in ``info.plist``.
286 |
287 | .. versionadded:: 1.33
288 |
289 | If ``bundleid`` is not specified, the bundle ID of the calling
290 | workflow is used.
291 |
292 | Args:
293 | name (str): Name of variable to set.
294 | value (str): Value to set variable to.
295 | bundleid (str, optional): Bundle ID of workflow variable belongs to.
296 | exportable (bool, optional): Whether variable should be marked
297 | as exportable (Don't Export checkbox).
298 |
299 | """
300 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
301 | appname = jxa_app_name()
302 | opts = {
303 | 'toValue': value,
304 | 'inWorkflow': bundleid,
305 | 'exportable': exportable,
306 | }
307 |
308 | script = JXA_SET_CONFIG.format(app=json.dumps(appname),
309 | arg=json.dumps(name),
310 | opts=json.dumps(opts, sort_keys=True))
311 |
312 | run_applescript(script, lang='JavaScript')
313 |
314 |
315 | def unset_config(name, bundleid=None):
316 | """Delete a workflow variable from ``info.plist``.
317 |
318 | .. versionadded:: 1.33
319 |
320 | If ``bundleid`` is not specified, the bundle ID of the calling
321 | workflow is used.
322 |
323 | Args:
324 | name (str): Name of variable to delete.
325 | bundleid (str, optional): Bundle ID of workflow variable belongs to.
326 |
327 | """
328 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
329 | appname = jxa_app_name()
330 | opts = {'inWorkflow': bundleid}
331 |
332 | script = JXA_UNSET_CONFIG.format(app=json.dumps(appname),
333 | arg=json.dumps(name),
334 | opts=json.dumps(opts, sort_keys=True))
335 |
336 | run_applescript(script, lang='JavaScript')
337 |
338 |
339 | def search_in_alfred(query=None):
340 | """Open Alfred with given search query.
341 |
342 | .. versionadded:: 1.39.0
343 |
344 | Omit ``query`` to simply open Alfred's main window.
345 |
346 | Args:
347 | query (unicode, optional): Search query.
348 |
349 | """
350 | query = query or u''
351 | appname = jxa_app_name()
352 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query))
353 | run_applescript(script, lang='JavaScript')
354 |
355 |
356 | def browse_in_alfred(path):
357 | """Open Alfred's filesystem navigation mode at ``path``.
358 |
359 | .. versionadded:: 1.39.0
360 |
361 | Args:
362 | path (unicode): File or directory path.
363 |
364 | """
365 | appname = jxa_app_name()
366 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path))
367 | run_applescript(script, lang='JavaScript')
368 |
369 |
370 | def action_in_alfred(paths):
371 | """Action the give filepaths in Alfred.
372 |
373 | .. versionadded:: 1.39.0
374 |
375 | Args:
376 | paths (list): Unicode paths to files/directories to action.
377 |
378 | """
379 | appname = jxa_app_name()
380 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths))
381 | run_applescript(script, lang='JavaScript')
382 |
383 |
384 | def reload_workflow(bundleid=None):
385 | """Tell Alfred to reload a workflow from disk.
386 |
387 | .. versionadded:: 1.39.0
388 |
389 | If ``bundleid`` is not specified, the bundle ID of the calling
390 | workflow is used.
391 |
392 | Args:
393 | bundleid (unicode, optional): Bundle ID of workflow to reload.
394 |
395 | """
396 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid')
397 | appname = jxa_app_name()
398 | script = JXA_RELOAD_WORKFLOW.format(app=json.dumps(appname),
399 | arg=json.dumps(bundleid))
400 |
401 | run_applescript(script, lang='JavaScript')
402 |
403 |
404 | def appinfo(name):
405 | """Get information about an installed application.
406 |
407 | .. versionadded:: 1.31
408 |
409 | Args:
410 | name (str): Name of application to look up.
411 |
412 | Returns:
413 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.
414 |
415 | """
416 | cmd = [
417 | 'mdfind',
418 | '-onlyin', '/Applications',
419 | '-onlyin', '/System/Applications',
420 | '-onlyin', os.path.expanduser('~/Applications'),
421 | '(kMDItemContentTypeTree == com.apple.application &&'
422 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'
423 | .format(name)
424 | ]
425 |
426 | output = run_command(cmd).strip()
427 | if not output:
428 | return None
429 |
430 | path = output.split('\n')[0]
431 |
432 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path]
433 | bid = run_command(cmd).strip()
434 | if not bid: # pragma: no cover
435 | return None
436 |
437 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid))
438 |
439 |
440 | @contextmanager
441 | def atomic_writer(fpath, mode):
442 | """Atomic file writer.
443 |
444 | .. versionadded:: 1.12
445 |
446 | Context manager that ensures the file is only written if the write
447 | succeeds. The data is first written to a temporary file.
448 |
449 | :param fpath: path of file to write to.
450 | :type fpath: ``unicode``
451 | :param mode: sames as for :func:`open`
452 | :type mode: string
453 |
454 | """
455 | suffix = '.{}.tmp'.format(os.getpid())
456 | temppath = fpath + suffix
457 | with open(temppath, mode) as fp:
458 | try:
459 | yield fp
460 | os.rename(temppath, fpath)
461 | finally:
462 | try:
463 | os.remove(temppath)
464 | except (OSError, IOError):
465 | pass
466 |
467 |
468 | class LockFile(object):
469 | """Context manager to protect filepaths with lockfiles.
470 |
471 | .. versionadded:: 1.13
472 |
473 | Creates a lockfile alongside ``protected_path``. Other ``LockFile``
474 | instances will refuse to lock the same path.
475 |
476 | >>> path = '/path/to/file'
477 | >>> with LockFile(path):
478 | >>> with open(path, 'wb') as fp:
479 | >>> fp.write(data)
480 |
481 | Args:
482 | protected_path (unicode): File to protect with a lockfile
483 | timeout (float, optional): Raises an :class:`AcquisitionError`
484 | if lock cannot be acquired within this number of seconds.
485 | If ``timeout`` is 0 (the default), wait forever.
486 | delay (float, optional): How often to check (in seconds) if
487 | lock has been released.
488 |
489 | Attributes:
490 | delay (float): How often to check (in seconds) whether the lock
491 | can be acquired.
492 | lockfile (unicode): Path of the lockfile.
493 | timeout (float): How long to wait to acquire the lock.
494 |
495 | """
496 |
497 | def __init__(self, protected_path, timeout=0.0, delay=0.05):
498 | """Create new :class:`LockFile` object."""
499 | self.lockfile = protected_path + '.lock'
500 | self._lockfile = None
501 | self.timeout = timeout
502 | self.delay = delay
503 | self._lock = Event()
504 | atexit.register(self.release)
505 |
506 | @property
507 | def locked(self):
508 | """``True`` if file is locked by this instance."""
509 | return self._lock.is_set()
510 |
511 | def acquire(self, blocking=True):
512 | """Acquire the lock if possible.
513 |
514 | If the lock is in use and ``blocking`` is ``False``, return
515 | ``False``.
516 |
517 | Otherwise, check every :attr:`delay` seconds until it acquires
518 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.
519 |
520 | """
521 | if self.locked and not blocking:
522 | return False
523 |
524 | start = time.time()
525 | while True:
526 | # Raise error if we've been waiting too long to acquire the lock
527 | if self.timeout and (time.time() - start) >= self.timeout:
528 | raise AcquisitionError('lock acquisition timed out')
529 |
530 | # If already locked, wait then try again
531 | if self.locked:
532 | time.sleep(self.delay)
533 | continue
534 |
535 | # Create in append mode so we don't lose any contents
536 | if self._lockfile is None:
537 | self._lockfile = open(self.lockfile, 'a')
538 |
539 | # Try to acquire the lock
540 | try:
541 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
542 | self._lock.set()
543 | break
544 | except IOError as err: # pragma: no cover
545 | if err.errno not in (errno.EACCES, errno.EAGAIN):
546 | raise
547 |
548 | # Don't try again
549 | if not blocking: # pragma: no cover
550 | return False
551 |
552 | # Wait, then try again
553 | time.sleep(self.delay)
554 |
555 | return True
556 |
557 | def release(self):
558 | """Release the lock by deleting `self.lockfile`."""
559 | if not self._lock.is_set():
560 | return False
561 |
562 | try:
563 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
564 | except IOError: # pragma: no cover
565 | pass
566 | finally:
567 | self._lock.clear()
568 | self._lockfile = None
569 | try:
570 | os.unlink(self.lockfile)
571 | except (IOError, OSError): # pragma: no cover
572 | pass
573 |
574 | return True
575 |
576 | def __enter__(self):
577 | """Acquire lock."""
578 | self.acquire()
579 | return self
580 |
581 | def __exit__(self, typ, value, traceback):
582 | """Release lock."""
583 | self.release()
584 |
585 | def __del__(self):
586 | """Clear up `self.lockfile`."""
587 | self.release() # pragma: no cover
588 |
589 |
590 | class uninterruptible(object):
591 | """Decorator that postpones SIGTERM until wrapped function returns.
592 |
593 | .. versionadded:: 1.12
594 |
595 | .. important:: This decorator is NOT thread-safe.
596 |
597 | As of version 2.7, Alfred allows Script Filters to be killed. If
598 | your workflow is killed in the middle of critical code (e.g.
599 | writing data to disk), this may corrupt your workflow's data.
600 |
601 | Use this decorator to wrap critical functions that *must* complete.
602 | If the script is killed while a wrapped function is executing,
603 | the SIGTERM will be caught and handled after your function has
604 | finished executing.
605 |
606 | Alfred-Workflow uses this internally to ensure its settings, data
607 | and cache writes complete.
608 |
609 | """
610 |
611 | def __init__(self, func, class_name=''):
612 | """Decorate `func`."""
613 | self.func = func
614 | functools.update_wrapper(self, func)
615 | self._caught_signal = None
616 |
617 | def signal_handler(self, signum, frame):
618 | """Called when process receives SIGTERM."""
619 | self._caught_signal = (signum, frame)
620 |
621 | def __call__(self, *args, **kwargs):
622 | """Trap ``SIGTERM`` and call wrapped function."""
623 | self._caught_signal = None
624 | # Register handler for SIGTERM, then call `self.func`
625 | self.old_signal_handler = signal.getsignal(signal.SIGTERM)
626 | signal.signal(signal.SIGTERM, self.signal_handler)
627 |
628 | self.func(*args, **kwargs)
629 |
630 | # Restore old signal handler
631 | signal.signal(signal.SIGTERM, self.old_signal_handler)
632 |
633 | # Handle any signal caught during execution
634 | if self._caught_signal is not None:
635 | signum, frame = self._caught_signal
636 | if callable(self.old_signal_handler):
637 | self.old_signal_handler(signum, frame)
638 | elif self.old_signal_handler == signal.SIG_DFL:
639 | sys.exit(0)
640 |
641 | def __get__(self, obj=None, klass=None):
642 | """Decorator API."""
643 | return self.__class__(self.func.__get__(obj, klass),
644 | klass.__name__)
645 |
--------------------------------------------------------------------------------
/src/workflow/version:
--------------------------------------------------------------------------------
1 | 1.39.0
--------------------------------------------------------------------------------
/src/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 | from __future__ import absolute_import, print_function
13 |
14 | import codecs
15 | import json
16 | import mimetypes
17 | import os
18 | import random
19 | import re
20 | import socket
21 | import string
22 | import unicodedata
23 | import urllib
24 | import urllib2
25 | import urlparse
26 | import zlib
27 |
28 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
29 |
30 | USER_AGENT = (u'Alfred-Workflow/' + __version__ +
31 | ' (+http://www.deanishe.net/alfred-workflow)')
32 |
33 | # Valid characters for multipart form data boundaries
34 | BOUNDARY_CHARS = string.digits + string.ascii_letters
35 |
36 | # HTTP response codes
37 | RESPONSES = {
38 | 100: 'Continue',
39 | 101: 'Switching Protocols',
40 | 200: 'OK',
41 | 201: 'Created',
42 | 202: 'Accepted',
43 | 203: 'Non-Authoritative Information',
44 | 204: 'No Content',
45 | 205: 'Reset Content',
46 | 206: 'Partial Content',
47 | 300: 'Multiple Choices',
48 | 301: 'Moved Permanently',
49 | 302: 'Found',
50 | 303: 'See Other',
51 | 304: 'Not Modified',
52 | 305: 'Use Proxy',
53 | 307: 'Temporary Redirect',
54 | 400: 'Bad Request',
55 | 401: 'Unauthorized',
56 | 402: 'Payment Required',
57 | 403: 'Forbidden',
58 | 404: 'Not Found',
59 | 405: 'Method Not Allowed',
60 | 406: 'Not Acceptable',
61 | 407: 'Proxy Authentication Required',
62 | 408: 'Request Timeout',
63 | 409: 'Conflict',
64 | 410: 'Gone',
65 | 411: 'Length Required',
66 | 412: 'Precondition Failed',
67 | 413: 'Request Entity Too Large',
68 | 414: 'Request-URI Too Long',
69 | 415: 'Unsupported Media Type',
70 | 416: 'Requested Range Not Satisfiable',
71 | 417: 'Expectation Failed',
72 | 500: 'Internal Server Error',
73 | 501: 'Not Implemented',
74 | 502: 'Bad Gateway',
75 | 503: 'Service Unavailable',
76 | 504: 'Gateway Timeout',
77 | 505: 'HTTP Version Not Supported'
78 | }
79 |
80 |
81 | def str_dict(dic):
82 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
83 |
84 | :param dic: Mapping of Unicode strings
85 | :type dic: dict
86 | :returns: Dictionary containing only UTF-8 strings
87 | :rtype: dict
88 |
89 | """
90 | if isinstance(dic, CaseInsensitiveDictionary):
91 | dic2 = CaseInsensitiveDictionary()
92 | else:
93 | dic2 = {}
94 | for k, v in dic.items():
95 | if isinstance(k, unicode):
96 | k = k.encode('utf-8')
97 | if isinstance(v, unicode):
98 | v = v.encode('utf-8')
99 | dic2[k] = v
100 | return dic2
101 |
102 |
103 | class NoRedirectHandler(urllib2.HTTPRedirectHandler):
104 | """Prevent redirections."""
105 |
106 | def redirect_request(self, *args):
107 | """Ignore redirect."""
108 | return None
109 |
110 |
111 | # Adapted from https://gist.github.com/babakness/3901174
112 | class CaseInsensitiveDictionary(dict):
113 | """Dictionary with caseless key search.
114 |
115 | Enables case insensitive searching while preserving case sensitivity
116 | when keys are listed, ie, via keys() or items() methods.
117 |
118 | Works by storing a lowercase version of the key as the new key and
119 | stores the original key-value pair as the key's value
120 | (values become dictionaries).
121 |
122 | """
123 |
124 | def __init__(self, initval=None):
125 | """Create new case-insensitive dictionary."""
126 | if isinstance(initval, dict):
127 | for key, value in initval.iteritems():
128 | self.__setitem__(key, value)
129 |
130 | elif isinstance(initval, list):
131 | for (key, value) in initval:
132 | self.__setitem__(key, value)
133 |
134 | def __contains__(self, key):
135 | return dict.__contains__(self, key.lower())
136 |
137 | def __getitem__(self, key):
138 | return dict.__getitem__(self, key.lower())['val']
139 |
140 | def __setitem__(self, key, value):
141 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
142 |
143 | def get(self, key, default=None):
144 | """Return value for case-insensitive key or default."""
145 | try:
146 | v = dict.__getitem__(self, key.lower())
147 | except KeyError:
148 | return default
149 | else:
150 | return v['val']
151 |
152 | def update(self, other):
153 | """Update values from other ``dict``."""
154 | for k, v in other.items():
155 | self[k] = v
156 |
157 | def items(self):
158 | """Return ``(key, value)`` pairs."""
159 | return [(v['key'], v['val']) for v in dict.itervalues(self)]
160 |
161 | def keys(self):
162 | """Return original keys."""
163 | return [v['key'] for v in dict.itervalues(self)]
164 |
165 | def values(self):
166 | """Return all values."""
167 | return [v['val'] for v in dict.itervalues(self)]
168 |
169 | def iteritems(self):
170 | """Iterate over ``(key, value)`` pairs."""
171 | for v in dict.itervalues(self):
172 | yield v['key'], v['val']
173 |
174 | def iterkeys(self):
175 | """Iterate over original keys."""
176 | for v in dict.itervalues(self):
177 | yield v['key']
178 |
179 | def itervalues(self):
180 | """Interate over values."""
181 | for v in dict.itervalues(self):
182 | yield v['val']
183 |
184 |
185 | class Request(urllib2.Request):
186 | """Subclass of :class:`urllib2.Request` that supports custom methods."""
187 |
188 | def __init__(self, *args, **kwargs):
189 | """Create a new :class:`Request`."""
190 | self._method = kwargs.pop('method', None)
191 | urllib2.Request.__init__(self, *args, **kwargs)
192 |
193 | def get_method(self):
194 | return self._method.upper()
195 |
196 |
197 | class Response(object):
198 | """
199 | Returned by :func:`request` / :func:`get` / :func:`post` functions.
200 |
201 | Simplified version of the ``Response`` object in the ``requests`` library.
202 |
203 | >>> r = request('http://www.google.com')
204 | >>> r.status_code
205 | 200
206 | >>> r.encoding
207 | ISO-8859-1
208 | >>> r.content # bytes
209 | ...
210 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag
211 | u' ...'
212 | >>> r.json() # content parsed as JSON
213 |
214 | """
215 |
216 | def __init__(self, request, stream=False):
217 | """Call `request` with :mod:`urllib2` and process results.
218 |
219 | :param request: :class:`Request` instance
220 | :param stream: Whether to stream response or retrieve it all at once
221 | :type stream: bool
222 |
223 | """
224 | self.request = request
225 | self._stream = stream
226 | self.url = None
227 | self.raw = None
228 | self._encoding = None
229 | self.error = None
230 | self.status_code = None
231 | self.reason = None
232 | self.headers = CaseInsensitiveDictionary()
233 | self._content = None
234 | self._content_loaded = False
235 | self._gzipped = False
236 |
237 | # Execute query
238 | try:
239 | self.raw = urllib2.urlopen(request)
240 | except urllib2.HTTPError as err:
241 | self.error = err
242 | try:
243 | self.url = err.geturl()
244 | # sometimes (e.g. when authentication fails)
245 | # urllib can't get a URL from an HTTPError
246 | # This behaviour changes across Python versions,
247 | # so no test cover (it isn't important).
248 | except AttributeError: # pragma: no cover
249 | pass
250 | self.status_code = err.code
251 | else:
252 | self.status_code = self.raw.getcode()
253 | self.url = self.raw.geturl()
254 | self.reason = RESPONSES.get(self.status_code)
255 |
256 | # Parse additional info if request succeeded
257 | if not self.error:
258 | headers = self.raw.info()
259 | self.transfer_encoding = headers.getencoding()
260 | self.mimetype = headers.gettype()
261 | for key in headers.keys():
262 | self.headers[key.lower()] = headers.get(key)
263 |
264 | # Is content gzipped?
265 | # Transfer-Encoding appears to not be used in the wild
266 | # (contrary to the HTTP standard), but no harm in testing
267 | # for it
268 | if 'gzip' in headers.get('content-encoding', '') or \
269 | 'gzip' in headers.get('transfer-encoding', ''):
270 | self._gzipped = True
271 |
272 | @property
273 | def stream(self):
274 | """Whether response is streamed.
275 |
276 | Returns:
277 | bool: `True` if response is streamed.
278 |
279 | """
280 | return self._stream
281 |
282 | @stream.setter
283 | def stream(self, value):
284 | if self._content_loaded:
285 | raise RuntimeError("`content` has already been read from "
286 | "this Response.")
287 |
288 | self._stream = value
289 |
290 | def json(self):
291 | """Decode response contents as JSON.
292 |
293 | :returns: object decoded from JSON
294 | :rtype: list, dict or unicode
295 |
296 | """
297 | return json.loads(self.content, self.encoding or 'utf-8')
298 |
299 | @property
300 | def encoding(self):
301 | """Text encoding of document or ``None``.
302 |
303 | :returns: Text encoding if found.
304 | :rtype: str or ``None``
305 |
306 | """
307 | if not self._encoding:
308 | self._encoding = self._get_encoding()
309 |
310 | return self._encoding
311 |
312 | @property
313 | def content(self):
314 | """Raw content of response (i.e. bytes).
315 |
316 | :returns: Body of HTTP response
317 | :rtype: str
318 |
319 | """
320 | if not self._content:
321 |
322 | # Decompress gzipped content
323 | if self._gzipped:
324 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
325 | self._content = decoder.decompress(self.raw.read())
326 |
327 | else:
328 | self._content = self.raw.read()
329 |
330 | self._content_loaded = True
331 |
332 | return self._content
333 |
334 | @property
335 | def text(self):
336 | """Unicode-decoded content of response body.
337 |
338 | If no encoding can be determined from HTTP headers or the content
339 | itself, the encoded response body will be returned instead.
340 |
341 | :returns: Body of HTTP response
342 | :rtype: unicode or str
343 |
344 | """
345 | if self.encoding:
346 | return unicodedata.normalize('NFC', unicode(self.content,
347 | self.encoding))
348 | return self.content
349 |
350 | def iter_content(self, chunk_size=4096, decode_unicode=False):
351 | """Iterate over response data.
352 |
353 | .. versionadded:: 1.6
354 |
355 | :param chunk_size: Number of bytes to read into memory
356 | :type chunk_size: int
357 | :param decode_unicode: Decode to Unicode using detected encoding
358 | :type decode_unicode: bool
359 | :returns: iterator
360 |
361 | """
362 | if not self.stream:
363 | raise RuntimeError("You cannot call `iter_content` on a "
364 | "Response unless you passed `stream=True`"
365 | " to `get()`/`post()`/`request()`.")
366 |
367 | if self._content_loaded:
368 | raise RuntimeError(
369 | "`content` has already been read from this Response.")
370 |
371 | def decode_stream(iterator, r):
372 | dec = codecs.getincrementaldecoder(r.encoding)(errors='replace')
373 |
374 | for chunk in iterator:
375 | data = dec.decode(chunk)
376 | if data:
377 | yield data
378 |
379 | data = dec.decode(b'', final=True)
380 | if data: # pragma: no cover
381 | yield data
382 |
383 | def generate():
384 | if self._gzipped:
385 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
386 |
387 | while True:
388 | chunk = self.raw.read(chunk_size)
389 | if not chunk:
390 | break
391 |
392 | if self._gzipped:
393 | chunk = decoder.decompress(chunk)
394 |
395 | yield chunk
396 |
397 | chunks = generate()
398 |
399 | if decode_unicode and self.encoding:
400 | chunks = decode_stream(chunks, self)
401 |
402 | return chunks
403 |
404 | def save_to_path(self, filepath):
405 | """Save retrieved data to file at ``filepath``.
406 |
407 | .. versionadded: 1.9.6
408 |
409 | :param filepath: Path to save retrieved data.
410 |
411 | """
412 | filepath = os.path.abspath(filepath)
413 | dirname = os.path.dirname(filepath)
414 | if not os.path.exists(dirname):
415 | os.makedirs(dirname)
416 |
417 | self.stream = True
418 |
419 | with open(filepath, 'wb') as fileobj:
420 | for data in self.iter_content():
421 | fileobj.write(data)
422 |
423 | def raise_for_status(self):
424 | """Raise stored error if one occurred.
425 |
426 | error will be instance of :class:`urllib2.HTTPError`
427 | """
428 | if self.error is not None:
429 | raise self.error
430 | return
431 |
432 | def _get_encoding(self):
433 | """Get encoding from HTTP headers or content.
434 |
435 | :returns: encoding or `None`
436 | :rtype: unicode or ``None``
437 |
438 | """
439 | headers = self.raw.info()
440 | encoding = None
441 |
442 | if headers.getparam('charset'):
443 | encoding = headers.getparam('charset')
444 |
445 | # HTTP Content-Type header
446 | for param in headers.getplist():
447 | if param.startswith('charset='):
448 | encoding = param[8:]
449 | break
450 |
451 | if not self.stream: # Try sniffing response content
452 | # Encoding declared in document should override HTTP headers
453 | if self.mimetype == 'text/html': # sniff HTML headers
454 | m = re.search(r"""""",
455 | self.content)
456 | if m:
457 | encoding = m.group(1)
458 |
459 | elif ((self.mimetype.startswith('application/')
460 | or self.mimetype.startswith('text/'))
461 | and 'xml' in self.mimetype):
462 | m = re.search(r"""]*\?>""",
463 | self.content)
464 | if m:
465 | encoding = m.group(1)
466 |
467 | # Format defaults
468 | if self.mimetype == 'application/json' and not encoding:
469 | # The default encoding for JSON
470 | encoding = 'utf-8'
471 |
472 | elif self.mimetype == 'application/xml' and not encoding:
473 | # The default for 'application/xml'
474 | encoding = 'utf-8'
475 |
476 | if encoding:
477 | encoding = encoding.lower()
478 |
479 | return encoding
480 |
481 |
482 | def request(method, url, params=None, data=None, headers=None, cookies=None,
483 | files=None, auth=None, timeout=60, allow_redirects=False,
484 | stream=False):
485 | """Initiate an HTTP(S) request. Returns :class:`Response` object.
486 |
487 | :param method: 'GET' or 'POST'
488 | :type method: unicode
489 | :param url: URL to open
490 | :type url: unicode
491 | :param params: mapping of URL parameters
492 | :type params: dict
493 | :param data: mapping of form data ``{'field_name': 'value'}`` or
494 | :class:`str`
495 | :type data: dict or str
496 | :param headers: HTTP headers
497 | :type headers: dict
498 | :param cookies: cookies to send to server
499 | :type cookies: dict
500 | :param files: files to upload (see below).
501 | :type files: dict
502 | :param auth: username, password
503 | :type auth: tuple
504 | :param timeout: connection timeout limit in seconds
505 | :type timeout: int
506 | :param allow_redirects: follow redirections
507 | :type allow_redirects: bool
508 | :param stream: Stream content instead of fetching it all at once.
509 | :type stream: bool
510 | :returns: Response object
511 | :rtype: :class:`Response`
512 |
513 |
514 | The ``files`` argument is a dictionary::
515 |
516 | {'fieldname' : { 'filename': 'blah.txt',
517 | 'content': '',
518 | 'mimetype': 'text/plain'}
519 | }
520 |
521 | * ``fieldname`` is the name of the field in the HTML form.
522 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
523 | be used to guess the mimetype, or ``application/octet-stream``
524 | will be used.
525 |
526 | """
527 | # TODO: cookies
528 | socket.setdefaulttimeout(timeout)
529 |
530 | # Default handlers
531 | openers = []
532 |
533 | if not allow_redirects:
534 | openers.append(NoRedirectHandler())
535 |
536 | if auth is not None: # Add authorisation handler
537 | username, password = auth
538 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
539 | password_manager.add_password(None, url, username, password)
540 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager)
541 | openers.append(auth_manager)
542 |
543 | # Install our custom chain of openers
544 | opener = urllib2.build_opener(*openers)
545 | urllib2.install_opener(opener)
546 |
547 | if not headers:
548 | headers = CaseInsensitiveDictionary()
549 | else:
550 | headers = CaseInsensitiveDictionary(headers)
551 |
552 | if 'user-agent' not in headers:
553 | headers['user-agent'] = USER_AGENT
554 |
555 | # Accept gzip-encoded content
556 | encodings = [s.strip() for s in
557 | headers.get('accept-encoding', '').split(',')]
558 | if 'gzip' not in encodings:
559 | encodings.append('gzip')
560 |
561 | headers['accept-encoding'] = ', '.join(encodings)
562 |
563 | if files:
564 | if not data:
565 | data = {}
566 | new_headers, data = encode_multipart_formdata(data, files)
567 | headers.update(new_headers)
568 | elif data and isinstance(data, dict):
569 | data = urllib.urlencode(str_dict(data))
570 |
571 | # Make sure everything is encoded text
572 | headers = str_dict(headers)
573 |
574 | if isinstance(url, unicode):
575 | url = url.encode('utf-8')
576 |
577 | if params: # GET args (POST args are handled in encode_multipart_formdata)
578 |
579 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
580 |
581 | if query: # Combine query string and `params`
582 | url_params = urlparse.parse_qs(query)
583 | # `params` take precedence over URL query string
584 | url_params.update(params)
585 | params = url_params
586 |
587 | query = urllib.urlencode(str_dict(params), doseq=True)
588 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
589 |
590 | req = Request(url, data, headers, method=method)
591 | return Response(req, stream)
592 |
593 |
594 | def get(url, params=None, headers=None, cookies=None, auth=None,
595 | timeout=60, allow_redirects=True, stream=False):
596 | """Initiate a GET request. Arguments as for :func:`request`.
597 |
598 | :returns: :class:`Response` instance
599 |
600 | """
601 | return request('GET', url, params, headers=headers, cookies=cookies,
602 | auth=auth, timeout=timeout, allow_redirects=allow_redirects,
603 | stream=stream)
604 |
605 |
606 | def delete(url, params=None, data=None, headers=None, cookies=None, auth=None,
607 | timeout=60, allow_redirects=True, stream=False):
608 | """Initiate a DELETE request. Arguments as for :func:`request`.
609 |
610 | :returns: :class:`Response` instance
611 |
612 | """
613 | return request('DELETE', url, params, data, headers=headers,
614 | cookies=cookies, auth=auth, timeout=timeout,
615 | allow_redirects=allow_redirects, stream=stream)
616 |
617 |
618 | def post(url, params=None, data=None, headers=None, cookies=None, files=None,
619 | auth=None, timeout=60, allow_redirects=False, stream=False):
620 | """Initiate a POST request. Arguments as for :func:`request`.
621 |
622 | :returns: :class:`Response` instance
623 |
624 | """
625 | return request('POST', url, params, data, headers, cookies, files, auth,
626 | timeout, allow_redirects, stream)
627 |
628 |
629 | def put(url, params=None, data=None, headers=None, cookies=None, files=None,
630 | auth=None, timeout=60, allow_redirects=False, stream=False):
631 | """Initiate a PUT request. Arguments as for :func:`request`.
632 |
633 | :returns: :class:`Response` instance
634 |
635 | """
636 | return request('PUT', url, params, data, headers, cookies, files, auth,
637 | timeout, allow_redirects, stream)
638 |
639 |
640 | def encode_multipart_formdata(fields, files):
641 | """Encode form data (``fields``) and ``files`` for POST request.
642 |
643 | :param fields: mapping of ``{name : value}`` pairs for normal form fields.
644 | :type fields: dict
645 | :param files: dictionary of fieldnames/files elements for file data.
646 | See below for details.
647 | :type files: dict of :class:`dict`
648 | :returns: ``(headers, body)`` ``headers`` is a
649 | :class:`dict` of HTTP headers
650 | :rtype: 2-tuple ``(dict, str)``
651 |
652 | The ``files`` argument is a dictionary::
653 |
654 | {'fieldname' : { 'filename': 'blah.txt',
655 | 'content': '',
656 | 'mimetype': 'text/plain'}
657 | }
658 |
659 | - ``fieldname`` is the name of the field in the HTML form.
660 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
661 | be used to guess the mimetype, or ``application/octet-stream``
662 | will be used.
663 |
664 | """
665 | def get_content_type(filename):
666 | """Return or guess mimetype of ``filename``.
667 |
668 | :param filename: filename of file
669 | :type filename: unicode/str
670 | :returns: mime-type, e.g. ``text/html``
671 | :rtype: str
672 |
673 | """
674 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
675 |
676 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS)
677 | for i in range(30))
678 | CRLF = '\r\n'
679 | output = []
680 |
681 | # Normal form fields
682 | for (name, value) in fields.items():
683 | if isinstance(name, unicode):
684 | name = name.encode('utf-8')
685 | if isinstance(value, unicode):
686 | value = value.encode('utf-8')
687 | output.append('--' + boundary)
688 | output.append('Content-Disposition: form-data; name="%s"' % name)
689 | output.append('')
690 | output.append(value)
691 |
692 | # Files to upload
693 | for name, d in files.items():
694 | filename = d[u'filename']
695 | content = d[u'content']
696 | if u'mimetype' in d:
697 | mimetype = d[u'mimetype']
698 | else:
699 | mimetype = get_content_type(filename)
700 | if isinstance(name, unicode):
701 | name = name.encode('utf-8')
702 | if isinstance(filename, unicode):
703 | filename = filename.encode('utf-8')
704 | if isinstance(mimetype, unicode):
705 | mimetype = mimetype.encode('utf-8')
706 | output.append('--' + boundary)
707 | output.append('Content-Disposition: form-data; '
708 | 'name="%s"; filename="%s"' % (name, filename))
709 | output.append('Content-Type: %s' % mimetype)
710 | output.append('')
711 | output.append(content)
712 |
713 | output.append('--' + boundary + '--')
714 | output.append('')
715 | body = CRLF.join(output)
716 | headers = {
717 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary,
718 | 'Content-Length': str(len(body)),
719 | }
720 | return (headers, body)
721 |
--------------------------------------------------------------------------------
/src/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+ version of :class:`~workflow.Workflow`.
11 |
12 | :class:`~workflow.Workflow3` supports 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 4's default cache directory."""
480 | return os.path.join(
481 | os.path.expanduser(
482 | '~/Library/Caches/com.runningwithcrayons.Alfred/'
483 | 'Workflow Data/'),
484 | self.bundleid)
485 |
486 | @property
487 | def _default_datadir(self):
488 | """Alfred 4's default data directory."""
489 | return os.path.join(os.path.expanduser(
490 | '~/Library/Application Support/Alfred/Workflow Data/'),
491 | self.bundleid)
492 |
493 | @property
494 | def rerun(self):
495 | """How often (in seconds) Alfred should re-run the Script Filter."""
496 | return self._rerun
497 |
498 | @rerun.setter
499 | def rerun(self, seconds):
500 | """Interval at which Alfred should re-run the Script Filter.
501 |
502 | Args:
503 | seconds (int): Interval between runs.
504 | """
505 | self._rerun = seconds
506 |
507 | @property
508 | def session_id(self):
509 | """A unique session ID every time the user uses the workflow.
510 |
511 | .. versionadded:: 1.25
512 |
513 | The session ID persists while the user is using this workflow.
514 | It expires when the user runs a different workflow or closes
515 | Alfred.
516 |
517 | """
518 | if not self._session_id:
519 | from uuid import uuid4
520 | self._session_id = uuid4().hex
521 | self.setvar('_WF_SESSION_ID', self._session_id)
522 |
523 | return self._session_id
524 |
525 | def setvar(self, name, value, persist=False):
526 | """Set a "global" workflow variable.
527 |
528 | .. versionchanged:: 1.33
529 |
530 | These variables are always passed to downstream workflow objects.
531 |
532 | If you have set :attr:`rerun`, these variables are also passed
533 | back to the script when Alfred runs it again.
534 |
535 | Args:
536 | name (unicode): Name of variable.
537 | value (unicode): Value of variable.
538 | persist (bool, optional): Also save variable to ``info.plist``?
539 |
540 | """
541 | self.variables[name] = value
542 | if persist:
543 | from .util import set_config
544 | set_config(name, value, self.bundleid)
545 | self.logger.debug('saved variable %r with value %r to info.plist',
546 | name, value)
547 |
548 | def getvar(self, name, default=None):
549 | """Return value of workflow variable for ``name`` or ``default``.
550 |
551 | Args:
552 | name (unicode): Variable name.
553 | default (None, optional): Value to return if variable is unset.
554 |
555 | Returns:
556 | unicode or ``default``: Value of variable if set or ``default``.
557 |
558 | """
559 | return self.variables.get(name, default)
560 |
561 | def add_item(self, title, subtitle='', arg=None, autocomplete=None,
562 | valid=False, uid=None, icon=None, icontype=None, type=None,
563 | largetext=None, copytext=None, quicklookurl=None, match=None):
564 | """Add an item to be output to Alfred.
565 |
566 | Args:
567 | match (unicode, optional): If you have "Alfred filters results"
568 | turned on for your Script Filter, Alfred (version 3.5 and
569 | above) will filter against this field, not ``title``.
570 |
571 | See :meth:`Workflow.add_item() ` for
572 | the main documentation and other parameters.
573 |
574 | The key difference is that this method does not support the
575 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
576 | method instead on the returned item instead.
577 |
578 | Returns:
579 | Item3: Alfred feedback item.
580 |
581 | """
582 | item = self.item_class(title, subtitle, arg, autocomplete,
583 | match, valid, uid, icon, icontype, type,
584 | largetext, copytext, quicklookurl)
585 |
586 | # Add variables to child item
587 | item.variables.update(self.variables)
588 |
589 | self._items.append(item)
590 | return item
591 |
592 | @property
593 | def _session_prefix(self):
594 | """Filename prefix for current session."""
595 | return '_wfsess-{0}-'.format(self.session_id)
596 |
597 | def _mk_session_name(self, name):
598 | """New cache name/key based on session ID."""
599 | return self._session_prefix + name
600 |
601 | def cache_data(self, name, data, session=False):
602 | """Cache API with session-scoped expiry.
603 |
604 | .. versionadded:: 1.25
605 |
606 | Args:
607 | name (str): Cache key
608 | data (object): Data to cache
609 | session (bool, optional): Whether to scope the cache
610 | to the current session.
611 |
612 | ``name`` and ``data`` are the same as for the
613 | :meth:`~workflow.Workflow.cache_data` method on
614 | :class:`~workflow.Workflow`.
615 |
616 | If ``session`` is ``True``, then ``name`` is prefixed
617 | with :attr:`session_id`.
618 |
619 | """
620 | if session:
621 | name = self._mk_session_name(name)
622 |
623 | return super(Workflow3, self).cache_data(name, data)
624 |
625 | def cached_data(self, name, data_func=None, max_age=60, session=False):
626 | """Cache API with session-scoped expiry.
627 |
628 | .. versionadded:: 1.25
629 |
630 | Args:
631 | name (str): Cache key
632 | data_func (callable): Callable that returns fresh data. It
633 | is called if the cache has expired or doesn't exist.
634 | max_age (int): Maximum allowable age of cache in seconds.
635 | session (bool, optional): Whether to scope the cache
636 | to the current session.
637 |
638 | ``name``, ``data_func`` and ``max_age`` are the same as for the
639 | :meth:`~workflow.Workflow.cached_data` method on
640 | :class:`~workflow.Workflow`.
641 |
642 | If ``session`` is ``True``, then ``name`` is prefixed
643 | with :attr:`session_id`.
644 |
645 | """
646 | if session:
647 | name = self._mk_session_name(name)
648 |
649 | return super(Workflow3, self).cached_data(name, data_func, max_age)
650 |
651 | def clear_session_cache(self, current=False):
652 | """Remove session data from the cache.
653 |
654 | .. versionadded:: 1.25
655 | .. versionchanged:: 1.27
656 |
657 | By default, data belonging to the current session won't be
658 | deleted. Set ``current=True`` to also clear current session.
659 |
660 | Args:
661 | current (bool, optional): If ``True``, also remove data for
662 | current session.
663 |
664 | """
665 | def _is_session_file(filename):
666 | if current:
667 | return filename.startswith('_wfsess-')
668 | return filename.startswith('_wfsess-') \
669 | and not filename.startswith(self._session_prefix)
670 |
671 | self.clear_cache(_is_session_file)
672 |
673 | @property
674 | def obj(self):
675 | """Feedback formatted for JSON serialization.
676 |
677 | Returns:
678 | dict: Data suitable for Alfred 3 feedback.
679 |
680 | """
681 | items = []
682 | for item in self._items:
683 | items.append(item.obj)
684 |
685 | o = {'items': items}
686 | if self.variables:
687 | o['variables'] = self.variables
688 | if self.rerun:
689 | o['rerun'] = self.rerun
690 | return o
691 |
692 | def warn_empty(self, title, subtitle=u'', icon=None):
693 | """Add a warning to feedback if there are no items.
694 |
695 | .. versionadded:: 1.31
696 |
697 | Add a "warning" item to Alfred feedback if no other items
698 | have been added. This is a handy shortcut to prevent Alfred
699 | from showing its fallback searches, which is does if no
700 | items are returned.
701 |
702 | Args:
703 | title (unicode): Title of feedback item.
704 | subtitle (unicode, optional): Subtitle of feedback item.
705 | icon (str, optional): Icon for feedback item. If not
706 | specified, ``ICON_WARNING`` is used.
707 |
708 | Returns:
709 | Item3: Newly-created item.
710 |
711 | """
712 | if len(self._items):
713 | return
714 |
715 | icon = icon or ICON_WARNING
716 | return self.add_item(title, subtitle, icon=icon)
717 |
718 | def send_feedback(self):
719 | """Print stored items to console/Alfred as JSON."""
720 | if self.debugging:
721 | json.dump(self.obj, sys.stdout, indent=2, separators=(',', ': '))
722 | else:
723 | json.dump(self.obj, sys.stdout)
724 | sys.stdout.flush()
725 |
--------------------------------------------------------------------------------