├── .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 | [![Release](https://github.com/almibarss/alfred-git-clone/workflows/Release/badge.svg)](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 | ![alfred-git-clone](https://user-images.githubusercontent.com/43891734/93094028-acf31200-f6a1-11ea-9a23-8379f45040dd.gif) 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 | --------------------------------------------------------------------------------