From 09ca8b88d2d19fc1e9bc0bad5d1f29369636148c Mon Sep 17 00:00:00 2001 From: Charlie Zhang Date: Tue, 24 May 2022 14:19:42 +0800 Subject: [PATCH] Switch workflow lib - Add APK file universal action - Add modifier for listed applications - Add modifier to show package options from dump task stacks - Check APK signature in background --- scripts/adb_connect.py => adb_connect.py | 0 .../adb_disconnect.py => adb_disconnect.py | 0 scripts/adb_reset.py => adb_reset.py | 0 scripts/adb_wifi.py => adb_wifi.py | 6 +- apk_print_cert.py | 37 + scripts/app_info.py => app_info.py | 0 ...and_history.py => clear_command_history.py | 4 +- scripts/clear_data.py => clear_data.py | 0 scripts/commands.py => commands.py | 0 .../custom_command.py => custom_command.py | 0 ...nd_convert.py => custom_command_convert.py | 0 scripts/demo_mode.py => demo_mode.py | 0 scripts/disable_app.py => disable_app.py | 0 scripts/dump_stack.py => dump_stack.py | 12 +- scripts/execute_input.py => execute_input.py | 0 scripts/extract_apk.py => extract_apk.py | 0 scripts/force_stop.py => force_stop.py | 0 ...inder.applescript => getFinder.applescript | 0 info.plist | 465 ++++--- scripts/install_apk.py => install_apk.py | 0 scripts/ipaddress.py => ipaddress.py | 0 scripts/item.py => item.py | 0 scripts/launch_app.py => launch_app.py | 0 scripts/launch_avd.py => launch_avd.py | 0 ...unch_genymotion.py => launch_genymotion.py | 4 +- scripts/list_apks.py => list_apks.py | 6 +- list_apps.py | 46 + ...mand_history.py => list_command_history.py | 4 +- scripts/list_devices.py => list_devices.py | 8 +- .../list_emulators.py => list_emulators.py | 4 +- .../list_genymotion.py => list_genymotion.py | 4 +- scripts/open_settings.py => open_settings.py | 0 scripts/reboot_device.py => reboot_device.py | 0 .../remove_history.py => remove_history.py | 4 +- .../save_last_func.py => save_last_func.py | 4 +- scripts/screen_shot.py => screen_shot.py | 0 scripts/list_apps.py | 25 - scripts/workflow/Notify.tgz | Bin 35556 -> 0 bytes scripts/workflow/notify.py | 344 ----- scripts/workflow/version | 1 - scripts/workflow/workflow3.py | 767 ----------- self_scripts/runner.py | 8 + ...evice_options.py => show_device_options.py | 4 +- ...tall_options.py => show_install_options.py | 68 +- ...ent_options.py => show_keyevent_options.py | 4 +- ...kage_options.py => show_package_options.py | 4 +- ..._debug_layout.py => toggle_debug_layout.py | 0 ..._gpu_overdraw.py => toggle_gpu_overdraw.py | 0 ...le_gpu_profile.py => toggle_gpu_profile.py | 0 ..._location.py => toggle_pointer_location.py | 0 ...toggle_show_taps.py => toggle_show_taps.py | 0 scripts/toolchain.py => toolchain.py | 0 scripts/uninstall_app.py => uninstall_app.py | 0 ..._wifi_history.py => update_wifi_history.py | 4 +- version | 2 +- workflow/.alfredversionchecked | 0 {scripts/workflow => workflow}/__init__.py | 46 +- {scripts/workflow => workflow}/background.py | 93 +- workflow/notificator | 169 +++ workflow/notify.py | 92 ++ {scripts/workflow => workflow}/update.py | 175 +-- {scripts/workflow => workflow}/util.py | 260 +--- workflow/version | 1 + workflow/web.py | 750 +++++++++++ {scripts/workflow => workflow}/workflow.py | 1175 ++++++++++------- 65 files changed, 2360 insertions(+), 2240 deletions(-) rename scripts/adb_connect.py => adb_connect.py (100%) rename scripts/adb_disconnect.py => adb_disconnect.py (100%) rename scripts/adb_reset.py => adb_reset.py (100%) rename scripts/adb_wifi.py => adb_wifi.py (83%) create mode 100644 apk_print_cert.py rename scripts/app_info.py => app_info.py (100%) rename scripts/clear_command_history.py => clear_command_history.py (90%) rename scripts/clear_data.py => clear_data.py (100%) rename scripts/commands.py => commands.py (100%) rename scripts/custom_command.py => custom_command.py (100%) rename scripts/custom_command_convert.py => custom_command_convert.py (100%) rename scripts/demo_mode.py => demo_mode.py (100%) rename scripts/disable_app.py => disable_app.py (100%) rename scripts/dump_stack.py => dump_stack.py (89%) rename scripts/execute_input.py => execute_input.py (100%) rename scripts/extract_apk.py => extract_apk.py (100%) rename scripts/force_stop.py => force_stop.py (100%) rename scripts/getFinder.applescript => getFinder.applescript (100%) rename scripts/install_apk.py => install_apk.py (100%) rename scripts/ipaddress.py => ipaddress.py (100%) rename scripts/item.py => item.py (100%) rename scripts/launch_app.py => launch_app.py (100%) rename scripts/launch_avd.py => launch_avd.py (100%) rename scripts/launch_genymotion.py => launch_genymotion.py (87%) rename scripts/list_apks.py => list_apks.py (81%) create mode 100644 list_apps.py rename scripts/list_command_history.py => list_command_history.py (91%) rename scripts/list_devices.py => list_devices.py (97%) rename scripts/list_emulators.py => list_emulators.py (95%) rename scripts/list_genymotion.py => list_genymotion.py (95%) rename scripts/open_settings.py => open_settings.py (100%) rename scripts/reboot_device.py => reboot_device.py (100%) rename scripts/remove_history.py => remove_history.py (92%) rename scripts/save_last_func.py => save_last_func.py (93%) rename scripts/screen_shot.py => screen_shot.py (100%) delete mode 100644 scripts/list_apps.py delete mode 100644 scripts/workflow/Notify.tgz delete mode 100644 scripts/workflow/notify.py delete mode 100644 scripts/workflow/version delete mode 100644 scripts/workflow/workflow3.py create mode 100644 self_scripts/runner.py rename scripts/show_device_options.py => show_device_options.py (99%) rename scripts/show_install_options.py => show_install_options.py (88%) rename scripts/show_keyevent_options.py => show_keyevent_options.py (98%) rename scripts/show_package_options.py => show_package_options.py (98%) rename scripts/toggle_debug_layout.py => toggle_debug_layout.py (100%) rename scripts/toggle_gpu_overdraw.py => toggle_gpu_overdraw.py (100%) rename scripts/toggle_gpu_profile.py => toggle_gpu_profile.py (100%) rename scripts/toggle_pointer_location.py => toggle_pointer_location.py (100%) rename scripts/toggle_show_taps.py => toggle_show_taps.py (100%) rename scripts/toolchain.py => toolchain.py (100%) rename scripts/uninstall_app.py => uninstall_app.py (100%) rename scripts/update_wifi_history.py => update_wifi_history.py (96%) create mode 100644 workflow/.alfredversionchecked rename {scripts/workflow => workflow}/__init__.py (73%) rename {scripts/workflow => workflow}/background.py (78%) create mode 100755 workflow/notificator create mode 100644 workflow/notify.py rename {scripts/workflow => workflow}/update.py (76%) rename {scripts/workflow => workflow}/util.py (70%) create mode 100644 workflow/version create mode 100644 workflow/web.py rename {scripts/workflow => workflow}/workflow.py (76%) diff --git a/scripts/adb_connect.py b/adb_connect.py similarity index 100% rename from scripts/adb_connect.py rename to adb_connect.py diff --git a/scripts/adb_disconnect.py b/adb_disconnect.py similarity index 100% rename from scripts/adb_disconnect.py rename to adb_disconnect.py diff --git a/scripts/adb_reset.py b/adb_reset.py similarity index 100% rename from scripts/adb_reset.py rename to adb_reset.py diff --git a/scripts/adb_wifi.py b/adb_wifi.py similarity index 83% rename from scripts/adb_wifi.py rename to adb_wifi.py index 9395670..091c1f1 100644 --- a/scripts/adb_wifi.py +++ b/adb_wifi.py @@ -4,7 +4,7 @@ from workflow.background import run_in_background, is_running from item import Item -from workflow import Workflow3 +from workflow import Workflow from toolchain import run_script from commands import CMD_GET_TCPIP from commands import CMD_TCPIP @@ -19,7 +19,7 @@ def connect(): wifiDevices = [] wifiDevices.append(it) run_in_background("update_wifi_history", - ['/usr/bin/python', wf.workflowfile('scripts/update_wifi_history.py'), 'add', pickle.dumps(wifiDevices)]) + ['/usr/bin/python3', wf.workflowfile('update_wifi_history.py'), 'add', pickle.dumps(wifiDevices)]) print("Executed: " + result) def init(): @@ -35,5 +35,5 @@ def init(): if __name__ == '__main__': if ip: - wf = Workflow3() + wf = Workflow() init() \ No newline at end of file diff --git a/apk_print_cert.py b/apk_print_cert.py new file mode 100644 index 0000000..5d8395f --- /dev/null +++ b/apk_print_cert.py @@ -0,0 +1,37 @@ +import os +import sys +import pipes +from workflow import Workflow +from toolchain import run_script +import subprocess + +adb_path = os.getenv('adb_path') +apkFileOrFolder = os.getenv('apkFile') +aapt_path = os.getenv('aapt_path') +apksigner_path = os.getenv("apksigner_path") + +def main(wf): + + hash = sys.argv[1] + apkPath = pipes.quote(apkFileOrFolder) + cmd = "{0} verify -v --print-certs {1}".format(apksigner_path, apkPath) + + result = "" + verified = False + try: + result = run_script(cmd) + verified = True + except subprocess.CalledProcessError as exc: + log.error("Not verified") + result = exc.output.decode('utf8') + + log.warning(result) + log.warning("result--end") + + wf.cache_data('apk_print_cert' + hash, result+ "\n{}".format(verified)) + + +if __name__ == '__main__': + wf = Workflow() + log = wf.logger + sys.exit(wf.run(main)) \ No newline at end of file diff --git a/scripts/app_info.py b/app_info.py similarity index 100% rename from scripts/app_info.py rename to app_info.py diff --git a/scripts/clear_command_history.py b/clear_command_history.py similarity index 90% rename from scripts/clear_command_history.py rename to clear_command_history.py index ac8f99d..ffe5b5d 100644 --- a/scripts/clear_command_history.py +++ b/clear_command_history.py @@ -1,6 +1,6 @@ import sys import os -from workflow import Workflow3 +from workflow import Workflow def main(wf): @@ -20,6 +20,6 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) \ No newline at end of file diff --git a/scripts/clear_data.py b/clear_data.py similarity index 100% rename from scripts/clear_data.py rename to clear_data.py diff --git a/scripts/commands.py b/commands.py similarity index 100% rename from scripts/commands.py rename to commands.py diff --git a/scripts/custom_command.py b/custom_command.py similarity index 100% rename from scripts/custom_command.py rename to custom_command.py diff --git a/scripts/custom_command_convert.py b/custom_command_convert.py similarity index 100% rename from scripts/custom_command_convert.py rename to custom_command_convert.py diff --git a/scripts/demo_mode.py b/demo_mode.py similarity index 100% rename from scripts/demo_mode.py rename to demo_mode.py diff --git a/scripts/disable_app.py b/disable_app.py similarity index 100% rename from scripts/disable_app.py rename to disable_app.py diff --git a/scripts/dump_stack.py b/dump_stack.py similarity index 89% rename from scripts/dump_stack.py rename to dump_stack.py index 1327da8..a3d28fa 100644 --- a/scripts/dump_stack.py +++ b/dump_stack.py @@ -1,6 +1,6 @@ import os import sys -from workflow import Workflow3 +from workflow import Workflow from toolchain import run_script from commands import CMD_DUMP_STACK @@ -52,12 +52,16 @@ def main(wf): arg=item.name, copytext=item.name, valid=True) - it.add_modifier("cmd", subtitle="Package: " + item.package) + m = it.add_modifier("cmd", subtitle="Package: " + item.package) + m.setvar("package", item.package) + wf.send_feedback() - except: + except Exception as e: log.debug("Error") + log.error(e) + if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) \ No newline at end of file diff --git a/scripts/execute_input.py b/execute_input.py similarity index 100% rename from scripts/execute_input.py rename to execute_input.py diff --git a/scripts/extract_apk.py b/extract_apk.py similarity index 100% rename from scripts/extract_apk.py rename to extract_apk.py diff --git a/scripts/force_stop.py b/force_stop.py similarity index 100% rename from scripts/force_stop.py rename to force_stop.py diff --git a/scripts/getFinder.applescript b/getFinder.applescript similarity index 100% rename from scripts/getFinder.applescript rename to getFinder.applescript diff --git a/info.plist b/info.plist index 3c769b1..c86b2cc 100644 --- a/info.plist +++ b/info.plist @@ -2074,6 +2074,19 @@ + C4BFE506-D83A-4933-96A8-42228EC98C25 + + + destinationuid + B81B0663-A4BF-4BB1-94B2-66752B977EBC + modifiers + 0 + modifiersubtext + + vitoclose + + + C6A84CEC-A533-4892-A67F-8D01904475E4 @@ -2427,6 +2440,16 @@ vitoclose + + destinationuid + C4BFE506-D83A-4933-96A8-42228EC98C25 + modifiers + 1048576 + modifiersubtext + + vitoclose + + destinationuid 16B1106B-C18C-423A-A5AE-BE8676769C18 @@ -2684,6 +2707,19 @@ + FA3DC615-5F82-4781-AA59-C59B8BE51488 + + + destinationuid + ED7185EE-E248-4535-AB02-D48AFC8196B4 + modifiers + 0 + modifiersubtext + + vitoclose + + + FB5790E8-22CF-4B6A-A83D-726346FC7D4F @@ -2734,7 +2770,7 @@ runningsubtext script - python3 scripts/show_device_options.py "{query}" + python3 show_device_options.py "{query}" scriptargtype 0 scriptfile @@ -2814,7 +2850,7 @@ escaping 102 script - python3 scripts/reboot_device.py + python3 reboot_device.py scriptargtype 1 scriptfile @@ -2857,7 +2893,7 @@ runningsubtext script - python3 scripts/list_devices.py "{query}" + python3 list_devices.py "{query}" scriptargtype 0 scriptfile @@ -3046,7 +3082,7 @@ escaping 102 script - python3 scripts/save_last_func.py + python3 save_last_func.py scriptargtype 1 scriptfile @@ -3173,7 +3209,7 @@ escaping 102 script - python3 scripts/toggle_debug_layout.py + python3 toggle_debug_layout.py scriptargtype 1 scriptfile @@ -3326,7 +3362,7 @@ escaping 102 script - python3 scripts/app_info.py + python3 app_info.py scriptargtype 1 scriptfile @@ -3367,7 +3403,7 @@ runningsubtext script - python3 scripts/list_command_history.py + python3 list_command_history.py scriptargtype 1 scriptfile @@ -3396,7 +3432,7 @@ escaping 102 script - python3 scripts/clear_command_history.py + python3 clear_command_history.py scriptargtype 1 scriptfile @@ -3438,7 +3474,7 @@ escaping 102 script - python3 scripts/toggle_pointer_location.py + python3 toggle_pointer_location.py scriptargtype 1 scriptfile @@ -3533,7 +3569,7 @@ escaping 102 script - python3 scripts/adb_reset.py + python3 adb_reset.py scriptargtype 1 scriptfile @@ -3611,7 +3647,7 @@ escaping 102 script - python3 scripts/force_stop.py + python3 force_stop.py scriptargtype 1 scriptfile @@ -3634,7 +3670,7 @@ escaping 102 script - python3 scripts/toggle_show_taps.py + python3 toggle_show_taps.py scriptargtype 1 scriptfile @@ -3695,7 +3731,7 @@ escaping 102 script - python3 scripts/remove_history.py + python3 remove_history.py scriptargtype 1 scriptfile @@ -3754,7 +3790,7 @@ escaping 102 script - python3 scripts/launch_app.py + python3 launch_app.py scriptargtype 1 scriptfile @@ -3777,7 +3813,7 @@ escaping 102 script - python3 scripts/toggle_gpu_profile.py + python3 toggle_gpu_profile.py scriptargtype 1 scriptfile @@ -3857,7 +3893,7 @@ escaping 102 script - python3 scripts/adb_connect.py + python3 adb_connect.py scriptargtype 1 scriptfile @@ -3916,7 +3952,7 @@ escaping 102 script - python3 scripts/clear_data.py + python3 clear_data.py scriptargtype 1 scriptfile @@ -3939,7 +3975,7 @@ escaping 102 script - python3 scripts/toggle_gpu_overdraw.py + python3 toggle_gpu_overdraw.py scriptargtype 1 scriptfile @@ -4038,7 +4074,7 @@ escaping 102 script - python3 scripts/adb_disconnect.py + python3 adb_disconnect.py scriptargtype 1 scriptfile @@ -4092,6 +4128,41 @@ version 1 + + config + + action + 0 + argument + 1 + focusedappvariable + + focusedappvariablename + focused_app + hotkey + 0 + hotmod + 524288 + hotstring + A + leftcursor + + modsmode + 0 + relatedApps + + com.apple.finder + + relatedAppsMode + 1 + + type + alfred.workflow.trigger.hotkey + uid + B986FEEE-E650-46C7-9C86-6DF90497F864 + version + 2 + config @@ -4100,7 +4171,7 @@ escaping 102 script - python3 scripts/disable_app.py + python3 disable_app.py scriptargtype 1 scriptfile @@ -4123,7 +4194,7 @@ escaping 102 script - python3 scripts/screen_shot.py + python3 screen_shot.py scriptargtype 1 scriptfile @@ -4179,72 +4250,39 @@ config - argument - - passthroughargument + inputstring + {query} + matchcasesensitive - variables - - time - {datetime:yyyyMMddHHmmss} - + matchmode + 2 + matchstring + .*.apk$ type - alfred.workflow.utility.argument + alfred.workflow.utility.filter uid - 91DBE388-01F6-45EE-A1E7-3A64000857CB + D3E26F07-2D48-4A3E-881F-0C63876FE144 version 1 config - action - 0 argument - 1 - focusedappvariable - - focusedappvariablename - focused_app - hotkey - 0 - hotmod - 0 - leftcursor - - modsmode - 0 - relatedApps - - com.apple.finder - - relatedAppsMode - 1 - - type - alfred.workflow.trigger.hotkey - uid - B986FEEE-E650-46C7-9C86-6DF90497F864 - version - 2 - - - config - - inputstring - {query} - matchcasesensitive + + passthroughargument - matchmode - 2 - matchstring - .*.apk$ + variables + + time + {datetime:yyyyMMddHHmmss} + type - alfred.workflow.utility.filter + alfred.workflow.utility.argument uid - D3E26F07-2D48-4A3E-881F-0C63876FE144 + 91DBE388-01F6-45EE-A1E7-3A64000857CB version 1 @@ -4277,7 +4315,7 @@ escaping 102 script - python3 scripts/open_settings.py + python3 open_settings.py scriptargtype 1 scriptfile @@ -4292,6 +4330,55 @@ version 2 + + config + + alfredfiltersresults + + alfredfiltersresultsmatchmode + 0 + argumenttreatemptyqueryasnil + + argumenttrimmode + 0 + argumenttype + 1 + escaping + 68 + keyword + apkf + queuedelaycustom + 3 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 2 + runningsubtext + + script + python3 list_apks.py $1 + scriptargtype + 1 + scriptfile + + subtext + + title + + type + 0 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 7568F43B-9DB6-4F18-8F99-44BD327BDE6B + version + 3 + config @@ -4300,7 +4387,7 @@ escaping 102 script - python3 scripts/extract_apk.py + python3 extract_apk.py scriptargtype 1 scriptfile @@ -4422,55 +4509,6 @@ version 1 - - config - - alfredfiltersresults - - alfredfiltersresultsmatchmode - 0 - argumenttreatemptyqueryasnil - - argumenttrimmode - 0 - argumenttype - 1 - escaping - 68 - keyword - apkf - queuedelaycustom - 3 - queuedelayimmediatelyinitially - - queuedelaymode - 0 - queuemode - 2 - runningsubtext - - script - python3 scripts/list_apks.py $1 - scriptargtype - 1 - scriptfile - - subtext - - title - - type - 0 - withspace - - - type - alfred.workflow.input.scriptfilter - uid - 7568F43B-9DB6-4F18-8F99-44BD327BDE6B - version - 3 - config @@ -4517,7 +4555,7 @@ escaping 102 script - python3 scripts/demo_mode.py + python3 demo_mode.py scriptargtype 1 scriptfile @@ -4559,7 +4597,7 @@ escaping 102 script - python3 scripts/uninstall_app.py + python3 uninstall_app.py scriptargtype 1 scriptfile @@ -4574,44 +4612,6 @@ version 2 - - config - - inputstring - {var:function} - matchcasesensitive - - matchmode - 2 - matchstring - ^uninstall_app(:.*)? - - type - alfred.workflow.utility.filter - uid - 0501E974-BEAD-46A7-8183-1ACA16FA8FE7 - version - 1 - - - config - - externaltriggerid - adb - passinputasargument - - passvariables - - workflowbundleid - self - - type - alfred.workflow.output.callexternaltrigger - uid - F9D679BA-BFE0-476F-A2FA-75F71A09F74F - version - 1 - config @@ -4692,6 +4692,44 @@ version 2 + + config + + inputstring + {var:function} + matchcasesensitive + + matchmode + 2 + matchstring + ^uninstall_app(:.*)? + + type + alfred.workflow.utility.filter + uid + 0501E974-BEAD-46A7-8183-1ACA16FA8FE7 + version + 1 + + + config + + externaltriggerid + adb + passinputasargument + + passvariables + + workflowbundleid + self + + type + alfred.workflow.output.callexternaltrigger + uid + F9D679BA-BFE0-476F-A2FA-75F71A09F74F + version + 1 + config @@ -4718,7 +4756,7 @@ runningsubtext script - python3 scripts/show_install_options.py "{query}" + python3 show_install_options.py "{query}" scriptargtype 0 scriptfile @@ -4867,7 +4905,7 @@ escaping 102 script - python3 scripts/adb_wifi.py + python3 adb_wifi.py scriptargtype 1 scriptfile @@ -4882,6 +4920,25 @@ version 2 + + config + + acceptsmulti + 0 + filetypes + + dyn.ah62d4rv4ge80c6dp + + name + Show apk info + + type + alfred.workflow.trigger.action + uid + FA3DC615-5F82-4781-AA59-C59B8BE51488 + version + 1 + config @@ -4926,20 +4983,13 @@ escaping 68 script - import sys -import os - -function = os.getenv('function') -config = os.getenv(function) -path = config.split("|")[1] - -print path + python3 self_scripts/runner.py scriptargtype 1 scriptfile type - 3 + 0 type alfred.workflow.action.script @@ -5059,7 +5109,7 @@ print path runningsubtext script - python3 scripts/list_apps.py "{query}" + python3 list_apps.py "{query}" scriptargtype 0 scriptfile @@ -5106,7 +5156,7 @@ print path runningsubtext script - python3 scripts/show_package_options.py "{query}" + python3 show_package_options.py "{query}" scriptargtype 0 scriptfile @@ -5135,7 +5185,7 @@ print path escaping 0 script - python3 scripts/launch_avd.py "{query}" + python3 launch_avd.py "{query}" scriptargtype 0 scriptfile @@ -5178,7 +5228,7 @@ print path runningsubtext script - python3 scripts/list_emulators.py "{query}" + python3 list_emulators.py "{query}" scriptargtype 0 scriptfile @@ -5318,7 +5368,7 @@ print path escaping 102 script - python3 scripts/install_apk.py + python3 install_apk.py scriptargtype 1 scriptfile @@ -5408,7 +5458,7 @@ print path runningsubtext script - python3 scripts/show_install_options.py "{query}" + python3 show_install_options.py "{query}" scriptargtype 0 scriptfile @@ -5516,7 +5566,7 @@ print path escaping 102 script - python3 scripts/launch_genymotion.py "$1" + python3 launch_genymotion.py "$1" scriptargtype 1 scriptfile @@ -5559,7 +5609,7 @@ print path runningsubtext script - python3 scripts/list_genymotion.py "{query}" + python3 list_genymotion.py "{query}" scriptargtype 0 scriptfile @@ -5636,6 +5686,23 @@ print path version 1 + + config + + argument + + passthroughargument + + variables + + + type + alfred.workflow.utility.argument + uid + C4BFE506-D83A-4933-96A8-42228EC98C25 + version + 1 + config @@ -5681,7 +5748,7 @@ print path runningsubtext script - python3 scripts/dump_stack.py + python3 dump_stack.py scriptargtype 1 scriptfile @@ -5991,7 +6058,7 @@ EOB runningsubtext script - python3 scripts/show_keyevent_options.py "{query}" + python3 show_keyevent_options.py "{query}" scriptargtype 0 scriptfile @@ -6020,7 +6087,7 @@ EOB escaping 0 script - python3 scripts/execute_input.py + python3 execute_input.py scriptargtype 1 scriptfile @@ -6133,7 +6200,7 @@ EOB escaping 102 script - python3 scripts/custom_command.py $1 + python3 custom_command.py $1 scriptargtype 1 scriptfile @@ -6177,7 +6244,7 @@ EOB escaping 102 script - python3 scripts/custom_command_convert.py + python3 custom_command_convert.py scriptargtype 0 scriptfile @@ -6901,7 +6968,7 @@ Use keyword "geny" to list and start Genymotion emulator xpos 250 ypos - 1380 + 1330 3578F438-4C73-4387-844F-2EAEA64F613E @@ -7219,7 +7286,7 @@ Use keyword "geny" to list and start Genymotion emulator xpos 250 ypos - 1220 + 1160 758F2BFC-527A-4C8D-9D3B-000CCF42574B @@ -7639,7 +7706,7 @@ Use keyword "geny" to list and start Genymotion emulator xpos 250 ypos - 1050 + 1000 BB0A60C4-2F87-444B-8938-AEF5F2F2F1A7 @@ -7671,6 +7738,13 @@ Use keyword "geny" to list and start Genymotion emulator ypos 1940 + C4BFE506-D83A-4933-96A8-42228EC98C25 + + xpos + 2180 + ypos + 1880 + C57F7CCE-4AC2-4490-9EB7-DB4D43A6C31B note @@ -7760,7 +7834,7 @@ Use keyword "geny" to list and start Genymotion emulator xpos 420 ypos - 1080 + 1030 D5C1C4B2-9ACD-4611-B432-202822D374CC @@ -7978,6 +8052,13 @@ Use keyword "geny" to list and start Genymotion emulator ypos 1380 + FA3DC615-5F82-4781-AA59-C59B8BE51488 + + xpos + 250 + ypos + 1485 + FB5790E8-22CF-4B6A-A83D-726346FC7D4F note @@ -8030,7 +8111,7 @@ Use keyword "geny" to list and start Genymotion emulator aapt_path version - 1.13.2 + 1.13.3 webaddress https://github.com/zjn0505/adb-alfred diff --git a/scripts/install_apk.py b/install_apk.py similarity index 100% rename from scripts/install_apk.py rename to install_apk.py diff --git a/scripts/ipaddress.py b/ipaddress.py similarity index 100% rename from scripts/ipaddress.py rename to ipaddress.py diff --git a/scripts/item.py b/item.py similarity index 100% rename from scripts/item.py rename to item.py diff --git a/scripts/launch_app.py b/launch_app.py similarity index 100% rename from scripts/launch_app.py rename to launch_app.py diff --git a/scripts/launch_avd.py b/launch_avd.py similarity index 100% rename from scripts/launch_avd.py rename to launch_avd.py diff --git a/scripts/launch_genymotion.py b/launch_genymotion.py similarity index 87% rename from scripts/launch_genymotion.py rename to launch_genymotion.py index 129865c..f58c289 100644 --- a/scripts/launch_genymotion.py +++ b/launch_genymotion.py @@ -1,6 +1,6 @@ import subprocess import sys -from workflow import Workflow3 +from workflow import Workflow from toolchain import run_script @@ -13,6 +13,6 @@ def main(wf): launch_genymotion() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) diff --git a/scripts/list_apks.py b/list_apks.py similarity index 81% rename from scripts/list_apks.py rename to list_apks.py index 606aa45..78ca6e4 100644 --- a/scripts/list_apks.py +++ b/list_apks.py @@ -1,7 +1,7 @@ import subprocess import os import sys -from workflow import Workflow3 +from workflow import Workflow def main(wf): arg = "" @@ -9,7 +9,7 @@ def main(wf): arg = wf.args[0].strip() log.debug(arg) - the_dir = subprocess.check_output(["osascript", "scripts/getFinder.applescript"]).strip().decode("utf-8") + the_dir = subprocess.check_output(["osascript", "getFinder.applescript"]).strip().decode("utf-8") log.debug("dir") log.debug(the_dir) @@ -27,6 +27,6 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) diff --git a/list_apps.py b/list_apps.py new file mode 100644 index 0000000..96c5bab --- /dev/null +++ b/list_apps.py @@ -0,0 +1,46 @@ +import sys +from workflow import Workflow +from toolchain import run_script +from commands import CMD_LIST_APPS +import os + +func = os.getenv("func") + + +def main(wf): + arg = wf.args[0].strip() + apps = run_script(CMD_LIST_APPS) + apps = apps.rstrip().split('\n') + + log.debug(arg) + for app in apps: + if arg is '' or any(x.isupper() for x in arg) and arg in app or arg.islower() and arg in app.lower(): + it = wf.add_item(title=app, + uid=app, + arg=app, + copytext=app, + valid=True) + + if func == '': + m = it.add_modifier('cmd', "Launch") + m.setvar("func", "start_app") + + m = it.add_modifier('alt', "Uninstall") + m.setvar("func", "uninstall_app") + + m = it.add_modifier('ctrl', "Force stop") + m.setvar("func", "force_stop") + + m = it.add_modifier('fn', "Clear data") + m.setvar("func", "clear_app_data") + + m = it.add_modifier('shift', "Show app info") + m.setvar("func", "app_info") + + + wf.send_feedback() + +if __name__ == '__main__': + wf = Workflow() + log = wf.logger + sys.exit(wf.run(main)) \ No newline at end of file diff --git a/scripts/list_command_history.py b/list_command_history.py similarity index 91% rename from scripts/list_command_history.py rename to list_command_history.py index f25e0ca..1b86963 100644 --- a/scripts/list_command_history.py +++ b/list_command_history.py @@ -1,6 +1,6 @@ import sys import os -from workflow import Workflow3 +from workflow import Workflow def main(wf): @@ -21,6 +21,6 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) \ No newline at end of file diff --git a/scripts/list_devices.py b/list_devices.py similarity index 97% rename from scripts/list_devices.py rename to list_devices.py index c3a8279..db4e359 100644 --- a/scripts/list_devices.py +++ b/list_devices.py @@ -4,14 +4,14 @@ import pickle import ipaddress from toolchain import run_script -from workflow import Workflow3 +from workflow import Workflow from workflow.background import run_in_background, is_running from item import Item import hashlib GITHUB_SLUG = 'zjn0505/adb-alfred' VERSION = open(os.path.join(os.path.dirname(__file__), - '../version')).read().strip() + './version')).read().strip() adb_path = os.getenv('adb_path') @@ -92,7 +92,7 @@ def list_devices(args): if wifiDevices: run_in_background("update_wifi_history", ['/usr/bin/python3', - wf.workflowfile('scripts/update_wifi_history.py'), 'add', pickle.dumps(wifiDevices)]) + wf.workflowfile('update_wifi_history.py'), 'add', pickle.dumps(wifiDevices)]) log.error("Save history wifi devices : count : {0}".format(len(wifiDevices))) for item in items: @@ -233,7 +233,7 @@ def main(wf): if __name__ == '__main__': update_settings = {'github_slug': GITHUB_SLUG, 'version': VERSION} - wf = Workflow3(update_settings=update_settings) + wf = Workflow(update_settings=update_settings) log = wf.logger log.debug("Hello from adb") if wf.update_available: diff --git a/scripts/list_emulators.py b/list_emulators.py similarity index 95% rename from scripts/list_emulators.py rename to list_emulators.py index f79ec2c..d2815c6 100644 --- a/scripts/list_emulators.py +++ b/list_emulators.py @@ -1,7 +1,7 @@ import subprocess import os import sys -from workflow import Workflow3 +from workflow import Workflow from toolchain import run_script adb_path = os.getenv('adb_path') @@ -28,7 +28,7 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger if wf.update_available: diff --git a/scripts/list_genymotion.py b/list_genymotion.py similarity index 95% rename from scripts/list_genymotion.py rename to list_genymotion.py index ac9b3ea..1cfe97a 100644 --- a/scripts/list_genymotion.py +++ b/list_genymotion.py @@ -1,6 +1,6 @@ import subprocess import sys -from workflow import Workflow3 +from workflow import Workflow from toolchain import run_script @@ -27,7 +27,7 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger if wf.update_available: diff --git a/scripts/open_settings.py b/open_settings.py similarity index 100% rename from scripts/open_settings.py rename to open_settings.py diff --git a/scripts/reboot_device.py b/reboot_device.py similarity index 100% rename from scripts/reboot_device.py rename to reboot_device.py diff --git a/scripts/remove_history.py b/remove_history.py similarity index 92% rename from scripts/remove_history.py rename to remove_history.py index 40afa81..7744ba1 100644 --- a/scripts/remove_history.py +++ b/remove_history.py @@ -1,7 +1,7 @@ import os import sys import pickle -from workflow import Workflow3 +from workflow import Workflow ip = os.getenv('ip') extra = os.getenv('extra') @@ -23,6 +23,6 @@ def main(wf): print("Connection history removed") if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) \ No newline at end of file diff --git a/scripts/save_last_func.py b/save_last_func.py similarity index 93% rename from scripts/save_last_func.py rename to save_last_func.py index a1fdb82..0a31867 100644 --- a/scripts/save_last_func.py +++ b/save_last_func.py @@ -1,11 +1,11 @@ import os import sys -from workflow import Workflow3 +from workflow import Workflow function = os.getenv("function") serial = os.getenv("serial") his_tag = os.getenv("his_tag") -wf = Workflow3() +wf = Workflow() his = wf.cached_data('last_func:' + his_tag, max_age=0) diff --git a/scripts/screen_shot.py b/screen_shot.py similarity index 100% rename from scripts/screen_shot.py rename to screen_shot.py diff --git a/scripts/list_apps.py b/scripts/list_apps.py deleted file mode 100644 index 2136a62..0000000 --- a/scripts/list_apps.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys -from workflow import Workflow3 -from toolchain import run_script -from commands import CMD_LIST_APPS - -def main(wf): - arg = wf.args[0].strip() - apps = run_script(CMD_LIST_APPS) - apps = apps.rstrip().split('\n') - - log.debug(arg) - for app in apps: - if arg is '' or any(x.isupper() for x in arg) and arg in app or arg.islower() and arg in app.lower(): - wf.add_item(title=app, - uid=app, - arg=app, - copytext=app, - valid=True) - - wf.send_feedback() - -if __name__ == '__main__': - wf = Workflow3() - log = wf.logger - sys.exit(wf.run(main)) \ No newline at end of file diff --git a/scripts/workflow/Notify.tgz b/scripts/workflow/Notify.tgz deleted file mode 100644 index 174e9a7b5c395385905cabf43d625a98acbb407c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35556 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 diff --git a/scripts/workflow/notify.py b/scripts/workflow/notify.py deleted file mode 100644 index fa582f6..0000000 --- a/scripts/workflow/notify.py +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -# -# Copyright (c) 2015 deanishe@deanishe.net -# -# MIT Licence. See http://opensource.org/licenses/MIT -# -# Created on 2015-11-26 -# - -# TODO: Exclude this module from test and code coverage in py2.6 - -""" -Post notifications via the macOS Notification Center. - -This feature is only available on Mountain Lion (10.8) and later. -It will silently fail on older systems. - -The main API is a single function, :func:`~workflow.notify.notify`. - -It works by copying a simple application to your workflow's data -directory. It replaces the application's icon with your workflow's -icon and then calls the application to post notifications. -""" - - -import os -import plistlib -import shutil -import subprocess -import sys -import tarfile -import tempfile -import uuid -from typing import List - -from . import workflow - -_wf = None -_log = None - - -#: Available system sounds from System Preferences > Sound > Sound Effects -SOUNDS = ( - "Basso", - "Blow", - "Bottle", - "Frog", - "Funk", - "Glass", - "Hero", - "Morse", - "Ping", - "Pop", - "Purr", - "Sosumi", - "Submarine", - "Tink", -) - - -def wf(): - """Return Workflow object for this module. - - Returns: - workflow.Workflow: Workflow object for current workflow. - """ - global _wf - if _wf is None: - _wf = workflow.Workflow() - return _wf - - -def log(): - """Return logger for this module. - - Returns: - logging.Logger: Logger for this module. - """ - global _log - if _log is None: - _log = wf().logger - return _log - - -def notifier_program(): - """Return path to notifier applet executable. - - Returns: - unicode: Path to Notify.app ``applet`` executable. - """ - return wf().datafile("Notify.app/Contents/MacOS/applet") - - -def notifier_icon_path(): - """Return path to icon file in installed Notify.app. - - Returns: - unicode: Path to ``applet.icns`` within the app bundle. - """ - return wf().datafile("Notify.app/Contents/Resources/applet.icns") - - -def install_notifier(): - """Extract ``Notify.app`` from the workflow to data directory. - - Changes the bundle ID of the installed app and gives it the - workflow's icon. - """ - archive = os.path.join(os.path.dirname(__file__), "Notify.tgz") - destdir = wf().datadir - app_path = os.path.join(destdir, "Notify.app") - n = notifier_program() - log().debug("installing Notify.app to %r ...", destdir) - # z = zipfile.ZipFile(archive, 'r') - # z.extractall(destdir) - tgz = tarfile.open(archive, "r:gz") - tgz.extractall(destdir) - if not os.path.exists(n): # pragma: nocover - raise RuntimeError("Notify.app could not be installed in " + destdir) - - # Replace applet icon - icon = notifier_icon_path() - workflow_icon = wf().workflowfile("icon.png") - if os.path.exists(icon): - os.unlink(icon) - - png_to_icns(workflow_icon, icon) - - # Set file icon - # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, - # none of this code will "work" on pre-10.8 systems. Let it run - # until I figure out a better way of excluding this module - # from coverage in py2.6. - if sys.version_info >= (2, 7): # pragma: no cover - from AppKit import NSImage, NSWorkspace - - ws = NSWorkspace.sharedWorkspace() - img = NSImage.alloc().init() - img.initWithContentsOfFile_(icon) - ws.setIcon_forFile_options_(img, app_path, 0) - - # Change bundle ID of installed app - ip_path = os.path.join(app_path, "Contents/Info.plist") - bundle_id = "{0}.{1}".format(wf().bundleid, uuid.uuid4().hex) - data = plistlib.readPlist(ip_path) - log().debug("changing bundle ID to %r", bundle_id) - data["CFBundleIdentifier"] = bundle_id - plistlib.writePlist(data, ip_path) - - -def validate_sound(sound): - """Coerce ``sound`` to valid sound name. - - Returns ``None`` for invalid sounds. Sound names can be found - in ``System Preferences > Sound > Sound Effects``. - - Args: - sound (str): Name of system sound. - - Returns: - str: Proper name of sound or ``None``. - """ - if not sound: - return None - - # Case-insensitive comparison of `sound` - if sound.lower() in [s.lower() for s in SOUNDS]: - # Title-case is correct for all system sounds as of macOS 10.11 - return sound.title() - return None - - -def notify(title="", text="", sound=None): - """Post notification via Notify.app helper. - - Args: - title (str, optional): Notification title. - text (str, optional): Notification body text. - sound (str, optional): Name of sound to play. - - Raises: - ValueError: Raised if both ``title`` and ``text`` are empty. - - Returns: - bool: ``True`` if notification was posted, else ``False``. - """ - if title == text == "": - raise ValueError("Empty notification") - - sound = validate_sound(sound) or "" - - n = notifier_program() - - if not os.path.exists(n): - install_notifier() - - env = os.environ.copy() - enc = "utf-8" - env["NOTIFY_TITLE"] = title.encode(enc) - env["NOTIFY_MESSAGE"] = text.encode(enc) - env["NOTIFY_SOUND"] = sound.encode(enc) - cmd = [n] - retcode = subprocess.call(cmd, env=env) - if retcode == 0: - return True - - log().error("Notify.app exited with status {0}.".format(retcode)) - return False - - -def usr_bin_env(*args: str) -> List[str]: - return ["/usr/bin/env", f'PATH={os.environ["PATH"]}'] + list(args) - - -def convert_image(inpath, outpath, size): - """Convert an image file using ``sips``. - - Args: - inpath (str): Path of source file. - outpath (str): Path to destination file. - size (int): Width and height of destination image in pixels. - - Raises: - RuntimeError: Raised if ``sips`` exits with non-zero status. - """ - cmd = ["sips", "-z", str(size), str(size), inpath, "--out", outpath] - # log().debug(cmd) - with open(os.devnull, "w") as pipe: - retcode = subprocess.call( - cmd, shell=True, stdout=pipe, stderr=subprocess.STDOUT - ) - - if retcode != 0: - raise RuntimeError("sips exited with %d" % retcode) - - -def png_to_icns(png_path, icns_path): - """Convert PNG file to ICNS using ``iconutil``. - - Create an iconset from the source PNG file. Generate PNG files - in each size required by macOS, then call ``iconutil`` to turn - them into a single ICNS file. - - Args: - png_path (str): Path to source PNG file. - icns_path (str): Path to destination ICNS file. - - Raises: - RuntimeError: Raised if ``iconutil`` or ``sips`` fail. - """ - tempdir = tempfile.mkdtemp(prefix="aw-", dir=wf().datadir) - - try: - iconset = os.path.join(tempdir, "Icon.iconset") - - if os.path.exists(iconset): # pragma: nocover - raise RuntimeError("iconset already exists: " + iconset) - - os.makedirs(iconset) - - # Copy source icon to icon set and generate all the other - # sizes needed - configs = [] - for i in (16, 32, 128, 256, 512): - configs.append(("icon_{0}x{0}.png".format(i), i)) - configs.append((("icon_{0}x{0}@2x.png".format(i), i * 2))) - - shutil.copy(png_path, os.path.join(iconset, "icon_256x256.png")) - shutil.copy(png_path, os.path.join(iconset, "icon_128x128@2x.png")) - - for name, size in configs: - outpath = os.path.join(iconset, name) - if os.path.exists(outpath): - continue - convert_image(png_path, outpath, size) - - cmd = ["iconutil", "-c", "icns", "-o", icns_path, iconset] - - retcode = subprocess.call(cmd) - if retcode != 0: - raise RuntimeError("iconset exited with %d" % retcode) - - if not os.path.exists(icns_path): # pragma: nocover - raise ValueError("generated ICNS file not found: " + repr(icns_path)) - finally: - try: - shutil.rmtree(tempdir) - except OSError: # pragma: no cover - pass - - -if __name__ == "__main__": # pragma: nocover - # Simple command-line script to test module with - # This won't work on 2.6, as `argparse` isn't available - # by default. - import argparse - from unicodedata import normalize - - def ustr(s): - """Coerce `s` to normalised Unicode.""" - return normalize("NFD", s.decode("utf-8")) - - p = argparse.ArgumentParser() - p.add_argument("-p", "--png", help="PNG image to convert to ICNS.") - p.add_argument( - "-l", "--list-sounds", help="Show available sounds.", action="store_true" - ) - p.add_argument("-t", "--title", help="Notification title.", type=ustr, default="") - p.add_argument( - "-s", "--sound", type=ustr, help="Optional notification sound.", default="" - ) - p.add_argument( - "text", type=ustr, help="Notification body text.", default="", nargs="?" - ) - o = p.parse_args() - - # List available sounds - if o.list_sounds: - for sound in SOUNDS: - print(sound) - sys.exit(0) - - # Convert PNG to ICNS - if o.png: - icns = os.path.join( - os.path.dirname(o.png), - os.path.splitext(os.path.basename(o.png))[0] + ".icns", - ) - - print("converting {0!r} to {1!r} ...".format(o.png, icns), file=sys.stderr) - - if os.path.exists(icns): - raise ValueError("destination file already exists: " + icns) - - png_to_icns(o.png, icns) - sys.exit(0) - - # Post notification - if o.title == o.text == "": - print("ERROR: empty notification.", file=sys.stderr) - sys.exit(1) - else: - notify(o.title, o.text, o.sound) diff --git a/scripts/workflow/version b/scripts/workflow/version deleted file mode 100644 index ebc91b4..0000000 --- a/scripts/workflow/version +++ /dev/null @@ -1 +0,0 @@ -1.40.0 \ No newline at end of file diff --git a/scripts/workflow/workflow3.py b/scripts/workflow/workflow3.py deleted file mode 100644 index 3a06e33..0000000 --- a/scripts/workflow/workflow3.py +++ /dev/null @@ -1,767 +0,0 @@ -# encoding: utf-8 -# -# Copyright (c) 2016 Dean Jackson -# -# MIT Licence. See http://opensource.org/licenses/MIT -# -# Created on 2016-06-25 -# - -"""An Alfred 3+ version of :class:`~workflow.Workflow`. - -:class:`~workflow.Workflow3` supports new features, such as -setting :ref:`workflow-variables` and -:class:`the more advanced modifiers ` supported by Alfred 3+. - -In order for the feedback mechanism to work correctly, it's important -to create :class:`Item3` and :class:`Modifier` objects via the -:meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods -respectively. If you instantiate :class:`Item3` or :class:`Modifier` -objects directly, the current :class:`Workflow3` object won't be aware -of them, and they won't be sent to Alfred when you call -:meth:`Workflow3.send_feedback()`. - -""" - - -import json -import os -import sys - -from .workflow import ICON_WARNING, Workflow - - -class Variables(dict): - """Workflow variables for Run Script actions. - - .. versionadded: 1.26 - - This class allows you to set workflow variables from - Run Script actions. - - It is a subclass of :class:`dict`. - - >>> v = Variables(username='deanishe', password='hunter2') - >>> v.arg = u'output value' - >>> print(v) - - See :ref:`variables-run-script` in the User Guide for more - information. - - Args: - arg (unicode or list, optional): Main output/``{query}``. - **variables: Workflow variables to set. - - In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a - :class:`list` or :class:`tuple`. - - Attributes: - arg (unicode or list): Output value (``{query}``). - In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a - :class:`list` or :class:`tuple`. - config (dict): Configuration for downstream workflow element. - - """ - - def __init__(self, arg=None, **variables): - """Create a new `Variables` object.""" - self.arg = arg - self.config = {} - super(Variables, self).__init__(**variables) - - @property - def obj(self): - """``alfredworkflow`` :class:`dict`.""" - o = {} - if self: - d2 = {} - for k, v in list(self.items()): - d2[k] = v - o["variables"] = d2 - - if self.config: - o["config"] = self.config - - if self.arg is not None: - o["arg"] = self.arg - - return {"alfredworkflow": o} - - def __str__(self): - """Convert to ``alfredworkflow`` JSON object. - - Returns: - unicode: ``alfredworkflow`` JSON object - - """ - if not self and not self.config: - if not self.arg: - return "" - if isinstance(self.arg, str): - return self.arg - - return json.dumps(self.obj) - - -class Modifier(object): - """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. - - Don't use this class directly (as it won't be associated with any - :class:`Item3`), but rather use :meth:`Item3.add_modifier()` - to add modifiers to results. - - >>> it = wf.add_item('Title', 'Subtitle', valid=True) - >>> it.setvar('name', 'default') - >>> m = it.add_modifier('cmd') - >>> m.setvar('name', 'alternate') - - See :ref:`workflow-variables` in the User Guide for more information - and :ref:`example usage `. - - Args: - key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. - subtitle (unicode, optional): Override default subtitle. - arg (unicode, optional): Argument to pass for this modifier. - valid (bool, optional): Override item's validity. - icon (unicode, optional): Filepath/UTI of icon to use - icontype (unicode, optional): Type of icon. See - :meth:`Workflow.add_item() ` - for valid values. - - Attributes: - arg (unicode): Arg to pass to following action. - config (dict): Configuration for a downstream element, such as - a File Filter. - icon (unicode): Filepath/UTI of icon. - icontype (unicode): Type of icon. See - :meth:`Workflow.add_item() ` - for valid values. - key (unicode): Modifier key (see above). - subtitle (unicode): Override item subtitle. - valid (bool): Override item validity. - variables (dict): Workflow variables set by this modifier. - - """ - - def __init__( - self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None - ): - """Create a new :class:`Modifier`. - - Don't use this class directly (as it won't be associated with any - :class:`Item3`), but rather use :meth:`Item3.add_modifier()` - to add modifiers to results. - - Args: - key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. - subtitle (unicode, optional): Override default subtitle. - arg (unicode, optional): Argument to pass for this modifier. - valid (bool, optional): Override item's validity. - icon (unicode, optional): Filepath/UTI of icon to use - icontype (unicode, optional): Type of icon. See - :meth:`Workflow.add_item() ` - for valid values. - - """ - self.key = key - self.subtitle = subtitle - self.arg = arg - self.valid = valid - self.icon = icon - self.icontype = icontype - - self.config = {} - self.variables = {} - - def setvar(self, name, value): - """Set a workflow variable for this Item. - - Args: - name (unicode): Name of variable. - value (unicode): Value of variable. - - """ - self.variables[name] = value - - def getvar(self, name, default=None): - """Return value of workflow variable for ``name`` or ``default``. - - Args: - name (unicode): Variable name. - default (None, optional): Value to return if variable is unset. - - Returns: - unicode or ``default``: Value of variable if set or ``default``. - - """ - return self.variables.get(name, default) - - @property - def obj(self): - """Modifier formatted for JSON serialization for Alfred 3. - - Returns: - dict: Modifier for serializing to JSON. - - """ - o = {} - - if self.subtitle is not None: - o["subtitle"] = self.subtitle - - if self.arg is not None: - o["arg"] = self.arg - - if self.valid is not None: - o["valid"] = self.valid - - if self.variables: - o["variables"] = self.variables - - if self.config: - o["config"] = self.config - - icon = self._icon() - if icon: - o["icon"] = icon - - return o - - def _icon(self): - """Return `icon` object for item. - - Returns: - dict: Mapping for item `icon` (may be empty). - - """ - icon = {} - if self.icon is not None: - icon["path"] = self.icon - - if self.icontype is not None: - icon["type"] = self.icontype - - return icon - - -class Item3(object): - """Represents a feedback item for Alfred 3+. - - Generates Alfred-compliant JSON for a single item. - - Don't use this class directly (as it then won't be associated with - any :class:`Workflow3 ` object), but rather use - :meth:`Workflow3.add_item() `. - See :meth:`~workflow.Workflow3.add_item` for details of arguments. - - """ - - def __init__( - self, - title, - subtitle="", - arg=None, - autocomplete=None, - match=None, - valid=False, - uid=None, - icon=None, - icontype=None, - type=None, - largetext=None, - copytext=None, - quicklookurl=None, - ): - """Create a new :class:`Item3` object. - - Use same arguments as for - :class:`Workflow.Item `. - - Argument ``subtitle_modifiers`` is not supported. - - """ - self.title = title - self.subtitle = subtitle - self.arg = arg - self.autocomplete = autocomplete - self.match = match - self.valid = valid - self.uid = uid - self.icon = icon - self.icontype = icontype - self.type = type - self.quicklookurl = quicklookurl - self.largetext = largetext - self.copytext = copytext - - self.modifiers = {} - - self.config = {} - self.variables = {} - - def setvar(self, name, value): - """Set a workflow variable for this Item. - - Args: - name (unicode): Name of variable. - value (unicode): Value of variable. - - """ - self.variables[name] = value - - def getvar(self, name, default=None): - """Return value of workflow variable for ``name`` or ``default``. - - Args: - name (unicode): Variable name. - default (None, optional): Value to return if variable is unset. - - Returns: - unicode or ``default``: Value of variable if set or ``default``. - - """ - return self.variables.get(name, default) - - def add_modifier( - self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None - ): - """Add alternative values for a modifier key. - - Args: - key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` - subtitle (unicode, optional): Override item subtitle. - arg (unicode, optional): Input for following action. - valid (bool, optional): Override item validity. - icon (unicode, optional): Filepath/UTI of icon. - icontype (unicode, optional): Type of icon. See - :meth:`Workflow.add_item() ` - for valid values. - - In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a - :class:`list` or :class:`tuple`. - - Returns: - Modifier: Configured :class:`Modifier`. - - """ - mod = Modifier(key, subtitle, arg, valid, icon, icontype) - - # Add Item variables to Modifier - mod.variables.update(self.variables) - - self.modifiers[key] = mod - - return mod - - @property - def obj(self): - """Item formatted for JSON serialization. - - Returns: - dict: Data suitable for Alfred 3 feedback. - - """ - # Required values - o = {"title": self.title, "subtitle": self.subtitle, "valid": self.valid} - - # Optional values - if self.arg is not None: - o["arg"] = self.arg - - if self.autocomplete is not None: - o["autocomplete"] = self.autocomplete - - if self.match is not None: - o["match"] = self.match - - if self.uid is not None: - o["uid"] = self.uid - - if self.type is not None: - o["type"] = self.type - - if self.quicklookurl is not None: - o["quicklookurl"] = self.quicklookurl - - if self.variables: - o["variables"] = self.variables - - if self.config: - o["config"] = self.config - - # Largetype and copytext - text = self._text() - if text: - o["text"] = text - - icon = self._icon() - if icon: - o["icon"] = icon - - # Modifiers - mods = self._modifiers() - if mods: - o["mods"] = mods - - return o - - def _icon(self): - """Return `icon` object for item. - - Returns: - dict: Mapping for item `icon` (may be empty). - - """ - icon = {} - if self.icon is not None: - icon["path"] = self.icon - - if self.icontype is not None: - icon["type"] = self.icontype - - return icon - - def _text(self): - """Return `largetext` and `copytext` object for item. - - Returns: - dict: `text` mapping (may be empty) - - """ - text = {} - if self.largetext is not None: - text["largetype"] = self.largetext - - if self.copytext is not None: - text["copy"] = self.copytext - - return text - - def _modifiers(self): - """Build `mods` dictionary for JSON feedback. - - Returns: - dict: Modifier mapping or `None`. - - """ - if self.modifiers: - mods = {} - for k, mod in list(self.modifiers.items()): - mods[k] = mod.obj - - return mods - - return None - - -class Workflow3(Workflow): - """Workflow class that generates Alfred 3+ feedback. - - It is a subclass of :class:`~workflow.Workflow` and most of its - methods are documented there. - - Attributes: - item_class (class): Class used to generate feedback items. - variables (dict): Top level workflow variables. - - """ - - item_class = Item3 - - def __init__(self, **kwargs): - """Create a new :class:`Workflow3` object. - - See :class:`~workflow.Workflow` for documentation. - - """ - Workflow.__init__(self, **kwargs) - self.variables = {} - self._rerun = 0 - # Get session ID from environment if present - self._session_id = os.getenv("_WF_SESSION_ID") or None - if self._session_id: - self.setvar("_WF_SESSION_ID", self._session_id) - - @property - def _default_cachedir(self): - """Alfred 4's default cache directory.""" - return os.path.join( - os.path.expanduser( - "~/Library/Caches/com.runningwithcrayons.Alfred/" "Workflow Data/" - ), - self.bundleid, - ) - - @property - def _default_datadir(self): - """Alfred 4's default data directory.""" - return os.path.join( - os.path.expanduser("~/Library/Application Support/Alfred/Workflow Data/"), - self.bundleid, - ) - - @property - def rerun(self): - """How often (in seconds) Alfred should re-run the Script Filter.""" - return self._rerun - - @rerun.setter - def rerun(self, seconds): - """Interval at which Alfred should re-run the Script Filter. - - Args: - seconds (int): Interval between runs. - """ - self._rerun = seconds - - @property - def session_id(self): - """A unique session ID every time the user uses the workflow. - - .. versionadded:: 1.25 - - The session ID persists while the user is using this workflow. - It expires when the user runs a different workflow or closes - Alfred. - - """ - if not self._session_id: - from uuid import uuid4 - - self._session_id = uuid4().hex - self.setvar("_WF_SESSION_ID", self._session_id) - - return self._session_id - - def setvar(self, name, value, persist=False): - """Set a "global" workflow variable. - - .. versionchanged:: 1.33 - - These variables are always passed to downstream workflow objects. - - If you have set :attr:`rerun`, these variables are also passed - back to the script when Alfred runs it again. - - Args: - name (unicode): Name of variable. - value (unicode): Value of variable. - persist (bool, optional): Also save variable to ``info.plist``? - - """ - self.variables[name] = value - if persist: - from .util import set_config - - set_config(name, value, self.bundleid) - self.logger.debug( - "saved variable %r with value %r to info.plist", name, value - ) - - def getvar(self, name, default=None): - """Return value of workflow variable for ``name`` or ``default``. - - Args: - name (unicode): Variable name. - default (None, optional): Value to return if variable is unset. - - Returns: - unicode or ``default``: Value of variable if set or ``default``. - - """ - return self.variables.get(name, default) - - def add_item( - self, - title, - subtitle="", - arg=None, - autocomplete=None, - valid=False, - uid=None, - icon=None, - icontype=None, - type=None, - largetext=None, - copytext=None, - quicklookurl=None, - match=None, - ): - """Add an item to be output to Alfred. - - Args: - match (unicode, optional): If you have "Alfred filters results" - turned on for your Script Filter, Alfred (version 3.5 and - above) will filter against this field, not ``title``. - - In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a - :class:`list` or :class:`tuple`. - - See :meth:`Workflow.add_item() ` for - the main documentation and other parameters. - - The key difference is that this method does not support the - ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` - method instead on the returned item instead. - - Returns: - Item3: Alfred feedback item. - - """ - item = self.item_class( - title, - subtitle, - arg, - autocomplete, - match, - valid, - uid, - icon, - icontype, - type, - largetext, - copytext, - quicklookurl, - ) - - # Add variables to child item - item.variables.update(self.variables) - - self._items.append(item) - return item - - @property - def _session_prefix(self): - """Filename prefix for current session.""" - return "_wfsess-{0}-".format(self.session_id) - - def _mk_session_name(self, name): - """New cache name/key based on session ID.""" - return self._session_prefix + name - - def cache_data(self, name, data, session=False): - """Cache API with session-scoped expiry. - - .. versionadded:: 1.25 - - Args: - name (str): Cache key - data (object): Data to cache - session (bool, optional): Whether to scope the cache - to the current session. - - ``name`` and ``data`` are the same as for the - :meth:`~workflow.Workflow.cache_data` method on - :class:`~workflow.Workflow`. - - If ``session`` is ``True``, then ``name`` is prefixed - with :attr:`session_id`. - - """ - if session: - name = self._mk_session_name(name) - - return super(Workflow3, self).cache_data(name, data) - - def cached_data(self, name, data_func=None, max_age=60, session=False): - """Cache API with session-scoped expiry. - - .. versionadded:: 1.25 - - Args: - name (str): Cache key - data_func (callable): Callable that returns fresh data. It - is called if the cache has expired or doesn't exist. - max_age (int): Maximum allowable age of cache in seconds. - session (bool, optional): Whether to scope the cache - to the current session. - - ``name``, ``data_func`` and ``max_age`` are the same as for the - :meth:`~workflow.Workflow.cached_data` method on - :class:`~workflow.Workflow`. - - If ``session`` is ``True``, then ``name`` is prefixed - with :attr:`session_id`. - - """ - if session: - name = self._mk_session_name(name) - - return super(Workflow3, self).cached_data(name, data_func, max_age) - - def clear_session_cache(self, current=False): - """Remove session data from the cache. - - .. versionadded:: 1.25 - .. versionchanged:: 1.27 - - By default, data belonging to the current session won't be - deleted. Set ``current=True`` to also clear current session. - - Args: - current (bool, optional): If ``True``, also remove data for - current session. - - """ - - def _is_session_file(filename): - if current: - return filename.startswith("_wfsess-") - return filename.startswith("_wfsess-") and not filename.startswith( - self._session_prefix - ) - - self.clear_cache(_is_session_file) - - @property - def obj(self): - """Feedback formatted for JSON serialization. - - Returns: - dict: Data suitable for Alfred 3 feedback. - - """ - items = [] - for item in self._items: - items.append(item.obj) - - o = {"items": items} - if self.variables: - o["variables"] = self.variables - if self.rerun: - o["rerun"] = self.rerun - return o - - def warn_empty(self, title, subtitle="", icon=None): - """Add a warning to feedback if there are no items. - - .. versionadded:: 1.31 - - Add a "warning" item to Alfred feedback if no other items - have been added. This is a handy shortcut to prevent Alfred - from showing its fallback searches, which is does if no - items are returned. - - Args: - title (unicode): Title of feedback item. - subtitle (unicode, optional): Subtitle of feedback item. - icon (str, optional): Icon for feedback item. If not - specified, ``ICON_WARNING`` is used. - - Returns: - Item3: Newly-created item. - - """ - if len(self._items): - return - - icon = icon or ICON_WARNING - return self.add_item(title, subtitle, icon=icon) - - def send_feedback(self): - """Print stored items to console/Alfred as JSON.""" - if self.debugging: - json.dump(self.obj, sys.stdout, indent=2, separators=(",", ": ")) - else: - json.dump(self.obj, sys.stdout) - sys.stdout.flush() diff --git a/self_scripts/runner.py b/self_scripts/runner.py new file mode 100644 index 0000000..b8c52ad --- /dev/null +++ b/self_scripts/runner.py @@ -0,0 +1,8 @@ +import sys +import os + +function = os.getenv('function') +config = os.getenv(function) +path = config.split("|")[1] + +print (path) \ No newline at end of file diff --git a/scripts/show_device_options.py b/show_device_options.py similarity index 99% rename from scripts/show_device_options.py rename to show_device_options.py index 9e09c25..a95649d 100644 --- a/scripts/show_device_options.py +++ b/show_device_options.py @@ -2,7 +2,7 @@ import os import sys import re -from workflow import Workflow3 +from workflow import Workflow adb_path = os.getenv('adb_path') serial = os.getenv('serial') @@ -269,6 +269,6 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) diff --git a/scripts/show_install_options.py b/show_install_options.py similarity index 88% rename from scripts/show_install_options.py rename to show_install_options.py index 58cdffc..79e2e98 100644 --- a/scripts/show_install_options.py +++ b/show_install_options.py @@ -3,13 +3,15 @@ import sys import re import pipes -from workflow import Workflow3, ICON_INFO, ICON_ERROR, ICON_WARNING +from workflow import Workflow, ICON_INFO, ICON_NOTE, ICON_ERROR, ICON_WARNING from toolchain import run_script +from workflow.background import is_running, run_in_background +import hashlib adb_path = os.getenv('adb_path') aapt_path = os.getenv('aapt_path') serial = os.getenv('serial') -apkFileOrFolder = pipes.quote(os.getenv('apkFile')) +apkFileOrFolder = os.getenv('apkFile') deviceApi = os.getenv('device_api') apksigner_path = os.getenv("apksigner_path") @@ -60,17 +62,20 @@ def showApkInstallItems(): arg = wf.args[0].strip() - log.debug("Path {0}".format(apkFileOrFolder)) + apkPath = pipes.quote(apkFileOrFolder) + log.debug("Path {0}".format(apkPath)) apk = None if not aapt_path: - head, tail = os.path.split(apkFileOrFolder) - wf.add_item(title=tail, subtitle=apkFileOrFolder, copytext=tail, arg=apkFileOrFolder, valid=True) + head, tail = os.path.split(apkPath) + wf.add_item(title=tail, subtitle=apkPath, copytext=tail, arg=apkPath, valid=True) wf.add_item(title="aapt not found", subtitle="Please config 'aapt_path' in workflow settings for richer APK info", valid=False, icon=ICON_WARNING) else: - cmd = "{0} dump badging {1} | grep 'package:\|application-label:\|dkVersion:\|uses-permission:\|application-debuggable\|testOnly='".format(aapt_path, apkFileOrFolder) + cmd = "{0} dump badging {1} | grep 'package:\|application-label:\|dkVersion:\|uses-permission:\|application-debuggable\|testOnly='".format(aapt_path, apkPath) result = run_script(cmd) + log.debug("result" + result) if result: + log.debug("Show results") log.debug(result) infos = result.rstrip().split('\n') if infos: @@ -115,9 +120,9 @@ def showApkInstallItems(): elif info.startswith("versionName="): apk["versionName"] = info.split("'")[1] - if deviceApi and "min" in apk and deviceApi < apk["min"]: + if deviceApi and "min" in apk and int(deviceApi) < apk["min"]: validApkByApiCheck = False - if deviceApi and "max" in apk and deviceApi > apk["maxs"]: + if deviceApi and "max" in apk and int(deviceApi) > apk["maxs"]: validApkByApiCheck = False currentApkResult = "" @@ -175,9 +180,9 @@ def showApkInstallItems(): wf.setvar("version_name", apk["versionName"]) wf.setvar("app_name", apk['label']) - if deviceApi and "min" in apk and deviceApi < apk["min"]: + if deviceApi and "min" in apk and int(deviceApi) < apk["min"]: wf.add_item(title="Incompatiable device", subtitle="current device api level is {1}, lower than apk minSdkVersion {0}, ".format(deviceApi, apk["min"]), icon=ICON_ERROR, valid=False) - if deviceApi and "max" in apk and deviceApi > apk["maxs"]: + if deviceApi and "max" in apk and int(deviceApi) > apk["maxs"]: wf.add_item(title="Incompatiable device", subtitle="current device api level is {1}, higher than apk maxSdkVersion {0}, ".format(deviceApi, apk["max"]), icon=ICON_ERROR, valid=False) @@ -208,6 +213,7 @@ def main(wf): log.debug(arg) apk = None fileCount = 1 + if os.path.isdir(apkFileOrFolder): if os.getenv('focused_app') != None: wf.warn_empty(title="Can't open a folder with hotkey, try apk files.") @@ -257,16 +263,22 @@ def main(wf): # add "-" to apksigner path will disable signature check if (apksigner_path != None and apksigner_path != "" and (not apksigner_path.startswith("-")) and apk != None): log.debug("Apksigner path " + apksigner_path) + + + # Unconditionally load data from cache + + hash = hashlib.md5(apkFileOrFolder.encode("utf-8")).hexdigest() + result = wf.cached_data('apk_print_cert' + hash, max_age=0) + + if not wf.cached_data_fresh('apk_print_cert' + hash, max_age=30): + run_in_background('apk_dump', ['/usr/bin/python3', + wf.workflowfile('apk_print_cert.py'), hash]) + cmd = "{0} verify -v --print-certs {1}".format(apksigner_path, apkFileOrFolder) - log.debug(cmd) - result = "" - verified = False - try: - result = run_script(cmd) - verified = True - except subprocess.CalledProcessError as exc: - log.error("Not verified") - result = exc.output.decode('utf8') + log.debug("Hellp " + cmd) + + log.debug(result) + if result: log.debug(result) infos = result.rstrip().split('\n') @@ -315,12 +327,14 @@ def main(wf): subtitle = "Scheme V1 {0}, V2 {1}, V3 {2} V4 {3}".format(v1Verified, v2Verified, v3Verified, v4Verified) else: subtitle = "Scheme V1 {0}, V2 {1}, V3 {2}".format(v1Verified, v2Verified, v3Verified) - if verified: + if result.endswith("True") == True: + log.error("Cert verified") wf.add_item(title=title, subtitle=subtitle, icon=ICON_INFO, valid=False) else: + log.error("Cert not verified") if len(error) > 0: subtitle = error[0] - wf.add_item(title=title, subtitle=subtitle, icon=ICON_ERROR, valid=False) + wf.add_item(title=title, subtitle=subtitle, icon=ICON_ERROR, valid=False, copytext=subtitle) log.error(infos) log.debug(signer) for i in range(len(signer)): @@ -348,7 +362,8 @@ def main(wf): subtitle="with script: %s" % path, arg=path, valid=True) - it.setvar("package", apk["packName"]) + if apk and apk["packName"]: + it.setvar("package", apk["packName"]) it.setvar("self_script_app", config) mod = it.add_modifier("cmd", subtitle="apply cmd modifier") mod.setvar("mod", "cmd") @@ -363,10 +378,17 @@ def main(wf): idx = idx + 1 else: idx = -1 + + if is_running('apk_dump'): + wf.rerun = 1 + if result: + wf.add_item('Updating APK certs...', subtitle="Please wait a bit for the results", icon=ICON_NOTE) + else: + wf.add_item('Checking APK certs...', subtitle="Please wait a bit for the results", icon=ICON_NOTE) wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) diff --git a/scripts/show_keyevent_options.py b/show_keyevent_options.py similarity index 98% rename from scripts/show_keyevent_options.py rename to show_keyevent_options.py index 866bb72..a17ecee 100644 --- a/scripts/show_keyevent_options.py +++ b/show_keyevent_options.py @@ -1,6 +1,6 @@ import re import sys -from workflow import Workflow3 +from workflow import Workflow from toolchain import run_script from commands import CMD_CHECK_KEYBOARD @@ -130,6 +130,6 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) diff --git a/scripts/show_package_options.py b/show_package_options.py similarity index 98% rename from scripts/show_package_options.py rename to show_package_options.py index 9c4e789..8a1bcad 100644 --- a/scripts/show_package_options.py +++ b/show_package_options.py @@ -1,7 +1,7 @@ import subprocess import os import sys -from workflow import Workflow3, ICON_INFO +from workflow import Workflow, ICON_INFO from toolchain import run_script from commands import CMD_DUMP_PACKAGE @@ -124,6 +124,6 @@ def main(wf): wf.send_feedback() if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) diff --git a/scripts/toggle_debug_layout.py b/toggle_debug_layout.py similarity index 100% rename from scripts/toggle_debug_layout.py rename to toggle_debug_layout.py diff --git a/scripts/toggle_gpu_overdraw.py b/toggle_gpu_overdraw.py similarity index 100% rename from scripts/toggle_gpu_overdraw.py rename to toggle_gpu_overdraw.py diff --git a/scripts/toggle_gpu_profile.py b/toggle_gpu_profile.py similarity index 100% rename from scripts/toggle_gpu_profile.py rename to toggle_gpu_profile.py diff --git a/scripts/toggle_pointer_location.py b/toggle_pointer_location.py similarity index 100% rename from scripts/toggle_pointer_location.py rename to toggle_pointer_location.py diff --git a/scripts/toggle_show_taps.py b/toggle_show_taps.py similarity index 100% rename from scripts/toggle_show_taps.py rename to toggle_show_taps.py diff --git a/scripts/toolchain.py b/toolchain.py similarity index 100% rename from scripts/toolchain.py rename to toolchain.py diff --git a/scripts/uninstall_app.py b/uninstall_app.py similarity index 100% rename from scripts/uninstall_app.py rename to uninstall_app.py diff --git a/scripts/update_wifi_history.py b/update_wifi_history.py similarity index 96% rename from scripts/update_wifi_history.py rename to update_wifi_history.py index 410b731..a60227d 100644 --- a/scripts/update_wifi_history.py +++ b/update_wifi_history.py @@ -2,7 +2,7 @@ import sys import re import pickle -from workflow import Workflow3 +from workflow import Workflow from item import Item import subprocess @@ -44,6 +44,6 @@ def main(wf): if __name__ == '__main__': - wf = Workflow3() + wf = Workflow() log = wf.logger sys.exit(wf.run(main)) diff --git a/version b/version index f0df1f7..d9ee657 100644 --- a/version +++ b/version @@ -1 +1 @@ -1.13.2 \ No newline at end of file +1.13.3 \ No newline at end of file diff --git a/workflow/.alfredversionchecked b/workflow/.alfredversionchecked new file mode 100644 index 0000000..e69de29 diff --git a/scripts/workflow/__init__.py b/workflow/__init__.py similarity index 73% rename from scripts/workflow/__init__.py rename to workflow/__init__.py index f93fb60..f539cd0 100644 --- a/scripts/workflow/__init__.py +++ b/workflow/__init__.py @@ -1,21 +1,15 @@ -#!/usr/bin/env python -# encoding: utf-8 -# -# Copyright (c) 2014 Dean Jackson -# -# MIT Licence. See http://opensource.org/licenses/MIT -# -# Created on 2014-02-15 -# +#!/usr/bin/env python3 """A helper library for `Alfred `_ workflows.""" - import os -# Filter matching rules -# Icons -# Exceptions # Workflow objects +from .workflow import Workflow, manager, Variables + +# Exceptions +from .workflow import PasswordNotFound, KeychainError + +# Icons from .workflow import ( ICON_ACCOUNT, ICON_BURN, @@ -38,8 +32,12 @@ ICON_SYNC, ICON_TRASH, ICON_USER, - ICON_WARNING, ICON_WEB, + ICON_WARNING, +) + +# Filter matching rules +from .workflow import ( MATCH_ALL, MATCH_ALLCHARS, MATCH_ATOM, @@ -49,23 +47,20 @@ MATCH_INITIALS_STARTSWITH, MATCH_STARTSWITH, MATCH_SUBSTRING, - KeychainError, - PasswordNotFound, - Workflow, - manager, ) -from .workflow3 import Variables, Workflow3 -__title__ = "Alfred-Workflow" -__version__ = open(os.path.join(os.path.dirname(__file__), "version")).read() -__author__ = "Dean Jackson" -__licence__ = "MIT" -__copyright__ = "Copyright 2014-2019 Dean Jackson" +# pylint: disable=consider-using-with +__version__ = open( + os.path.join(os.path.dirname(__file__), "version"), encoding="utf-8" +).read() +__title__ = "Alpynist" +__author__ = "Arthur Pinheiro" +__license__ = "MIT" +__copyright__ = "Copyright 2022 Arthur Pinheiro" __all__ = [ "Variables", "Workflow", - "Workflow3", "manager", "PasswordNotFound", "KeychainError", @@ -90,7 +85,6 @@ "ICON_SYNC", "ICON_TRASH", "ICON_USER", - "ICON_WARNING", "ICON_WEB", "MATCH_ALL", "MATCH_ALLCHARS", diff --git a/scripts/workflow/background.py b/workflow/background.py similarity index 78% rename from scripts/workflow/background.py rename to workflow/background.py index 2001856..2035c4b 100644 --- a/scripts/workflow/background.py +++ b/workflow/background.py @@ -1,11 +1,4 @@ -# encoding: utf-8 -# -# Copyright (c) 2014 deanishe@deanishe.net -# -# MIT Licence. See http://opensource.org/licenses/MIT -# -# Created on 2014-04-06 -# +#!/usr/bin/env python3 """This module provides an API to run commands in background processes. @@ -16,7 +9,6 @@ and examples. """ - import os import pickle import signal @@ -31,6 +23,7 @@ def wf(): + """Lazy `Workflow` object.""" global _wf if _wf is None: _wf = Workflow() @@ -45,9 +38,9 @@ def _arg_cache(name): """Return path to pickle cache file for arguments. :param name: name of task - :type name: ``unicode`` + :type name: ``str`` :returns: Path to cache file - :rtype: ``unicode`` filepath + :rtype: ``str`` filepath """ return wf().cachefile(name + ".argcache") @@ -57,9 +50,9 @@ def _pid_file(name): """Return path to PID file for ``name``. :param name: name of task - :type name: ``unicode`` + :type name: ``str`` :returns: Path to PID file for task - :rtype: ``unicode`` filepath + :rtype: ``str`` filepath """ return wf().cachefile(name + ".pid") @@ -91,26 +84,24 @@ def _job_pid(name): int: PID of job process (or `None` if job doesn't exist). """ pidfile = _pid_file(name) - if not os.path.exists(pidfile): - return - with open(pidfile, "rb") as fp: - read = fp.read() - # print(str(read)) - pid = int.from_bytes(read, sys.byteorder) - # print(pid) + if os.path.exists(pidfile): + with open(pidfile, "rb") as f: + read = f.read() + pid = int.from_bytes(read, sys.byteorder) - if _process_exists(pid): - return pid + if _process_exists(pid): + return pid - os.unlink(pidfile) + os.unlink(pidfile) + return None def is_running(name): """Test whether task ``name`` is currently running. :param name: name of task - :type name: unicode + :type name: str :returns: ``True`` if task with name ``name`` is running, else ``False`` :rtype: bool @@ -143,8 +134,8 @@ def _fork_and_exit_parent(errmsg, wait=False, write=False): if pid > 0: if write: # write PID of child process to `pidfile` tmp = pidfile + ".tmp" - with open(tmp, "wb") as fp: - fp.write(pid.to_bytes(4, sys.byteorder)) + with open(tmp, "wb") as f: + f.write(pid.to_bytes(4, sys.byteorder)) os.rename(tmp, pidfile) if wait: # wait for child process to exit os.waitpid(pid, 0) @@ -165,22 +156,22 @@ def _fork_and_exit_parent(errmsg, wait=False, write=False): # Now I am a daemon! # Redirect standard file descriptors. - si = open(stdin, "r", 1) - so = open(stdout, "a+", 1) - se = open(stderr, "a+", 1) - if hasattr(sys.stdin, "fileno"): - os.dup2(si.fileno(), sys.stdin.fileno()) - if hasattr(sys.stdout, "fileno"): - os.dup2(so.fileno(), sys.stdout.fileno()) - if hasattr(sys.stderr, "fileno"): - os.dup2(se.fileno(), sys.stderr.fileno()) + with open(stdin, "r", 1, encoding="utf-8") as stdin_fd: + if hasattr(sys.stdin, "fileno"): + os.dup2(stdin_fd.fileno(), sys.stdin.fileno()) + + with open(stdout, "a+", 1, encoding="utf-8") as stdout_fd: + if hasattr(sys.stdout, "fileno"): + os.dup2(stdout_fd.fileno(), sys.stdout.fileno()) + + with open(stderr, "a+", 1, encoding="utf-8") as stderr_fd: + if hasattr(sys.stderr, "fileno"): + os.dup2(stderr_fd.fileno(), sys.stderr.fileno()) def kill(name, sig=signal.SIGTERM): """Send a signal to job ``name`` via :func:`os.kill`. - .. versionadded:: 1.29 - Args: name (str): Name of the job sig (int, optional): Signal to send (default: SIGTERM) @@ -197,12 +188,12 @@ def kill(name, sig=signal.SIGTERM): def run_in_background(name, args, **kwargs): - r"""Cache arguments then call this script again via :func:`subprocess.call`. + r"""Cache arguments then call this script again via :func:`subprocess.run`. :param name: name of job - :type name: unicode - :param args: arguments passed as first argument to :func:`subprocess.call` - :param \**kwargs: keyword arguments to :func:`subprocess.call` + :type name: str + :param args: arguments passed as first argument to :func:`subprocess.run` + :param \**kwargs: keyword arguments to :func:`subprocess.run` :returns: exit code of sub-process :rtype: int @@ -223,19 +214,19 @@ def run_in_background(name, args, **kwargs): """ if is_running(name): _log().info("[%s] job already running", name) - return + return None argcache = _arg_cache(name) # Cache arguments - with open(argcache, "wb") as fp: - pickle.dump({"args": args, "kwargs": kwargs}, fp) + with open(argcache, "wb") as f: + pickle.dump({"args": args, "kwargs": kwargs}, f) _log().debug("[%s] command cached: %s", name, argcache) # Call this script - cmd = [sys.executable, "-m", "workflow.background", name] + cmd = ["/usr/bin/python3", "-m", "workflow.background", name] _log().debug("[%s] passing job to background runner: %r", name, cmd) - retcode = subprocess.call(cmd) + retcode = subprocess.run(cmd, check=True).returncode if retcode: # pragma: no cover _log().error("[%s] background runner failed with %d", name, retcode) @@ -249,14 +240,14 @@ def main(wf): # pragma: no cover """Run command in a background process. Load cached arguments, fork into background, then call - :meth:`subprocess.call` with cached arguments. + :meth:`subprocess.run` with cached arguments. """ log = wf.logger name = wf.args[0] argcache = _arg_cache(name) if not os.path.exists(argcache): - msg = "[{0}] command cache not found: {1}".format(name, argcache) + msg = f"[{name}] command cache not found: {argcache}" log.critical(msg) raise IOError(msg) @@ -265,8 +256,8 @@ def main(wf): # pragma: no cover _background(pidfile) # Load cached arguments - with open(argcache, "rb") as fp: - data = pickle.load(fp) + with open(argcache, "rb") as f: + data = pickle.load(f) # Cached arguments args = data["args"] @@ -279,7 +270,7 @@ def main(wf): # pragma: no cover # Run the command log.debug("[%s] running command: %r", name, args) - retcode = subprocess.call(args, **kwargs) + retcode = subprocess.run(args, **kwargs, check=True).returncode if retcode: log.error("[%s] command failed with status %d", name, retcode) diff --git a/workflow/notificator b/workflow/notificator new file mode 100755 index 0000000..1d5d646 --- /dev/null +++ b/workflow/notificator @@ -0,0 +1,169 @@ +#!/bin/zsh + +#################################################### +### Created by Vítor Galvão ### +### Find the latest version at: ### +### https://github.com/vitorgalvao/notificator ### +#################################################### + +readonly program="$(basename "${0}")" + +# Helpers +function show_notification { + /usr/bin/open "${app}" --args "${notificator_message}" "${notificator_title}" "${notificator_subtitle}" "${notificator_sound}" +} + +function make_icns { + # Setup + local -r file="${1}" + local -r tmp_dir="$(/usr/bin/mktemp -d)" + local -r icon="${tmp_dir}/icon.icns" + local -r iconset="${tmp_dir}/icon.iconset" + /bin/mkdir "${iconset}" + + # Create iconset + for size in {16,32,64,128,256,512}; do + /usr/bin/sips --resampleHeightWidth "${size}" "${size}" "${file}" --out "${iconset}/icon_${size}x${size}.png" &> /dev/null + /usr/bin/sips --resampleHeightWidth "$((size * 2))" "$((size * 2))" "${file}" --out "${iconset}/icon_${size}x${size}@2x.png" &> /dev/null + done + + # Convert to icns + /usr/bin/iconutil --convert icns "${iconset}" --output "${icon}" + + # Clean up and return path to icns + /bin/rm -rf "${iconset}" + echo "${icon}" +} + +function usage { + echo " + Trigger macOS notifications from Alfred, using the Workflow icon + + Usage: + ${program} --message [options] + + Options: + -m, --message Message text + -t, --title Title text + -s, --subtitle Subtitle text + -p, --sound Sound name (from /System/Library/Sounds) + -h, --help Show this help + " | sed -E 's/^ {4}//' +} + +# Options +args=() +while [[ "${1}" ]]; do + case "${1}" in + -h | --help) + usage + exit 0 + ;; + -m | --message) + readonly notificator_message="${2}" + shift + ;; + -t | --title) + readonly notificator_title="${2}" + shift + ;; + -s | --subtitle) + readonly notificator_subtitle="${2}" + shift + ;; + -p | --sound) + readonly notificator_sound="${2}" + shift + ;; + --) + shift + args+=("${@}") + break + ;; + -*) + echo "Unrecognised option: ${1}" + exit 1 + ;; + *) + args+=("${1}") + ;; + esac + shift +done +set -- "${args[@]}" + +# Check for required arguments +if [[ -z "${notificator_message}" ]]; then + echo 'A message is mandatory! Aborting…' >&2 + exit 1 +fi + +readonly bundle_id="$(tr -cd '[:alnum:]._-' <<< "${alfred_workflow_bundleid}")" +readonly icon="${alfred_preferences}/workflows/${alfred_workflow_uid}/icon.png" +readonly app="${alfred_workflow_cache}/Notificator.app" +readonly plist="${app}/Contents/Info.plist" + +# Exit early if Notificator exists and was modified fewer than 30 days ago +if [[ -e "${app}" && -n "$(find "${app}" -depth 0 -mtime -30)" ]]; then + show_notification + exit 0 +fi + +# Pre-build checks +if [[ -z "${bundle_id}" ]]; then + echo "Workflow is missing the bundle identifier! Aborting…" >&2 + exit 1 +fi + +if [[ ! -f "${icon}" ]]; then + echo "Workflow is missing the icon! Aborting…" >&2 + exit 1 +fi + +# Build Notificator +readonly jxa_script=' + // Build argv/argc in a way that can be used from the applet inside the app bundle + ObjC.import("Foundation") + const args = $.NSProcessInfo.processInfo.arguments + const argv = [] + const argc = args.count + for (let i = 0; i < argc; i++) { argv.push(ObjC.unwrap(args.objectAtIndex(i))) } + + // Notification script + const app = Application.currentApplication() + app.includeStandardAdditions = true + + if (argv.length < 2) { // We use "2" because the script will always see at least one argument: the applet itself + argv[1] = "Opening usage instructions…" + argv[2] = "Notificator is a command-line app" + argv[4] = "Funk" + + app.openLocation("https://github.com/vitorgalvao/notificator#usage") + } + + const message = argv[1] + const title = argv[2] + const subtitle = argv[3] + const sound = argv[4] + + const options = {} + if (title) options.withTitle = title + if (subtitle) options.subtitle = subtitle + if (sound) options.soundName = sound + + app.displayNotification(message, options) +' + +/bin/mkdir -p "${alfred_workflow_cache}" +/usr/bin/osacompile -l JavaScript -o "${app}" -e "${jxa_script}" &> /dev/null + +# Modify Notificator +/usr/libexec/PlistBuddy -c "add :CFBundleIdentifier string ${bundle_id}.notificator" "${plist}" +/usr/libexec/PlistBuddy -c 'add :LSUIElement string 1' "${plist}" +mv "$(make_icns "${icon}")" "${app}/Contents/Resources/applet.icns" + +# Redo signature +codesign --remove-signature "${app}" +codesign --sign - "${app}" + +show_notification diff --git a/workflow/notify.py b/workflow/notify.py new file mode 100644 index 0000000..dfdbe0b --- /dev/null +++ b/workflow/notify.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# This module relies on Notificator created by Vítor Galvão +# See https://github.com/vitorgalvao/notificator + +""" +Post notifications via the macOS Notification Center. + +The main API is a single function, :func:`~workflow.notify.notify`. + +It works by copying a simple application to your workflow's cache +directory. +""" + +import os +import subprocess + +from . import workflow + +_wf = None +_log = None + + +#: Available system sounds from System Preferences > Sound > Sound Effects +SOUNDS = ( + "Basso", + "Blow", + "Bottle", + "Frog", + "Funk", + "Glass", + "Hero", + "Morse", + "Ping", + "Pop", + "Purr", + "Sosumi", + "Submarine", + "Tink", +) + + +def wf(): + """Return Workflow object for this module. + + Returns: + workflow.Workflow: Workflow object for current workflow. + """ + global _wf + if _wf is None: + _wf = workflow.Workflow() + return _wf + + +def log(): + """Return logger for this module. + + Returns: + logging.Logger: Logger for this module. + """ + global _log + if _log is None: + _log = wf().logger + return _log + + +def notify(title="", text="", sound=""): + """Post notification via Notify.app helper. + + Args: + title (str, optional): Notification title. + text (str, optional): Notification body text. + sound (str, optional): Name of sound to play. + + Raises: + ValueError: Raised if both ``title`` and ``text`` are empty. + + Returns: + bool: ``True`` if notification was posted, else ``False``. + """ + if text == "": + raise ValueError("A message is mandatory.") + + notificator = os.path.join(os.path.dirname(__file__), "notificator") + retcode = subprocess.run( + [notificator, "--title", title, "--message", text, "--sound", sound], check=True + ).returncode + + if retcode == 0: + return True + + log().error("Notificator exited with status %s", retcode) + return False diff --git a/scripts/workflow/update.py b/workflow/update.py similarity index 76% rename from scripts/workflow/update.py rename to workflow/update.py index dc40c4b..87728ce 100644 --- a/scripts/workflow/update.py +++ b/workflow/update.py @@ -1,18 +1,7 @@ -#!/usr/bin/env python -# encoding: utf-8 -# -# Copyright (c) 2014 Fabio Niephaus , -# Dean Jackson -# -# MIT Licence. See http://opensource.org/licenses/MIT -# -# Created on 2014-08-16 -# +#!/usr/bin/env python3 """Self-updating from GitHub. -.. versionadded:: 1.9 - .. note:: This module is not intended to be used directly. Automatic updates @@ -21,7 +10,6 @@ """ - import json import os import re @@ -29,14 +17,8 @@ import tempfile from collections import defaultdict from functools import total_ordering -from itertools import zip_longest -from urllib import request - -from workflow.util import atomic_writer -from . import workflow - -# __all__ = [] +from . import workflow, web RELEASES_BASE = "https://api.github.com/repos/{}/releases" @@ -45,6 +27,7 @@ _wf = None +# pylint: disable=duplicate-code def wf(): """Lazy `Workflow` object.""" global _wf @@ -54,11 +37,9 @@ def wf(): @total_ordering -class Download(object): +class Download: """A workflow file that is available for download. - .. versionadded: 1.37 - Attributes: url (str): URL of workflow file. filename (str): Filename of workflow file. @@ -70,17 +51,17 @@ class Download(object): """ @classmethod - def from_dict(cls, d): + def from_dict(cls, dl): """Create a `Download` from a `dict`.""" return cls( - url=d["url"], - filename=d["filename"], - version=Version(d["version"]), - prerelease=d["prerelease"], + url=dl["url"], + filename=dl["filename"], + version=Version(dl["version"]), + prerelease=dl["prerelease"], ) @classmethod - def from_releases(cls, js): + def from_releases(cls, json_resp): """Extract downloads from GitHub releases. Searches releases with semantic tags for assets with @@ -92,12 +73,12 @@ def from_releases(cls, js): extension are rejected as ambiguous. Args: - js (str): JSON response from GitHub's releases endpoint. + json_resp (str): JSON response from GitHub's releases endpoint. Returns: list: Sequence of `Download`. """ - releases = json.loads(js) + releases = json.loads(json_resp) downloads = [] for release in releases: tag = release["tag_name"] @@ -112,12 +93,12 @@ def from_releases(cls, js): for asset in release.get("assets", []): url = asset.get("browser_download_url") filename = os.path.basename(url) - m = match_workflow(filename) - if not m: + is_match = match_workflow(filename) + if not is_match: wf().logger.debug("unwanted file: %s", filename) continue - ext = m.group(0) + ext = is_match.group(0) dupes[ext] = dupes[ext] + 1 dls.append(Download(url, filename, version, release["prerelease"])) @@ -125,7 +106,7 @@ def from_releases(cls, js): for ext, n in list(dupes.items()): if n > 1: wf().logger.debug( - 'ignored release "%s": multiple assets ' 'with extension "%s"', + 'ignored release "%s": multiple assets with extension "%s"', tag, ext, ) @@ -160,10 +141,10 @@ def __init__(self, url, filename, version, prerelease=False): @property def alfred_version(self): """Minimum Alfred version based on filename extension.""" - m = match_workflow(self.filename) - if not m or not m.group(1): + is_match = match_workflow(self.filename) + if not is_match or not is_match.group(1): return Version("0") - return Version(m.group(1)) + return Version(is_match.group(1)) @property def dict(self): @@ -212,7 +193,7 @@ def __lt__(self, other): return self.alfred_version < other.alfred_version -class Version(object): +class Version: """Mostly semantic versioning. The main difference to proper :ref:`semantic versioning ` @@ -245,7 +226,7 @@ def __init__(self, vstr): vstr (basestring): Semantic version string. """ if not vstr: - raise ValueError("invalid version number: {!r}".format(vstr)) + raise ValueError(f"invalid version number: {vstr!r}") self.vstr = vstr self.major = 0 @@ -258,20 +239,20 @@ def __init__(self, vstr): def _parse(self, vstr): vstr = str(vstr) if vstr.startswith("v"): - m = self.match_version(vstr[1:]) + is_match = self.match_version(vstr[1:]) else: - m = self.match_version(vstr) - if not m: + is_match = self.match_version(vstr) + if not is_match: raise ValueError("invalid version number: " + vstr) - version, suffix = m.groups() + version, suffix = is_match.groups() parts = self._parse_dotted_string(version) self.major = parts.pop(0) - if len(parts): + if parts: self.minor = parts.pop(0) - if len(parts): + if parts: self.patch = parts.pop(0) - if not len(parts) == 0: + if parts: raise ValueError("version number too long: " + vstr) if suffix: @@ -285,14 +266,15 @@ def _parse(self, vstr): raise ValueError("suffix must start with - : " + suffix) self.suffix = suffix[1:] - def _parse_dotted_string(self, s): + @staticmethod + def _parse_dotted_string(string): """Parse string ``s`` into list of ints and strings.""" parsed = [] - parts = s.split(".") - for p in parts: - if p.isdigit(): - p = int(p) - parsed.append(p) + parts = string.split(".") + for part in parts: + if part.isdigit(): + part = int(part) + parsed.append(part) return parsed @property @@ -303,37 +285,24 @@ def tuple(self): def __lt__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError("not a Version instance: {0!r}".format(other)) - t = self.tuple[:3] - o = other.tuple[:3] - if t < o: + raise ValueError(f"not a Version instance: {other!r}") + if self.tuple[:3] < other.tuple[:3]: return True - if t == o: # We need to compare suffixes + if self.tuple[:3] == other.tuple[:3]: # We need to compare suffixes if self.suffix and not other.suffix: return True if other.suffix and not self.suffix: return False + return self._parse_dotted_string(self.suffix) < self._parse_dotted_string( + other.suffix + ) - self_suffix = self._parse_dotted_string(self.suffix) - other_suffix = self._parse_dotted_string(other.suffix) - - for s, o in zip_longest(self_suffix, other_suffix): - if s is None: # shorter value wins - return True - elif o is None: # longer value loses - return False - elif type(s) != type(o): # type coersion - s, o = str(s), str(o) - if s == o: # next if the same compare - continue - return s < o # finally compare - # t > o return False def __eq__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError("not a Version instance: {0!r}".format(other)) + raise ValueError(f"not a Version instance: {other!r}") return self.tuple == other.tuple def __ne__(self, other): @@ -343,13 +312,13 @@ def __ne__(self, other): def __gt__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError("not a Version instance: {0!r}".format(other)) + raise ValueError(f"not a Version instance: {format(other)!r}") return other.__lt__(self) def __le__(self, other): """Implement comparison.""" if not isinstance(other, Version): - raise ValueError("not a Version instance: {0!r}".format(other)) + raise ValueError(f"not a Version instance: {other!r}") return not other.__lt__(self) def __ge__(self, other): @@ -358,40 +327,37 @@ def __ge__(self, other): def __str__(self): """Return semantic version string.""" - vstr = "{0}.{1}.{2}".format(self.major, self.minor, self.patch) + vstr = f"{self.major}.{self.minor}.{self.patch}" if self.suffix: - vstr = "{0}-{1}".format(vstr, self.suffix) + vstr = f"{vstr}-{self.suffix}" if self.build: - vstr = "{0}+{1}".format(vstr, self.build) + vstr = f"{vstr}+{self.build}" return vstr def __repr__(self): """Return 'code' representation of `Version`.""" - return "Version('{0}')".format(str(self)) + return f'Version("{str(self)}")' def retrieve_download(dl): """Saves a download to a temporary file and returns path. - .. versionadded: 1.37 - Args: - url (unicode): URL to .alfredworkflow file in GitHub repo + url (str): URL to .alfredworkflow file in GitHub repo Returns: - unicode: path to downloaded file + str: path to downloaded file """ if not match_workflow(dl.filename): - raise ValueError("attachment not a workflow: " + dl.filename) + raise ValueError(f"attachment not a workflow: {dl.filename}") path = os.path.join(tempfile.gettempdir(), dl.filename) - wf().logger.debug("downloading update from " "%r to %r ...", dl.url, path) - - r = request.urlopen(dl.url) + wf().logger.debug("downloading update from %r to %r ...", dl.url, path) - with atomic_writer(path, "wb") as file_obj: - file_obj.write(r.read()) + r = web.get(dl.url) + r.raise_for_status() + r.save_to_path(path) return path @@ -400,14 +366,14 @@ def build_api_url(repo): """Generate releases URL from GitHub repo. Args: - repo (unicode): Repo name in form ``username/repo`` + repo (str): Repo name in form ``username/repo`` Returns: - unicode: URL to the API endpoint for the repo's releases + str: URL to the API endpoint for the repo's releases """ if len(repo.split("/")) != 2: - raise ValueError("invalid GitHub repo: {!r}".format(repo)) + raise ValueError(f"invalid GitHub repo: {repo!r}") return RELEASES_BASE.format(repo) @@ -415,10 +381,8 @@ def build_api_url(repo): def get_downloads(repo): """Load available ``Download``s for GitHub repo. - .. versionadded: 1.37 - Args: - repo (unicode): GitHub repo to load releases for. + repo (str): GitHub repo to load releases for. Returns: list: Sequence of `Download` contained in GitHub releases. @@ -427,13 +391,14 @@ def get_downloads(repo): def _fetch(): wf().logger.info("retrieving releases for %r ...", repo) - r = request.urlopen(url) - return r.read() + r = web.get(url) + r.raise_for_status() + return r.content key = "github-releases-" + repo.replace("/", "-") - js = wf().cached_data(key, _fetch, max_age=60) + json_resp = wf().cached_data(key, _fetch, max_age=60) - return Download.from_releases(js) + return Download.from_releases(json_resp) def latest_download(dls, alfred_version=None, prereleases=False): @@ -467,11 +432,11 @@ def check_update(repo, current_version, prereleases=False, alfred_version=None): """Check whether a newer release is available on GitHub. Args: - repo (unicode): ``username/repo`` for workflow's GitHub repo - current_version (unicode): the currently installed version of the + repo (str): ``username/repo`` for workflow's GitHub repo + current_version (str): the currently installed version of the workflow. :ref:`Semantic versioning ` is required. prereleases (bool): Whether to include pre-releases. - alfred_version (unicode): version of currently-running Alfred. + alfred_version (str): version of currently-running Alfred. if empty, defaults to ``$alfred_version`` environment variable. Returns: @@ -487,7 +452,7 @@ def check_update(repo, current_version, prereleases=False, alfred_version=None): current = Version(current_version) dls = get_downloads(repo) - if not len(dls): + if not dls: wf().logger.warning("no valid downloads for %s", repo) wf().cache_data(key, no_update) return False @@ -549,7 +514,7 @@ def install_update(): def show_help(status=0): """Print help message.""" - print("usage: update.py (check|install) " "[--prereleases] ") + print("usage: update.py (check|install) [--prereleases] ") sys.exit(status) argv = sys.argv[:] @@ -568,14 +533,12 @@ def show_help(status=0): version = argv[3] try: - if action == "check": check_update(repo, version, prereleases) elif action == "install": install_update() else: show_help(1) - except Exception as err: # ensure traceback is in log file wf().logger.exception(err) raise err diff --git a/scripts/workflow/util.py b/workflow/util.py similarity index 70% rename from scripts/workflow/util.py rename to workflow/util.py index 998456b..8ddb99e 100644 --- a/scripts/workflow/util.py +++ b/workflow/util.py @@ -1,16 +1,7 @@ -#!/usr/bin/env python -# encoding: utf-8 -# -# Copyright (c) 2017 Dean Jackson -# -# MIT Licence. See http://opensource.org/licenses/MIT -# -# Created on 2017-12-17 -# +#!/usr/bin/env python3 """A selection of helper functions useful for building workflows.""" - import atexit import errno import fcntl @@ -25,6 +16,7 @@ from contextlib import contextmanager from threading import Event + # JXA scripts to call Alfred's API via the Scripting Bridge # {app} is automatically replaced with "Alfred 3" or # "com.runningwithcrayons.Alfred" depending on version. @@ -54,101 +46,26 @@ class AcquisitionError(Exception): AppInfo = namedtuple("AppInfo", ["name", "path", "bundleid"]) """Information about an installed application. -Returned by :func:`appinfo`. All attributes are Unicode. +Returned by :func:`appinfo`. .. py:attribute:: name - Name of the application, e.g. ``u'Safari'``. + Name of the application, e.g. ``'Safari'``. .. py:attribute:: path - Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. + Path to the application bundle, e.g. ``'/Applications/Safari.app'``. .. py:attribute:: bundleid - Application's bundle ID, e.g. ``u'com.apple.Safari'``. + Application's bundle ID, e.g. ``'com.apple.Safari'``. """ -def jxa_app_name(): - """Return name of application to call currently running Alfred. - - .. versionadded: 1.37 - - Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending - on which version of Alfred is running. - - This name is suitable for use with ``Application(name)`` in JXA. - - Returns: - unicode: Application name or ID. - - """ - if os.getenv("alfred_version", "").startswith("3"): - # Alfred 3 - return "Alfred 3" - # Alfred 4+ - return "com.runningwithcrayons.Alfred" - - -def unicodify(s, encoding="utf-8", norm=None): - """Ensure string is Unicode. - - .. versionadded:: 1.31 - - Decode encoded strings using ``encoding`` and normalise Unicode - to form ``norm`` if specified. - - Args: - s (str): String to decode. May also be Unicode. - encoding (str, optional): Encoding to use on bytestrings. - norm (None, optional): Normalisation form to apply to Unicode string. - - Returns: - unicode: Decoded, optionally normalised, Unicode string. - - """ - if not isinstance(s, str): - s = str(s, encoding) - - if norm: - from unicodedata import normalize - - s = normalize(norm, s) - - return s - - -def utf8ify(s): - """Ensure string is a bytestring. - - .. versionadded:: 1.31 - - Returns `str` objects unchanced, encodes `unicode` objects to - UTF-8, and calls :func:`str` on anything else. - - Args: - s (object): A Python object - - Returns: - str: UTF-8 string or string representation of s. - - """ - if isinstance(s, str): - return s - - if isinstance(s, str): - return s.encode("utf-8") - - return str(s) - - -def applescriptify(s): +def applescriptify(string): """Escape string for insertion into an AppleScript string. - .. versionadded:: 1.31 - Replaces ``"`` with `"& quote &"`. Use this function if you want to insert a string into an AppleScript script: @@ -156,40 +73,18 @@ def applescriptify(s): 'g " & quote & "python" & quote & "test' Args: - s (unicode): Unicode string to escape. - - Returns: - unicode: Escaped string. - - """ - return s.replace('"', '" & quote & "') - - -def run_command(cmd, **kwargs): - """Run a command and return the output. - - .. versionadded:: 1.31 - - A thin wrapper around :func:`subprocess.check_output` that ensures - all arguments are encoded to UTF-8 first. - - Args: - cmd (list): Command arguments to pass to :func:`~subprocess.check_output`. - **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`. + s (str): String to escape. Returns: - str: Output returned by :func:`~subprocess.check_output`. + str: Escaped string. """ - cmd = [str(s) for s in cmd] - return subprocess.check_output(cmd, **kwargs).decode() + return string.replace('"', '" & quote & "') def run_applescript(script, *args, **kwargs): """Execute an AppleScript script and return its output. - .. versionadded:: 1.31 - Run AppleScript either by filepath or code. If ``script`` is a valid filepath, that script will be run, otherwise ``script`` is treated as code. @@ -198,7 +93,7 @@ def run_applescript(script, *args, **kwargs): script (str, optional): Filepath of script or code to run. *args: Optional command-line arguments to pass to the script. **kwargs: Pass ``lang`` to run a language other than AppleScript. - Any other keyword arguments are passed to :func:`run_command`. + Any other keyword arguments are passed to :func:`~subprocess.run`. Returns: str: Output of run command. @@ -218,14 +113,12 @@ def run_applescript(script, *args, **kwargs): cmd.extend(args) - return run_command(cmd, **kwargs) + return subprocess.run(cmd, **kwargs, check=True, stdout=subprocess.PIPE).stdout def run_jxa(script, *args): """Execute a JXA script and return its output. - .. versionadded:: 1.31 - Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. Args: @@ -242,8 +135,6 @@ def run_jxa(script, *args): def run_trigger(name, bundleid=None, arg=None): """Call an Alfred External Trigger. - .. versionadded:: 1.31 - If ``bundleid`` is not specified, the bundle ID of the calling workflow is used. @@ -254,7 +145,7 @@ def run_trigger(name, bundleid=None, arg=None): """ bundleid = bundleid or os.getenv("alfred_workflow_bundleid") - appname = jxa_app_name() + appname = "com.runningwithcrayons.Alfred" opts = {"inWorkflow": bundleid} if arg: opts["withArgument"] = arg @@ -271,13 +162,11 @@ def run_trigger(name, bundleid=None, arg=None): def set_theme(theme_name): """Change Alfred's theme. - .. versionadded:: 1.39.0 - Args: - theme_name (unicode): Name of theme Alfred should use. + theme_name (str): Name of theme Alfred should use. """ - appname = jxa_app_name() + appname = "com.runningwithcrayons.Alfred" script = JXA_SET_THEME.format(app=json.dumps(appname), arg=json.dumps(theme_name)) run_applescript(script, lang="JavaScript") @@ -285,8 +174,6 @@ def set_theme(theme_name): def set_config(name, value, bundleid=None, exportable=False): """Set a workflow variable in ``info.plist``. - .. versionadded:: 1.33 - If ``bundleid`` is not specified, the bundle ID of the calling workflow is used. @@ -299,8 +186,12 @@ def set_config(name, value, bundleid=None, exportable=False): """ bundleid = bundleid or os.getenv("alfred_workflow_bundleid") - appname = jxa_app_name() - opts = {"toValue": value, "inWorkflow": bundleid, "exportable": exportable} + appname = "com.runningwithcrayons.Alfred" + opts = { + "toValue": value, + "inWorkflow": bundleid, + "exportable": exportable, + } script = JXA_SET_CONFIG.format( app=json.dumps(appname), @@ -314,8 +205,6 @@ def set_config(name, value, bundleid=None, exportable=False): def unset_config(name, bundleid=None): """Delete a workflow variable from ``info.plist``. - .. versionadded:: 1.33 - If ``bundleid`` is not specified, the bundle ID of the calling workflow is used. @@ -325,7 +214,7 @@ def unset_config(name, bundleid=None): """ bundleid = bundleid or os.getenv("alfred_workflow_bundleid") - appname = jxa_app_name() + appname = "com.runningwithcrayons.Alfred" opts = {"inWorkflow": bundleid} script = JXA_UNSET_CONFIG.format( @@ -340,16 +229,14 @@ def unset_config(name, bundleid=None): def search_in_alfred(query=None): """Open Alfred with given search query. - .. versionadded:: 1.39.0 - Omit ``query`` to simply open Alfred's main window. Args: - query (unicode, optional): Search query. + query (str, optional): Search query. """ query = query or "" - appname = jxa_app_name() + appname = "com.runningwithcrayons.Alfred" script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) run_applescript(script, lang="JavaScript") @@ -357,13 +244,11 @@ def search_in_alfred(query=None): def browse_in_alfred(path): """Open Alfred's filesystem navigation mode at ``path``. - .. versionadded:: 1.39.0 - Args: - path (unicode): File or directory path. + path (str): File or directory path. """ - appname = jxa_app_name() + appname = "com.runningwithcrayons.Alfred" script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) run_applescript(script, lang="JavaScript") @@ -371,13 +256,11 @@ def browse_in_alfred(path): def action_in_alfred(paths): """Action the give filepaths in Alfred. - .. versionadded:: 1.39.0 - Args: - paths (list): Unicode paths to files/directories to action. + paths (list): Paths to files/directories to action. """ - appname = jxa_app_name() + appname = "com.runningwithcrayons.Alfred" script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) run_applescript(script, lang="JavaScript") @@ -385,17 +268,15 @@ def action_in_alfred(paths): def reload_workflow(bundleid=None): """Tell Alfred to reload a workflow from disk. - .. versionadded:: 1.39.0 - If ``bundleid`` is not specified, the bundle ID of the calling workflow is used. Args: - bundleid (unicode, optional): Bundle ID of workflow to reload. + bundleid (str, optional): Bundle ID of workflow to reload. """ bundleid = bundleid or os.getenv("alfred_workflow_bundleid") - appname = jxa_app_name() + appname = "com.runningwithcrayons.Alfred" script = JXA_RELOAD_WORKFLOW.format( app=json.dumps(appname), arg=json.dumps(bundleid) ) @@ -406,8 +287,6 @@ def reload_workflow(bundleid=None): def appinfo(name): """Get information about an installed application. - .. versionadded:: 1.31 - Args: name (str): Name of application to look up. @@ -424,17 +303,17 @@ def appinfo(name): "-onlyin", os.path.expanduser("~/Applications"), "(kMDItemContentTypeTree == com.apple.application &&" - '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'.format(name), + f'(kMDItemDisplayName == "{name}" || kMDItemFSName == "{name}.app"))', ] - output = run_command(cmd).strip() + output = subprocess.run(cmd, check=True, stdout=subprocess.PIPE).stdout.strip() if not output: return None path = output.split("\n")[0] cmd = ["mdls", "-raw", "-name", "kMDItemCFBundleIdentifier", path] - bid = run_command(cmd).strip() + bid = subprocess.run(cmd, check=True, stdout=subprocess.PIPE).stdout.strip() if not bid: # pragma: no cover return None @@ -445,45 +324,41 @@ def appinfo(name): def atomic_writer(fpath, mode): """Atomic file writer. - .. versionadded:: 1.12 - Context manager that ensures the file is only written if the write succeeds. The data is first written to a temporary file. :param fpath: path of file to write to. - :type fpath: ``unicode`` + :type fpath: ``str`` :param mode: sames as for :func:`open` :type mode: string """ - suffix = ".{}.tmp".format(os.getpid()) + suffix = f".{os.getpid()}.tmp" temppath = fpath + suffix - with open(temppath, mode) as fp: + with open(temppath, mode) as f: # pylint: disable=unspecified-encoding try: - yield fp + yield f os.rename(temppath, fpath) finally: try: os.remove(temppath) - except OSError: + except (OSError, IOError): pass -class LockFile(object): +class LockFile: """Context manager to protect filepaths with lockfiles. - .. versionadded:: 1.13 - Creates a lockfile alongside ``protected_path``. Other ``LockFile`` instances will refuse to lock the same path. >>> path = '/path/to/file' >>> with LockFile(path): - >>> with open(path, 'w') as fp: - >>> fp.write(data) + >>> with open(path, 'wb') as f: + >>> f.write(data) Args: - protected_path (unicode): File to protect with a lockfile + protected_path (str): File to protect with a lockfile timeout (float, optional): Raises an :class:`AcquisitionError` if lock cannot be acquired within this number of seconds. If ``timeout`` is 0 (the default), wait forever. @@ -493,7 +368,7 @@ class LockFile(object): Attributes: delay (float): How often to check (in seconds) whether the lock can be acquired. - lockfile (unicode): Path of the lockfile. + lockfile (str): Path of the lockfile. timeout (float): How long to wait to acquire the lock. """ @@ -538,23 +413,22 @@ def acquire(self, blocking=True): # Create in append mode so we don't lose any contents if self._lockfile is None: - self._lockfile = open(self.lockfile, "a") - - # Try to acquire the lock - try: - fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) - self._lock.set() - break - except IOError as err: # pragma: no cover - if err.errno not in (errno.EACCES, errno.EAGAIN): - raise - - # Don't try again - if not blocking: # pragma: no cover - return False - - # Wait, then try again - time.sleep(self.delay) + with open(self.lockfile, "a", encoding="utf-8") as self._lockfile: + # Try to acquire the lock + try: + fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) + self._lock.set() + break + except IOError as err: # pragma: no cover + if err.errno not in (errno.EACCES, errno.EAGAIN): + raise + + # Don't try again + if not blocking: # pragma: no cover + return False + + # Wait, then try again + time.sleep(self.delay) return True @@ -572,10 +446,10 @@ def release(self): self._lockfile = None try: os.unlink(self.lockfile) - except OSError: # pragma: no cover + except (IOError, OSError): # pragma: no cover pass - return True # noqa: B012 + return True # pylint: disable=lost-exception def __enter__(self): """Acquire lock.""" @@ -591,11 +465,9 @@ def __del__(self): self.release() # pragma: no cover -class uninterruptible(object): +class uninterruptible: # pylint: disable=invalid-name """Decorator that postpones SIGTERM until wrapped function returns. - .. versionadded:: 1.12 - .. important:: This decorator is NOT thread-safe. As of version 2.7, Alfred allows Script Filters to be killed. If @@ -616,7 +488,9 @@ def __init__(self, func, class_name=""): """Decorate `func`.""" self.func = func functools.update_wrapper(self, func) + self.class_name = class_name self._caught_signal = None + self.old_signal_handler = signal.getsignal(signal.SIGTERM) def signal_handler(self, signum, frame): """Called when process receives SIGTERM.""" @@ -626,9 +500,7 @@ def __call__(self, *args, **kwargs): """Trap ``SIGTERM`` and call wrapped function.""" self._caught_signal = None # Register handler for SIGTERM, then call `self.func` - self.old_signal_handler = signal.getsignal(signal.SIGTERM) signal.signal(signal.SIGTERM, self.signal_handler) - self.func(*args, **kwargs) # Restore old signal handler @@ -642,6 +514,6 @@ def __call__(self, *args, **kwargs): elif self.old_signal_handler == signal.SIG_DFL: sys.exit(0) - def __get__(self, obj=None, klass=None): + def __get__(self, obj=None, class_name=None): """Decorator API.""" - return self.__class__(self.func.__get__(obj, klass), klass.__name__) + return self.__class__(self.func.__get__(obj, class_name), class_name.__name__) diff --git a/workflow/version b/workflow/version new file mode 100644 index 0000000..359a5b9 --- /dev/null +++ b/workflow/version @@ -0,0 +1 @@ +2.0.0 \ No newline at end of file diff --git a/workflow/web.py b/workflow/web.py new file mode 100644 index 0000000..146f485 --- /dev/null +++ b/workflow/web.py @@ -0,0 +1,750 @@ +"""Lightweight HTTP library with a requests-like interface.""" + +import codecs +import json +import mimetypes +import os +import random +import re +import socket +import string +import unicodedata +import urllib.request +import urllib.parse +import urllib.error +import zlib + +# pylint: disable=consider-using-with +__version__ = open( + os.path.join(os.path.dirname(__file__), "version"), encoding="utf-8" +).read() + +USER_AGENT = f"Alpynist/{__version__}" + +# Valid characters for multipart form data boundaries +BOUNDARY_CHARS = string.digits + string.ascii_letters + +# HTTP response codes +RESPONSES = { + 100: "Continue", + 101: "Switching Protocols", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Request Entity Too Large", + 414: "Request-URI Too Long", + 415: "Unsupported Media Type", + 416: "Requested Range Not Satisfiable", + 417: "Expectation Failed", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", +} + + +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Prevent redirections.""" + + def redirect_request(self, *args): # pylint: disable=unused-argument + """Ignore redirect.""" + return None + + +# Adapted from https://gist.github.com/babakness/3901174 +class CaseInsensitiveDictionary(dict): + """Dictionary with caseless key search. + + Enables case insensitive searching while preserving case sensitivity + when keys are listed, ie, via keys() or items() methods. + + Works by storing a lowercase version of the key as the new key and + stores the original key-value pair as the key's value + (values become dictionaries). + + """ + + def __init__(self, initval=None): + """Create new case-insensitive dictionary.""" + super().__init__(self) + + if isinstance(initval, dict): + for key, value in initval.items(): + self.__setitem__(key, value) + elif isinstance(initval, list): + for (key, value) in initval: + self.__setitem__(key, value) + + def __contains__(self, key): + return dict.__contains__(self, key.lower()) + + def __getitem__(self, key): + return dict.__getitem__(self, key.lower())["val"] + + def __setitem__(self, key, value): + return dict.__setitem__(self, key.lower(), {"key": key, "val": value}) + + def get(self, key, default=None): + """Return value for case-insensitive key or default.""" + try: + v = dict.__getitem__(self, key.lower()) + except KeyError: + return default + else: + return v["val"] + + def update(self, other): + """Update values from other ``dict``.""" + for k, v in list(other.items()): + self[k] = v + + def items(self): + """Return ``(key, value)`` pairs.""" + return [(v["key"], v["val"]) for v in dict.values(self)] + + def keys(self): + """Return original keys.""" + return [v["key"] for v in dict.values(self)] + + def values(self): + """Return all values.""" + return [v["val"] for v in dict.values(self)] + + def iteritems(self): + """Iterate over ``(key, value)`` pairs.""" + for v in dict.values(self): + yield v["key"], v["val"] + + def iterkeys(self): + """Iterate over original keys.""" + for v in dict.values(self): + yield v["key"] + + def itervalues(self): + """Interate over values.""" + for v in dict.values(self): + yield v["val"] + + +class Request(urllib.request.Request): + """Subclass of :class:`urllib.request.Request` that supports custom methods.""" + + def __init__(self, *args, **kwargs): + """Create a new :class:`Request`.""" + self._method = kwargs.pop("method", None) + urllib.request.Request.__init__(self, *args, **kwargs) + + def get_method(self): + return self._method.upper() + + +class Response: + """ + Returned by :func:`request` / :func:`get` / :func:`post` functions. + + Simplified version of the ``Response`` object in the ``requests`` library. + + >>> r = request('http://www.google.com') + >>> r.status_code # int + 200 + >>> r.encoding # str + ISO-8859-1 + >>> r.content # bytes + ... + >>> r.text # str, decoded according to charset in HTTP header/meta tag + ... + >>> r.json() # content parsed as JSON + + """ + + def __init__(self, request, stream=False): + """Call `request` with :mod:`urllib` and process results. + + :param request: :class:`Request` instance + :param stream: Whether to stream response or retrieve it all at once + :type stream: bool + + """ + self.request = request + self._stream = stream + self.url = None + self.raw = None + self._encoding = None + self.error = None + self.status_code = None + self.reason = None + self.headers = CaseInsensitiveDictionary() + self._content = None + self._content_loaded = False + self._gzipped = False + + # Execute query + try: + # pylint: disable=consider-using-with + self.raw = urllib.request.urlopen(request) + except urllib.error.HTTPError as err: + self.error = err + try: + self.url = err.geturl() + # sometimes (e.g. when authentication fails) + # urllib can't get a URL from an HTTPError + # This behaviour changes across Python versions, + # so no test cover (it isn't important). + except AttributeError: # pragma: no cover + pass + self.status_code = err.code + else: + self.status_code = self.raw.getcode() + self.url = self.raw.geturl() + self.reason = RESPONSES.get(self.status_code) + + # Parse additional info if request succeeded + if not self.error: + headers = self.raw.info() + self.transfer_encoding = headers.get_content_charset() + self.mimetype = headers.get("content-type") + for key in list(headers.keys()): + self.headers[key.lower()] = headers.get(key) + + # Is content gzipped? + # Transfer-Encoding appears to not be used in the wild + # (contrary to the HTTP standard), but no harm in testing + # for it + if "gzip" in headers.get("content-encoding", "") or "gzip" in headers.get( + "transfer-encoding", "" + ): + self._gzipped = True + + @property + def stream(self): + """Whether response is streamed. + + Returns: + bool: `True` if response is streamed. + + """ + return self._stream + + @stream.setter + def stream(self, value): + if self._content_loaded: + raise RuntimeError("`content` has already been read from this Response.") + + self._stream = value + + def json(self): + """Decode response contents as JSON. + + :returns: object decoded from JSON + :rtype: list, dict or str + + """ + return json.loads(self.content) + + @property + def encoding(self): + """Text encoding of document or ``None``. + + :returns: Text encoding if found. + :rtype: str or ``None`` + + """ + if not self._encoding: + self._encoding = self._get_encoding() + + return self._encoding + + @property + def content(self): + """Raw content of response (i.e. bytes). + + :returns: Body of HTTP response + :rtype: bytes + + """ + if not self._content: + + # Decompress gzipped content + if self._gzipped: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + self._content = decoder.decompress(self.raw.read()) + + else: + self._content = self.raw.read() + + self._content_loaded = True + + return self._content + + @property + def text(self): + """Unicode-decoded content of response body. + + If no encoding can be determined from HTTP headers or the content + itself, the encoded response body will be returned instead. + + :returns: Body of HTTP response + :rtype: str or bytes + + """ + if self.encoding: + return unicodedata.normalize("NFC", str(self.content, self.encoding)) + return self.content + + def iter_content(self, chunk_size=4096, decode_unicode=False): + """Iterate over response data. + + :param chunk_size: Number of bytes to read into memory + :type chunk_size: int + :param decode_unicode: Decode to Unicode using detected encoding + :type decode_unicode: bool + :returns: iterator + + """ + if not self.stream: + raise RuntimeError( + "You cannot call `iter_content` on a Response unless you passed `stream=True` to `get()`/`post()`/`request()`." # noqa + ) + + if self._content_loaded: + raise RuntimeError("`content` has already been read from this Response.") + + def decode_stream(iterator, r): + dec = codecs.getincrementaldecoder(r.encoding)(errors="replace") + + for chunk in iterator: + data = dec.decode(chunk) + if data: + yield data + + data = dec.decode(b"", final=True) + if data: # pragma: no cover + yield data + + def generate(): + if self._gzipped: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + + while True: + chunk = self.raw.read(chunk_size) + if not chunk: + break + + if self._gzipped: + chunk = decoder.decompress(chunk) + + yield chunk + + chunks = generate() + + if decode_unicode and self.encoding: + chunks = decode_stream(chunks, self) + + return chunks + + def save_to_path(self, filepath): + """Save retrieved data to file at ``filepath``. + + :param filepath: Path to save retrieved data. + + """ + filepath = os.path.abspath(filepath) + dirname = os.path.dirname(filepath) + if not os.path.exists(dirname): + os.makedirs(dirname) + + self.stream = True + + with open(filepath, "wb") as fileobj: + for data in self.iter_content(): + fileobj.write(data) + + def raise_for_status(self): + """Raise stored error if one occurred. + + error will be instance of :class:`urllib.error.HTTPError` + """ + if self.error is not None: + raise self.error + + def _get_encoding(self): + """Get encoding from HTTP headers or content. + + :returns: encoding or `None` + :rtype: str or ``None`` + + """ + headers = self.raw.info() + encoding = None + + if headers.get_content_charset(): + encoding = headers.get_content_charset() + + if not self.stream: # Try sniffing response content + # Encoding declared in document should override HTTP headers + if self.mimetype == "text/html": # sniff HTML headers + match = re.search( + r"""""", self.content + ) + if match: + encoding = match.group(1) + + elif ( + self.mimetype.startswith("application/") + or self.mimetype.startswith("text/") + ) and "xml" in self.mimetype: # noqa + match = re.search( + r"""]*\?>""", self.content + ) + if match: + encoding = match.group(1) + + # Format defaults + if self.mimetype == "application/json" and not encoding: + # The default encoding for JSON + encoding = "utf-8" + + elif self.mimetype == "application/xml" and not encoding: + # The default for 'application/xml' + encoding = "utf-8" + + if encoding: + encoding = encoding.lower() + + return encoding + + +def request( + method, + url, + params=None, + data=None, + headers=None, + files=None, + auth=None, + timeout=60, + allow_redirects=False, + stream=False, +): + """Initiate an HTTP(S) request. Returns :class:`Response` object. + + :param method: 'GET' or 'POST' + :type method: str + :param url: URL to open + :type url: str + :param params: mapping of URL parameters + :type params: dict + :param data: mapping of form data ``{'field_name': 'value'}`` or + :class:`str` + :type data: dict or str + :param headers: HTTP headers + :type headers: dict + :param cookies: cookies to send to server + :type cookies: dict + :param files: files to upload (see below). + :type files: dict + :param auth: username, password + :type auth: tuple + :param timeout: connection timeout limit in seconds + :type timeout: int + :param allow_redirects: follow redirections + :type allow_redirects: bool + :param stream: Stream content instead of fetching it all at once. + :type stream: bool + :returns: Response object + :rtype: :class:`Response` + + The ``files`` argument is a dictionary:: + + { + "fieldname": { + "filename": "blah.txt", + "content": "", + "mimetype": "text/plain", + } + } + + * ``fieldname`` is the name of the field in the HTML form. + * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will + be used to guess the mimetype, or ``application/octet-stream`` + will be used. + + """ + # TODO: cookies + socket.setdefaulttimeout(timeout) + + # Default handlers + openers = [urllib.request.ProxyHandler(urllib.request.getproxies())] + + if not allow_redirects: + openers.append(NoRedirectHandler()) + + if auth is not None: # Add authorisation handler + username, password = auth + password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm() + password_manager.add_password(None, url, username, password) + auth_manager = urllib.request.HTTPBasicAuthHandler(password_manager) + openers.append(auth_manager) + + # Install our custom chain of openers + opener = urllib.request.build_opener(*openers) + urllib.request.install_opener(opener) + + if not headers: + headers = CaseInsensitiveDictionary() + else: + headers = CaseInsensitiveDictionary(headers) + + if "user-agent" not in headers: + headers["user-agent"] = USER_AGENT + + # Accept gzip-encoded content + encodings = [s.strip() for s in headers.get("accept-encoding", "").split(",")] + if "gzip" not in encodings: + encodings.append("gzip") + + headers["accept-encoding"] = ", ".join(encodings) + + if files: + if not data: + data = {} + new_headers, data = encode_multipart_formdata(data, files) + headers.update(new_headers) + elif data and isinstance(data, dict): + data = urllib.parse.urlencode(data) + + # Make sure everything is encoded text + + if params: # GET args (POST args are handled in encode_multipart_formdata) + + scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url) + + if query: # Combine query string and `params` + url_params = urllib.parse.parse_qs(query) + # `params` take precedence over URL query string + url_params.update(params) + params = url_params + + query = urllib.parse.urlencode(params, doseq=True) + url = urllib.parse.urlunsplit((scheme, netloc, path, query, fragment)) + + req = Request(url, data, headers, method=method) + return Response(req, stream) + + +def get( + url, + params=None, + headers=None, + auth=None, + timeout=60, + allow_redirects=True, + stream=False, +): + """Initiate a GET request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request( + "GET", + url, + params, + headers=headers, + auth=auth, + timeout=timeout, + allow_redirects=allow_redirects, + stream=stream, + ) + + +def delete( + url, + params=None, + data=None, + headers=None, + auth=None, + timeout=60, + allow_redirects=True, + stream=False, +): + """Initiate a DELETE request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request( + "DELETE", + url, + params, + data, + headers=headers, + auth=auth, + timeout=timeout, + allow_redirects=allow_redirects, + stream=stream, + ) + + +def post( + url, + params=None, + data=None, + headers=None, + files=None, + auth=None, + timeout=60, + allow_redirects=False, + stream=False, +): + """Initiate a POST request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request( + "POST", + url, + params, + data, + headers, + files, + auth, + timeout, + allow_redirects, + stream, + ) + + +def put( + url, + params=None, + data=None, + headers=None, + files=None, + auth=None, + timeout=60, + allow_redirects=False, + stream=False, +): + """Initiate a PUT request. Arguments as for :func:`request`. + + :returns: :class:`Response` instance + + """ + return request( + "PUT", url, params, data, headers, files, auth, timeout, allow_redirects, stream + ) + + +def encode_multipart_formdata(fields, files): + """Encode form data (``fields``) and ``files`` for POST request. + + :param fields: mapping of ``{name: value}`` pairs for normal form fields. + :type fields: dict + :param files: dictionary of fieldnames/files elements for file data. + See below for details. + :type files: dict of :class:`dict` + :returns: ``(headers, body)`` ``headers`` is a + :class:`dict` of HTTP headers + :rtype: 2-tuple ``(dict, str)`` + + The ``files`` argument is a dictionary:: + + { + "fieldname": { + "filename": "blah.txt", + "content": "", + "mimetype": "text/plain", + } + } + + - ``fieldname`` is the name of the field in the HTML form. + - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will + be used to guess the mimetype, or ``application/octet-stream`` + will be used. + + """ + + def get_content_type(filename): + """Return or guess mimetype of ``filename``. + + :param filename: filename of file + :type filename: unicode/str + :returns: mime-type, e.g. ``text/html`` + :rtype: str + + """ + return mimetypes.guess_type(filename)[0] or "application/octet-stream" + + boundary = "-----" + "".join(random.choice(BOUNDARY_CHARS) for i in range(30)) + crlf = "\r\n" + output = [] + + # Normal form fields + for (k, v) in list(fields.items()): + if isinstance(k, str): + k = k.encode("utf-8") + if isinstance(v, str): + v = v.encode("utf-8") + output.append("--" + boundary) + output.append(f'Content-Disposition: form-data; name="{k}"') + output.append("") + output.append(v) + + # Files to upload + for k, v in list(files.items()): + filename = v["filename"] + content = v["content"] + if "mimetype" in v: + mimetype = v["mimetype"] + else: + mimetype = get_content_type(filename) + if isinstance(k, str): + k = k.encode("utf-8") + if isinstance(filename, str): + filename = filename.encode("utf-8") + if isinstance(mimetype, str): + mimetype = mimetype.encode("utf-8") + output.append("--" + boundary) + output.append( + f'Content-Disposition: form-data; name="{k}"; filename="{filename}"' # noqa + ) + output.append(f"Content-Type: {mimetype}") + output.append("") + output.append(content) + + output.append("--" + boundary + "--") + output.append("") + body = crlf.join(output) + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(body)), + } + return (headers, body) diff --git a/scripts/workflow/workflow.py b/workflow/workflow.py similarity index 76% rename from scripts/workflow/workflow.py rename to workflow/workflow.py index 8bd5fe2..fbc39cb 100644 --- a/scripts/workflow/workflow.py +++ b/workflow/workflow.py @@ -1,25 +1,16 @@ -# encoding: utf-8 -# -# Copyright (c) 2014 Dean Jackson -# -# MIT Licence. See http://opensource.org/licenses/MIT -# -# Created on 2014-02-15 +# This is a port to Python 3 of the Alfred-Workflow library created by +# Dean Jackson . +# See https://github.com/deanishe/alfred-workflow # +# Copyright (c) 2022 Arthur Pinheiro """The :class:`Workflow` object is the main interface to this library. -:class:`Workflow` is targeted at Alfred 2. Use -:class:`~workflow.Workflow3` if you want to use Alfred 3's new -features, such as :ref:`workflow variables ` or -more powerful modifiers. - See :ref:`setup` in the :ref:`user-manual` for an example of how to set up your Python script to best utilise the :class:`Workflow` object. """ - import binascii import json import logging @@ -37,17 +28,12 @@ from contextlib import contextmanager from copy import deepcopy from typing import Optional +from uuid import uuid4 -try: - import xml.etree.cElementTree as ET -except ImportError: # pragma: no cover - import xml.etree.ElementTree as ET +from . import update +from .update import Version +from .util import atomic_writer, LockFile, uninterruptible, set_config -# imported to maintain API -from workflow.util import AcquisitionError # noqa: F401 -from workflow.util import LockFile, atomic_writer, uninterruptible - -assert sys.version_info[0] == 3 #: Sentinel for properties that haven't been set yet (that might #: correctly have the value ``None``) @@ -57,15 +43,6 @@ # Standard system icons #################################################################### -# These icons are default macOS icons. They are super-high quality, and -# will be familiar to users. -# This library uses `ICON_ERROR` when a workflow dies in flames, so -# in my own workflows, I use `ICON_WARNING` for less fatal errors -# (e.g. bad user input, no results etc.) - -# The system icons are all in this directory. There are many more than -# are listed here - ICON_ROOT = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources" ICON_ACCOUNT = os.path.join(ICON_ROOT, "Accounts.icns") @@ -90,9 +67,8 @@ ICON_SYNC = os.path.join(ICON_ROOT, "Sync.icns") ICON_TRASH = os.path.join(ICON_ROOT, "TrashIcon.icns") ICON_USER = os.path.join(ICON_ROOT, "UserIcon.icns") -# ICON_WARNING = os.path.join(ICON_ROOT, "AlertCautionIcon.icns") # Missing since Big Sur -ICON_WARNING = os.path.join(ICON_ROOT, "AlertNoteIcon.icns") ICON_WEB = os.path.join(ICON_ROOT, "BookmarkIcon.icns") +ICON_WARNING = os.path.join(ICON_ROOT, "AlertNoteIcon.icns") #################################################################### # non-ASCII to ASCII diacritic folding. @@ -335,44 +311,6 @@ "э": "e", "ю": "iu", "я": "ia", - # 'ᴀ': '', - # 'ᴁ': '', - # 'ᴂ': '', - # 'ᴃ': '', - # 'ᴄ': '', - # 'ᴅ': '', - # 'ᴆ': '', - # 'ᴇ': '', - # 'ᴈ': '', - # 'ᴉ': '', - # 'ᴊ': '', - # 'ᴋ': '', - # 'ᴌ': '', - # 'ᴍ': '', - # 'ᴎ': '', - # 'ᴏ': '', - # 'ᴐ': '', - # 'ᴑ': '', - # 'ᴒ': '', - # 'ᴓ': '', - # 'ᴔ': '', - # 'ᴕ': '', - # 'ᴖ': '', - # 'ᴗ': '', - # 'ᴘ': '', - # 'ᴙ': '', - # 'ᴚ': '', - # 'ᴛ': '', - # 'ᴜ': '', - # 'ᴝ': '', - # 'ᴞ': '', - # 'ᴟ': '', - # 'ᴠ': '', - # 'ᴡ': '', - # 'ᴢ': '', - # 'ᴣ': '', - # 'ᴤ': '', - # 'ᴥ': '', "ᴦ": "G", "ᴧ": "L", "ᴨ": "P", @@ -402,7 +340,6 @@ "—": "-", } - #################################################################### # Used by `Workflow.filter` #################################################################### @@ -435,7 +372,6 @@ #: Combination of all other ``MATCH_*`` constants MATCH_ALL = 127 - #################################################################### # Used by `Workflow.check_update` #################################################################### @@ -443,7 +379,6 @@ # Number of days to wait between checking for updates to the workflow DEFAULT_UPDATE_FREQUENCY = 1 - #################################################################### # Keychain access errors #################################################################### @@ -485,12 +420,10 @@ class PasswordExists(KeychainError): def isascii(text): """Test if ``text`` contains only ASCII characters. - :param text: text to test for ASCII-ness - :type text: ``unicode`` + :type text: ``str`` :returns: ``True`` if ``text`` contains only ASCII characters :rtype: ``Boolean`` - """ try: text.encode("ascii") @@ -504,11 +437,9 @@ def isascii(text): #################################################################### -class SerializerManager(object): +class SerializerManager: """Contains registered serializers. - .. versionadded:: 1.8 - A configured instance of this class is available at :attr:`workflow.manager`. @@ -535,14 +466,14 @@ def register(self, name, serializer): ``name`` will be used as the file extension of the saved files. :param name: Name to register ``serializer`` under - :type name: ``unicode`` or ``str`` + :type name: ``str`` :param serializer: object with ``load()`` and ``dump()`` methods """ # Basic validation - serializer.load - serializer.dump + serializer.load # pylint: disable=pointless-statement + serializer.dump # pylint: disable=pointless-statement self._serializers[name] = serializer @@ -550,7 +481,7 @@ def serializer(self, name): """Return serializer object for ``name``. :param name: Name of serializer to return - :type name: ``unicode`` or ``str`` + :type name: ``str`` :returns: serializer object or ``None`` if no such serializer is registered. @@ -564,12 +495,12 @@ def unregister(self, name): serializer. :param name: Name of serializer to remove - :type name: ``unicode`` or ``str`` + :type name: ``str`` :returns: serializer object """ if name not in self._serializers: - raise ValueError("No such serializer registered : {0}".format(name)) + raise ValueError(f"No such serializer registered : {name}") serializer = self._serializers[name] del self._serializers[name] @@ -583,6 +514,8 @@ def serializers(self): class BaseSerializer: + """Base class for serializers.""" + is_binary: Optional[bool] = None @classmethod @@ -591,8 +524,8 @@ def binary_mode(cls): @classmethod def _opener(cls, opener, path, mode="r"): - with opener(path, mode + cls.binary_mode()) as fp: - yield fp + with opener(path, mode + cls.binary_mode()) as f: + yield f @classmethod @contextmanager @@ -608,8 +541,6 @@ def open(cls, path, mode): class JSONSerializer(BaseSerializer): """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``. - .. versionadded:: 1.8 - Use this serializer if you need readable data files. JSON doesn't support Python objects as well as ``pickle``, so be careful which data you try to serialize as JSON. @@ -622,8 +553,6 @@ class JSONSerializer(BaseSerializer): def load(cls, file_obj): """Load serialized object from open JSON file. - .. versionadded:: 1.8 - :param file_obj: file handle :type file_obj: ``file`` object :returns: object loaded from JSON file @@ -636,22 +565,18 @@ def load(cls, file_obj): def dump(cls, obj, file_obj): """Serialize object ``obj`` to open JSON file. - .. versionadded:: 1.8 - :param obj: Python object to serialize :type obj: JSON-serializable data structure :param file_obj: file handle :type file_obj: ``file`` object """ - return json.dump(obj, file_obj, indent=2) + return json.dump(obj, file_obj, indent=2, encoding="utf-8") class PickleSerializer(BaseSerializer): """Wrapper around :mod:`pickle`. Sets ``protocol``. - .. versionadded:: 1.8 - Use this serializer if you need to add custom pickling. """ @@ -662,8 +587,6 @@ class PickleSerializer(BaseSerializer): def load(cls, file_obj): """Load serialized object from open pickle file. - .. versionadded:: 1.8 - :param file_obj: file handle :type file_obj: ``file`` object :returns: object loaded from pickle file @@ -676,8 +599,6 @@ def load(cls, file_obj): def dump(cls, obj, file_obj): """Serialize object ``obj`` to open pickle file. - .. versionadded:: 1.8 - :param obj: Python object to serialize :type obj: Python object :param file_obj: file handle @@ -693,14 +614,13 @@ def dump(cls, obj, file_obj): manager.register("json", JSONSerializer) -class Item(object): +class Item: """Represents a feedback item for Alfred. - Generates Alfred-compliant XML for a single item. + Generates Alfred-compliant JSON for a single item. - You probably shouldn't use this class directly, but via - :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item` - for details of arguments. + Don't use this class directly but via :meth:`Workflow.add_item`. + See :meth:`~Workflow.add_item` for details of arguments. """ @@ -708,93 +628,396 @@ def __init__( self, title, subtitle="", - modifier_subtitles=None, arg=None, autocomplete=None, + match=None, valid=False, uid=None, icon=None, icontype=None, - type=None, + type=None, # pylint: disable=redefined-builtin largetext=None, copytext=None, quicklookurl=None, ): - """Same arguments as :meth:`Workflow.add_item`.""" + """Create a new :class:`Item` object. + + Use same arguments as for + :class:`Workflow.Item `. + + Argument ``subtitle_modifiers`` is not supported. + + """ self.title = title self.subtitle = subtitle - self.modifier_subtitles = modifier_subtitles or {} self.arg = arg self.autocomplete = autocomplete + self.match = match self.valid = valid self.uid = uid self.icon = icon self.icontype = icontype self.type = type + self.quicklookurl = quicklookurl self.largetext = largetext self.copytext = copytext - self.quicklookurl = quicklookurl + + self.modifiers = {} + + self.config = {} + self.variables = {} + + def setvar(self, name, value): + """Set a workflow variable for this Item. + + Args: + name (str): Name of variable. + value (str): Value of variable. + + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (str): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + str or ``default``: Value of variable if set or ``default``. + + """ + return self.variables.get(name, default) + + def add_modifier( + self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None + ): + """Add alternative values for a modifier key. + + Args: + key (str): Modifier key, e.g. ``"cmd"`` or ``"alt"`` + subtitle (str, optional): Override item subtitle. + arg (str, optional): Input for following action. + valid (bool, optional): Override item validity. + icon (str, optional): Filepath/UTI of icon. + icontype (str, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + + In Alfred 4.1+, ``arg`` may also be a :class:`list` or :class:`tuple`. + + Returns: + Modifier: Configured :class:`Modifier`. + + """ + mod = Modifier(key, subtitle, arg, valid, icon, icontype) + + # Add Item variables to Modifier + mod.variables.update(self.variables) + + self.modifiers[key] = mod + + return mod @property - def elem(self): - """Create and return feedback item for Alfred. + def obj(self): + """Item formatted for JSON serialization. - :returns: :class:`ElementTree.Element ` - instance for this :class:`Item` instance. + Returns: + dict: Data suitable for Alfred feedback. """ - # Attributes on element - attr = {} - if self.valid: - attr["valid"] = "yes" - else: - attr["valid"] = "no" - # Allow empty string for autocomplete. This is a useful value, - # as TABing the result will revert the query back to just the - # keyword + # Required values + obj_ = { + "title": self.title, + "subtitle": self.subtitle, + "valid": self.valid, + } + + # Optional values + if self.arg is not None: + obj_["arg"] = self.arg + if self.autocomplete is not None: - attr["autocomplete"] = self.autocomplete + obj_["autocomplete"] = self.autocomplete - # Optional attributes - for name in ("uid", "type"): - value = getattr(self, name, None) - if value: - attr[name] = value - - root = ET.Element("item", attr) - ET.SubElement(root, "title").text = self.title - ET.SubElement(root, "subtitle").text = self.subtitle - - # Add modifier subtitles - for mod in ("cmd", "ctrl", "alt", "shift", "fn"): - if mod in self.modifier_subtitles: - ET.SubElement( - root, "subtitle", {"mod": mod} - ).text = self.modifier_subtitles[mod] - - # Add arg as element instead of attribute on , as it's more - # flexible (newlines aren't allowed in attributes) - if self.arg: - ET.SubElement(root, "arg").text = self.arg - - # Add icon if there is one - if self.icon: - if self.icontype: - attr = dict(type=self.icontype) - else: - attr = {} - ET.SubElement(root, "icon", attr).text = self.icon + if self.match is not None: + obj_["match"] = self.match + + if self.uid is not None: + obj_["uid"] = self.uid + + if self.type is not None: + obj_["type"] = self.type + + if self.quicklookurl is not None: + obj_["quicklookurl"] = self.quicklookurl + + if self.variables: + obj_["variables"] = self.variables + + if self.config: + obj_["config"] = self.config + + # Largetype and copytext + text = self._text() + if text: + obj_["text"] = text + + icon = self._icon() + if icon: + obj_["icon"] = icon + + # Modifiers + mods = self._modifiers() + if mods: + obj_["mods"] = mods + + return obj_ + + def _icon(self): + """Return `icon` object for item. + + Returns: + dict: Mapping for item `icon` (may be empty). + + """ + icon = {} + if self.icon is not None: + icon["path"] = self.icon + + if self.icontype is not None: + icon["type"] = self.icontype + + return icon + + def _text(self): + """Return `largetext` and `copytext` object for item. + + Returns: + dict: `text` mapping (may be empty) + + """ + text = {} + if self.largetext is not None: + text["largetype"] = self.largetext + + if self.copytext is not None: + text["copy"] = self.copytext + + return text + + def _modifiers(self): + """Build `mods` dictionary for JSON feedback. + + Returns: + dict: Modifier mapping or `None`. + + """ + if self.modifiers: + mods = {} + for k, mod in self.modifiers.items(): + mods[k] = mod.obj + + return mods + + return None + + +class Variables(dict): + """Workflow variables for Run Script actions. + + This class allows you to set workflow variables from + Run Script actions. + + It is a subclass of :class:`dict`. + + >>> v = Variables(username='deanishe', password='hunter2') + >>> v.arg = u'output value' + >>> print(v) + + See :ref:`variables-run-script` in the User Guide for more + information. + + Args: + arg (str or list, optional): Main output/``{query}``. + **variables: Workflow variables to set. + + In Alfred 4.1+ ``arg`` may also be a :class:`list` or :class:`tuple`. + + Attributes: + arg (str or list): Output value (``{query}``). + In Alfred 4.1+ ``arg`` may also be a :class:`list` or + :class:`tuple`. + config (dict): Configuration for downstream workflow element. + + """ + + def __init__(self, arg=None, **variables): + """Create a new `Variables` object.""" + self.arg = arg + self.config = {} + super().__init__(**variables) + + @property + def obj(self): + """``alfredworkflow`` :class:`dict`.""" + obj_ = {} + if self: + d2 = {} + for k, v in self.items(): + d2[k] = v + obj_["variables"] = d2 + + if self.config: + obj_["config"] = self.config + + if self.arg is not None: + obj_["arg"] = self.arg + + return {"alfredworkflow": obj_} + + +class Modifier: + """Modify :class:`Item` arg/icon/variables when modifier key is pressed. + + Don't use this class directly (as it won't be associated with any + :class:`Item`), but rather use :meth:`Item.add_modifier()` + to add modifiers to results. + + >>> it = wf.add_item('Title', 'Subtitle', valid=True) + >>> it.setvar('name', 'default') + >>> m = it.add_modifier('cmd') + >>> m.setvar('name', 'alternate') + + See :ref:`workflow-variables` in the User Guide for more information + and :ref:`example usage `. + + Args: + key (str): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. + subtitle (str, optional): Override default subtitle. + arg (str, optional): Argument to pass for this modifier. + valid (bool, optional): Override item's validity. + icon (str, optional): Filepath/UTI of icon to use + icontype (str, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + + Attributes: + arg (str): Arg to pass to following action. + config (dict): Configuration for a downstream element, such as + a File Filter. + icon (str): Filepath/UTI of icon. + icontype (str): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + key (str): Modifier key (see above). + subtitle (str): Override item subtitle. + valid (bool): Override item validity. + variables (dict): Workflow variables set by this modifier. + + """ + + def __init__( + self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None + ): + """Create a new :class:`Modifier`. + + Don't use this class directly (as it won't be associated with any + :class:`Item`), but rather use :meth:`Item.add_modifier()` + to add modifiers to results. + + Args: + key (str): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. + subtitle (str, optional): Override default subtitle. + arg (str, optional): Argument to pass for this modifier. + valid (bool, optional): Override item's validity. + icon (str, optional): Filepath/UTI of icon to use + icontype (str, optional): Type of icon. See + :meth:`Workflow.add_item() ` + for valid values. + + """ + self.key = key + self.subtitle = subtitle + self.arg = arg + self.valid = valid + self.icon = icon + self.icontype = icontype + + self.config = {} + self.variables = {} + + def setvar(self, name, value): + """Set a workflow variable for this Item. + + Args: + name (str): Name of variable. + value (str): Value of variable. + + """ + self.variables[name] = value + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (str): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + str or ``default``: Value of variable if set or ``default``. + + """ + return self.variables.get(name, default) + + @property + def obj(self): + """Modifier formatted for JSON serialization for Alfred 3. + + Returns: + dict: Modifier for serializing to JSON. + + """ + obj_ = {} + + if self.subtitle is not None: + obj_["subtitle"] = self.subtitle + + if self.arg is not None: + obj_["arg"] = self.arg - if self.largetext: - ET.SubElement(root, "text", {"type": "largetype"}).text = self.largetext + if self.valid is not None: + obj_["valid"] = self.valid - if self.copytext: - ET.SubElement(root, "text", {"type": "copy"}).text = self.copytext + if self.variables: + obj_["variables"] = self.variables - if self.quicklookurl: - ET.SubElement(root, "quicklookurl").text = self.quicklookurl + if self.config: + obj_["config"] = self.config - return root + icon = self._icon() + if icon: + obj_["icon"] = icon + + return obj_ + + def _icon(self): + """Return `icon` object for item. + + Returns: + dict: Mapping for item `icon` (may be empty). + + """ + icon = {} + if self.icon is not None: + icon["path"] = self.icon + + if self.icontype is not None: + icon["type"] = self.icontype + + return icon class Settings(dict): @@ -805,7 +1028,7 @@ class Settings(dict): (and settings file) will be initialised with ``defaults``. :param filepath: where to save the settings - :type filepath: :class:`unicode` + :type filepath: :class:`str` :param defaults: dict of default settings :type defaults: :class:`dict` @@ -817,14 +1040,14 @@ class Settings(dict): def __init__(self, filepath, defaults=None): """Create new :class:`Settings` object.""" - super(Settings, self).__init__() + super().__init__() self._filepath = filepath self._nosave = False self._original = {} if os.path.exists(self._filepath): self._load() elif defaults: - for key, val in list(defaults.items()): + for key, val in defaults.items(): self[key] = val self.save() # save default settings @@ -832,8 +1055,8 @@ def _load(self): """Load cached settings from JSON file `self._filepath`.""" data = {} with LockFile(self._filepath, 0.5): - with open(self._filepath, "r") as fp: - data.update(json.load(fp)) + with open(self._filepath, "rb") as f: + data.update(json.load(f)) self._original = deepcopy(data) @@ -856,44 +1079,39 @@ def save(self): data.update(self) with LockFile(self._filepath, 0.5): - with atomic_writer(self._filepath, "w") as fp: - json.dump(data, fp, sort_keys=True, indent=2) + with atomic_writer(self._filepath, "w") as f: + json.dump(data, f, sort_keys=True, indent=2) # dict methods def __setitem__(self, key, value): """Implement :class:`dict` interface.""" if self._original.get(key) != value: - super(Settings, self).__setitem__(key, value) + super().__setitem__(key, value) self.save() def __delitem__(self, key): """Implement :class:`dict` interface.""" - super(Settings, self).__delitem__(key) + super().__delitem__(key) self.save() def update(self, *args, **kwargs): """Override :class:`dict` method to save on update.""" - super(Settings, self).update(*args, **kwargs) + super().update(*args, **kwargs) self.save() def setdefault(self, key, value=None): """Override :class:`dict` method to save on update.""" - ret = super(Settings, self).setdefault(key, value) + ret = super().setdefault(key, value) self.save() return ret -class Workflow(object): +class Workflow: """The ``Workflow`` object is the main interface to Alfred-Workflow. - It provides APIs for accessing the Alfred/workflow environment, storing & caching data, using Keychain, and generating Script Filter feedback. - - ``Workflow`` is compatible with Alfred 2+. Subclass - :class:`~workflow.Workflow3` provides additional features, - only available in Alfred 3+, such as workflow variables. - + ``Workflow`` is compatible with Alfred 3+. :param default_settings: default workflow settings. If no settings file exists, :class:`Workflow.settings` will be pre-populated with ``default_settings``. @@ -909,10 +1127,10 @@ class Workflow(object): :param input_encoding: encoding of command line arguments. You should probably leave this as the default (``utf-8``), which is the encoding Alfred uses. - :type input_encoding: :class:`unicode` - :param normalization: normalisation to apply to CLI args. + :type input_encoding: :class:`str` + :param normalization: normalization to apply to CLI args. See :meth:`Workflow.decode` for more details. - :type normalization: :class:`unicode` + :type normalization: :class:`str` :param capture_args: Capture and act on ``workflow:*`` arguments. See :ref:`Magic arguments ` for details. :type capture_args: :class:`Boolean` @@ -925,12 +1143,9 @@ class Workflow(object): this URL will be displayed in the log and Alfred's debugger. It can also be opened directly in a web browser with the ``workflow:help`` :ref:`magic argument `. - :type help_url: :class:`unicode` or :class:`str` - + :type help_url: :class:`str` """ - # Which class to use to generate feedback items. You probably - # won't want to change this item_class = Item def __init__( @@ -944,13 +1159,10 @@ def __init__( help_url=None, ): """Create new :class:`Workflow` object.""" - - seralizer = "pickle" - self._default_settings = default_settings or {} self._update_settings = update_settings or {} self._input_encoding = input_encoding - self._normalizsation = normalization + self._normalization = normalization self._capture_args = capture_args self.help_url = help_url self._workflowdir = None @@ -959,8 +1171,8 @@ def __init__( self._bundleid = None self._debugging = None self._name = None - self._cache_serializer = seralizer - self._data_serializer = seralizer + self._cache_serializer = "pickle" + self._data_serializer = "pickle" self._info = None self._info_loaded = False self._logger = None @@ -982,14 +1194,18 @@ def __init__( #: what the user should enter (prefixed with :attr:`magic_prefix`) #: and the value is a callable that will be called when the argument #: is entered. If you would like to display a message in Alfred, the - #: function should return a ``unicode`` string. + #: function should return a ``str`` #: #: By default, the magic arguments documented #: :ref:`here ` are registered. self.magic_arguments = {} - self._register_default_magic() - + self.variables = {} + self._rerun = 0 + # Get session ID from environment if present + self._session_id = os.getenv("_WF_SESSION_ID") or None + if self._session_id: + self.setvar("_WF_SESSION_ID", self._session_id) if libraries: sys.path = libraries + sys.path @@ -1002,17 +1218,13 @@ def __init__( @property def alfred_version(self): """Alfred version as :class:`~workflow.update.Version` object.""" - from .update import Version - return Version(self.alfred_env.get("version")) @property def alfred_env(self): """Dict of Alfred's environmental variables minus ``alfred_`` prefix. - .. versionadded:: 1.7 - - The variables Alfred 2.4+ exports are: + The variables Alfred exports are: ============================ ========================================= Variable Description @@ -1046,7 +1258,7 @@ def alfred_env(self): workflow configuration sheet/info.plist ============================ ========================================= - **Note:** all values are Unicode strings except ``version_build`` and + **Note:** all values are strings except ``version_build`` and ``theme_subtext``, which are integers. :returns: ``dict`` of Alfred's environmental variables without the @@ -1079,12 +1291,9 @@ def alfred_env(self): if value: if key in ("debug", "version_build", "theme_subtext"): - if value.isdigit(): - value = int(value) - else: - value = False - else: - value = self.decode(value) + value = int(value) + # else: + # value = self.decode(value) data[key] = value @@ -1104,7 +1313,7 @@ def bundleid(self): """Workflow bundle ID from environmental vars or ``info.plist``. :returns: bundle ID - :rtype: ``unicode`` + :rtype: ``str`` """ if not self._bundleid: @@ -1123,16 +1332,14 @@ def debugging(self): :rtype: ``bool`` """ - return bool( - self.alfred_env.get("debug") == 1 or os.environ.get("PYTEST_RUNNING") - ) + return self.alfred_env.get("debug") == 1 @property def name(self): """Workflow name from Alfred's environmental vars or ``info.plist``. :returns: workflow name - :rtype: ``unicode`` + :rtype: ``str`` """ if not self._name: @@ -1147,8 +1354,6 @@ def name(self): def version(self): """Return the version of the workflow. - .. versionadded:: 1.9.10 - Get the workflow version from environment variable, the ``update_settings`` dict passed on instantiation, the ``version`` file located in the workflow's @@ -1176,7 +1381,7 @@ def version(self): filepath = self.workflowfile("version") if os.path.exists(filepath): - with open(filepath, "r") as fileobj: + with open(filepath, "rb") as fileobj: version = fileobj.read() # info.plist @@ -1184,8 +1389,6 @@ def version(self): version = self.info.get("version") if version: - from .update import Version - version = Version(version) self._version = version @@ -1200,7 +1403,7 @@ def args(self): Args are decoded and normalised via :meth:`~Workflow.decode`. - The encoding and normalisation are the ``input_encoding`` and + The encoding and normalization are the ``input_encoding`` and ``normalization`` arguments passed to :class:`Workflow` (``UTF-8`` and ``NFC`` are the defaults). @@ -1217,10 +1420,10 @@ def args(self): # Handle magic args if len(args) and self._capture_args: - for name in self.magic_arguments: - key = "{0}{1}".format(self.magic_prefix, name) + for k, v in self.magic_arguments.items(): + key = f"{self.magic_prefix}{k}" if key in args: - msg = self.magic_arguments[name]() + msg = v() if msg: self.logger.debug(msg) @@ -1235,18 +1438,12 @@ def cachedir(self): """Path to workflow's cache directory. The cache directory is a subdirectory of Alfred's own cache directory - in ``~/Library/Caches``. The full path is in Alfred 4+ is: + in ``~/Library/Caches``. The full path is: ``~/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/`` - For earlier versions: - - ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` - - where ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. - Returns: - unicode: full path to workflow's cache directory + str: full path to workflow's cache directory """ if self.alfred_env.get("workflow_cache"): @@ -1259,10 +1456,10 @@ def cachedir(self): @property def _default_cachedir(self): - """Alfred 2's default cache directory.""" + """Alfred's default cache directory.""" return os.path.join( os.path.expanduser( - "~/Library/Caches/com.runningwithcrayons.Alfred-2/" "Workflow Data/" + "~/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/" ), self.bundleid, ) @@ -1272,18 +1469,12 @@ def datadir(self): """Path to workflow's data directory. The data directory is a subdirectory of Alfred's own data directory in - ``~/Library/Application Support``. The full path for Alfred 4+ is: + ``~/Library/Application Support``. The full path is: ``~/Library/Application Support/Alfred/Workflow Data/`` - For earlier versions, the path is: - - ``~/Library/Application Support/Alfred X/Workflow Data/`` - - where ``Alfred X` is ``Alfred 2`` or ``Alfred 3``. - Returns: - unicode: full path to workflow data directory + str: full path to workflow data directory """ if self.alfred_env.get("workflow_data"): @@ -1296,9 +1487,9 @@ def datadir(self): @property def _default_datadir(self): - """Alfred 2's default data directory.""" + """Alfred's default data directory.""" return os.path.join( - os.path.expanduser("~/Library/Application Support/Alfred 2/Workflow Data/"), + os.path.expanduser("~/Library/Application Support/Alfred/Workflow Data/"), self.bundleid, ) @@ -1307,7 +1498,7 @@ def workflowdir(self): """Path to workflow's root directory (where ``info.plist`` is). Returns: - unicode: full path to workflow root directory + str: full path to workflow root directory """ if not self._workflowdir: @@ -1321,16 +1512,12 @@ def workflowdir(self): # climb the directory tree until we find `info.plist` for dirpath in candidates: - - # Ensure directory path is Unicode - dirpath = self.decode(dirpath) - while True: if os.path.exists(os.path.join(dirpath, "info.plist")): self._workflowdir = dirpath break - elif dirpath == "/": + if dirpath == "/": # no `info.plist` found break @@ -1353,9 +1540,9 @@ def cachefile(self, filename): :attr:`cache directory `. :param filename: basename of file - :type filename: ``unicode`` + :type filename: ``str`` :returns: full path to file within cache directory - :rtype: ``unicode`` + :rtype: ``str`` """ return os.path.join(self.cachedir, filename) @@ -1367,9 +1554,9 @@ def datafile(self, filename): :attr:`data directory `. :param filename: basename of file - :type filename: ``unicode`` + :type filename: ``str`` :returns: full path to file within data directory - :rtype: ``unicode`` + :rtype: ``str`` """ return os.path.join(self.datadir, filename) @@ -1378,9 +1565,9 @@ def workflowfile(self, filename): """Return full path to ``filename`` in workflow's root directory. :param filename: basename of file - :type filename: ``unicode`` + :type filename: ``str`` :returns: full path to file within data directory - :rtype: ``unicode`` + :rtype: ``str`` """ return os.path.join(self.workflowdir, filename) @@ -1390,10 +1577,10 @@ def logfile(self): """Path to logfile. :returns: path to logfile within workflow's cache directory - :rtype: ``unicode`` + :rtype: ``str`` """ - return self.cachefile("%s.log" % self.bundleid) + return self.cachefile(f"{self.bundleid}.log") @property def logger(self): @@ -1416,8 +1603,7 @@ def logger(self): # Only add one set of handlers # Exclude from coverage, as pytest will have configured the # root logger already - if not len(logger.handlers): # pragma: no cover - + if not logger.handlers: # pragma: no cover fmt = logging.Formatter( "%(asctime)s %(filename)s:%(lineno)s" " %(levelname)-8s %(message)s", datefmt="%H:%M:%S", @@ -1457,7 +1643,7 @@ def settings_path(self): """Path to settings file within workflow's data directory. :returns: path to ``settings.json`` file - :rtype: ``unicode`` + :rtype: ``str`` """ if not self._settings_path: @@ -1489,15 +1675,13 @@ def settings(self): def cache_serializer(self): """Name of default cache serializer. - .. versionadded:: 1.8 - This serializer is used by :meth:`cache_data()` and :meth:`cached_data()` See :class:`SerializerManager` for details. :returns: serializer name - :rtype: ``unicode`` + :rtype: ``str`` """ return self._cache_serializer @@ -1506,8 +1690,6 @@ def cache_serializer(self): def cache_serializer(self, serializer_name): """Set the default cache serialization format. - .. versionadded:: 1.8 - This serializer is used by :meth:`cache_data()` and :meth:`cached_data()` @@ -1521,8 +1703,8 @@ def cache_serializer(self, serializer_name): """ if manager.serializer(serializer_name) is None: raise ValueError( - "Unknown serializer : `{0}`. Register your serializer " - "with `manager` first.".format(serializer_name) + f"Unknown serializer : `{serializer_name}`. " + "Register your serializer with `manager` first." ) self.logger.debug("default cache serializer: %s", serializer_name) @@ -1533,15 +1715,13 @@ def cache_serializer(self, serializer_name): def data_serializer(self): """Name of default data serializer. - .. versionadded:: 1.8 - This serializer is used by :meth:`store_data()` and :meth:`stored_data()` See :class:`SerializerManager` for details. :returns: serializer name - :rtype: ``unicode`` + :rtype: ``str`` """ return self._data_serializer @@ -1550,8 +1730,6 @@ def data_serializer(self): def data_serializer(self, serializer_name): """Set the default cache serialization format. - .. versionadded:: 1.8 - This serializer is used by :meth:`store_data()` and :meth:`stored_data()` @@ -1564,8 +1742,8 @@ def data_serializer(self, serializer_name): """ if manager.serializer(serializer_name) is None: raise ValueError( - "Unknown serializer : `{0}`. Register your serializer " - "with `manager` first.".format(serializer_name) + f"Unknown serializer : `{serializer_name}`. " + "Register your serializer with `manager` first." ) self.logger.debug("default data serializer: %s", serializer_name) @@ -1577,32 +1755,30 @@ def stored_data(self, name): Returns ``None`` if there are no data stored under ``name``. - .. versionadded:: 1.8 - :param name: name of datastore """ - metadata_path = self.datafile(".{0}.alfred-workflow".format(name)) + metadata_path = self.datafile(f".{name}.alfred-workflow") if not os.path.exists(metadata_path): self.logger.debug("no data stored for `%s`", name) return None - with open(metadata_path, "r") as file_obj: + with open(metadata_path, "r", encoding="utf-8") as file_obj: serializer_name = file_obj.read().strip() serializer = manager.serializer(serializer_name) if serializer is None: raise ValueError( - "Unknown serializer `{0}`. Register a corresponding " - "serializer with `manager.register()` " - "to load this data.".format(serializer_name) + f"Unknown serializer `{serializer_name}`. " + "Register a corresponding serializer with `manager.register()`" + " to load this data." ) self.logger.debug("data `%s` stored as `%s`", name, serializer_name) - filename = "{0}.{1}".format(name, serializer_name) + filename = f"{name}.{serializer_name}" data_path = self.datafile(filename) if not os.path.exists(data_path): @@ -1622,8 +1798,6 @@ def stored_data(self, name): def store_data(self, name, data, serializer=None): """Save data to data directory. - .. versionadded:: 1.8 - If ``data`` is ``None``, the datastore will be deleted. Note that the datastore does NOT support mutliple threads. @@ -1651,14 +1825,14 @@ def delete_paths(paths): # In order for `stored_data()` to be able to load data stored with # an arbitrary serializer, yet still have meaningful file extensions, # the format (i.e. extension) is saved to an accompanying file - metadata_path = self.datafile(".{0}.alfred-workflow".format(name)) - filename = "{0}.{1}".format(name, serializer_name) + metadata_path = self.datafile(f".{name}.alfred-workflow") + filename = f"{name}.{serializer_name}" data_path = self.datafile(filename) if data_path == self.settings_path: raise ValueError( "Cannot save data to" - + "`{0}` with format `{1}`. ".format(name, serializer_name) + + f"`{name}` with format `{serializer_name}`. " + "This would overwrite Alfred-Workflow's settings file." ) @@ -1666,8 +1840,8 @@ def delete_paths(paths): if serializer is None: raise ValueError( - "Invalid serializer `{0}`. Register your serializer with " - "`manager.register()` first.".format(serializer_name) + f"Invalid serializer `{serializer_name}`. " + "Register your serializer with `manager.register()` first." ) if data is None: # Delete cached data @@ -1709,7 +1883,7 @@ def cached_data(self, name, data_func=None, max_age=60): """ serializer = manager.serializer(self.cache_serializer) - cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer)) + cache_path = self.cachefile(f"{name}.{self.cache_serializer}") age = self.cached_data_age(name) if (age < max_age or max_age == 0) and os.path.exists(cache_path): @@ -1739,7 +1913,7 @@ def cache_data(self, name, data): """ serializer = manager.serializer(self.cache_serializer) - cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer)) + cache_path = self.cachefile(f"{name}.{self.cache_serializer}") if data is None: if os.path.exists(cache_path): @@ -1773,12 +1947,12 @@ def cached_data_age(self, name): """Return age in seconds of cache `name` or 0 if cache doesn't exist. :param name: name of datastore - :type name: ``unicode`` + :type name: ``str`` :returns: age of datastore in seconds :rtype: ``int`` """ - cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer)) + cache_path = self.cachefile(f"{name}.{self.cache_serializer}") if not os.path.exists(cache_path): return 0 @@ -1806,11 +1980,11 @@ def filter( all items will match. :param query: query to test items against - :type query: ``unicode`` + :type query: ``str`` :param items: iterable of items to test :type items: ``list`` or ``tuple`` :param key: function to get comparison key from ``items``. - Must return a ``unicode`` string. The default simply returns + Must return ``str``. The default simply returns the item. :type key: ``callable`` :param ascending: set to ``True`` to get worst matches first @@ -1882,8 +2056,6 @@ def filter( **Diacritic folding** - .. versionadded:: 1.3 - If ``fold_diacritics`` is ``True`` (the default), and ``query`` contains only ASCII characters, non-ASCII characters in search keys will be converted to ASCII equivalents (e.g. **ü** -> **u**, @@ -1921,11 +2093,11 @@ def filter( for word in words: if word == "": continue - s, rule = self._filter_item(value, word, match_on, fold_diacritics) + score_, rule = self._filter_item(value, word, match_on, fold_diacritics) - if not s: # Skip items that don't match part of the query + if not score_: # Skip items that don't match part of the query skip = True - score += s + score += score_ if skip: continue @@ -1940,10 +2112,10 @@ def filter( # sort on keys, then discard the keys results.sort(reverse=ascending) - results = [t[1] for t in results] + results = [result[1] for result in results] if min_score: - results = [r for r in results if r[1] > min_score] + results = [result for result in results if result[1] > min_score] if max_results and len(results) > max_results: results = results[:max_results] @@ -1952,7 +2124,7 @@ def filter( if include_score: return results # just return list of items - return [t[0] for t in results] + return [result[0] for result in results] def _filter_item(self, value, query, match_on, fold_diacritics): """Filter ``value`` against ``query`` using rules ``match_on``. @@ -1997,7 +2169,6 @@ def _filter_item(self, value, query, match_on, fold_diacritics): or match_on & MATCH_INITIALS_STARTSWITH ): atoms = [s.lower() for s in split_on_delimiters(value)] - # print('atoms : %s --> %s' % (value, atoms)) # initials of the atoms initials = "".join([s[0] for s in atoms if s]) @@ -2021,7 +2192,7 @@ def _filter_item(self, value, query, match_on, fold_diacritics): # `query` is a substring of initials, e.g. ``doh`` matches # "The Dukes of Hazzard" - elif match_on & MATCH_INITIALS_CONTAIN and query in initials: + if match_on & MATCH_INITIALS_CONTAIN and query in initials: score = 95.0 - (len(initials) / len(query)) return (score, MATCH_INITIALS_CONTAIN) @@ -2053,9 +2224,8 @@ def _search_for_query(self, query): # Build pattern: include all characters pattern = [] - for c in query: - # pattern.append('[^{0}]*{0}'.format(re.escape(c))) - pattern.append(".*?{0}".format(re.escape(c))) + for char in query: + pattern.append(f".*?{re.escape(char)}") pattern = "".join(pattern) search = re.compile(pattern, re.IGNORECASE).search @@ -2111,7 +2281,7 @@ def run(self, func, text_errors=False): # run self.set_last_version() - except Exception as err: + except Exception as err: # pylint: disable=broad-except self.logger.exception(err) if self.help_url: self.logger.info("for assistance, see: %s", self.help_url) @@ -2128,7 +2298,7 @@ def run(self, func, text_errors=False): else: # pragma: no cover name = os.path.dirname(__file__) self.add_item( - "Error in workflow '%s'" % name, str(err), icon=ICON_ERROR + f"Error in workflow '{name}'", str(err), icon=ICON_ERROR ) self.send_feedback() return 1 @@ -2142,64 +2312,128 @@ def run(self, func, text_errors=False): # Alfred feedback methods ------------------------------------------ + @property + def rerun(self): + """How often (in seconds) Alfred should re-run the Script Filter.""" + return self._rerun + + @rerun.setter + def rerun(self, seconds): + """Interval at which Alfred should re-run the Script Filter. + + Args: + seconds (int): Interval between runs. + """ + self._rerun = seconds + + @property + def session_id(self): + """A unique session ID every time the user uses the workflow. + + The session ID persists while the user is using this workflow. + It expires when the user runs a different workflow or closes + Alfred. + + """ + if not self._session_id: + self._session_id = uuid4().hex + self.setvar("_WF_SESSION_ID", self._session_id) + + return self._session_id + + def setvar(self, name, value, persist=False): + """Set a "global" workflow variable. + + .. versionchanged:: 1.33 + + These variables are always passed to downstream workflow objects. + + If you have set :attr:`rerun`, these variables are also passed + back to the script when Alfred runs it again. + + Args: + name (str): Name of variable. + value (str): Value of variable. + persist (bool, optional): Also save variable to ``info.plist``? + + """ + self.variables[name] = value + if persist: + set_config(name, value, self.bundleid) + self.logger.debug( + "saved variable %r with value %r to info.plist", name, value + ) + + def getvar(self, name, default=None): + """Return value of workflow variable for ``name`` or ``default``. + + Args: + name (str): Variable name. + default (None, optional): Value to return if variable is unset. + + Returns: + str or ``default``: Value of variable if set or ``default``. + + """ + return self.variables.get(name, default) + def add_item( self, title, subtitle="", - modifier_subtitles=None, arg=None, autocomplete=None, valid=False, uid=None, icon=None, icontype=None, - type=None, + type=None, # pylint: disable=redefined-builtin largetext=None, copytext=None, quicklookurl=None, + match=None, ): """Add an item to be output to Alfred. - :param title: Title shown in Alfred - :type title: ``unicode`` - :param subtitle: Subtitle shown in Alfred - :type subtitle: ``unicode`` - :param modifier_subtitles: Subtitles shown when modifier - (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase - keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn`` - :type modifier_subtitles: ``dict`` + :param title: Title shown in Alfred. + :type title: ``str`` + :param subtitle: Subtitle shown in Alfred. + :type subtitle: ``str`` :param arg: Argument passed by Alfred as ``{query}`` when item is - actioned - :type arg: ``unicode`` - :param autocomplete: Text expanded in Alfred when item is TABbed - :type autocomplete: ``unicode`` - :param valid: Whether or not item can be actioned + actioned. + :type arg: ``str``, ``list`` or ``tuple`` + :param autocomplete: Text expanded in Alfred when item is TABbed. + :type autocomplete: ``str`` + :param valid: Whether or not item can be actioned. :type valid: ``Boolean`` - :param uid: Used by Alfred to remember/sort items - :type uid: ``unicode`` - :param icon: Filename of icon to use - :type icon: ``unicode`` - :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'`` - or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype - such as ``'public.folder'``. Use ``'fileicon'`` when you wish to - use the icon of the file specified as ``icon``, e.g. - ``icon='/Applications/Safari.app', icontype='fileicon'``. - Leave as `None` if ``icon`` points to an actual - icon file. - :type icontype: ``unicode`` + :param uid: Used by Alfred to remember/sort items. + :type uid: ``str`` + :param icon: Filename of icon to use. + :type icon: ``str`` + :param icontype: Type of icon. Must be one of ``None``, ``'filetype'`` + or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype + such as ``'public.folder'``. Use ``'fileicon'`` when you wish to + use the icon of the file specified as ``icon``, e.g. + ``icon='/Applications/Safari.app', icontype='fileicon'``. + Leave as `None` if ``icon`` points to an actual icon file. + :type icontype: ``str`` :param type: Result type. Currently only ``'file'`` is supported (by Alfred). This will tell Alfred to enable file actions for this item. - :type type: ``unicode`` + :type type: ``str`` :param largetext: Text to be displayed in Alfred's large text box if user presses CMD+L on item. - :type largetext: ``unicode`` + :type largetext: ``str`` :param copytext: Text to be copied to pasteboard if user presses CMD+C on item. - :type copytext: ``unicode`` + :type copytext: ``str`` :param quicklookurl: URL to be displayed using Alfred's Quick Look feature (tapping ``SHIFT`` or ``⌘+Y`` on a result). - :type quicklookurl: ``unicode`` + :type quicklookurl: ``str`` + :param match: If you have "Alfred filters results" turned on for + your Script Filter, Alfred will filter against this field, not + ``title``. + :type match: ``str`` :returns: :class:`Item` instance See :ref:`icons` for a list of the supported system icons. @@ -2218,9 +2452,9 @@ def add_item( item = self.item_class( title, subtitle, - modifier_subtitles, arg, autocomplete, + match, valid, uid, icon, @@ -2230,16 +2464,62 @@ def add_item( copytext, quicklookurl, ) + + # Add variables to child item + item.variables.update(self.variables) + self._items.append(item) return item - def send_feedback(self): - """Print stored items to console/Alfred as XML.""" - root = ET.Element("items") + @property + def obj(self): + """Feedback formatted for JSON serialization. + + Returns: + dict: Data suitable for Alfred feedback. + + """ + items = [] for item in self._items: - root.append(item.elem) - sys.stdout.write('\n') - sys.stdout.write(ET.tostring(root, encoding="unicode")) + items.append(item.obj) + + obj_ = {"items": items} + if self.variables: + obj_["variables"] = self.variables + if self.rerun: + obj_["rerun"] = self.rerun + return obj_ + + def warn_empty(self, title, subtitle="", icon=None): + """Add a warning to feedback if there are no items. + + Add a warning item to Alfred feedback if no other items + have been added. This is a handy shortcut to prevent Alfred + from showing its fallback searches, which it does if no + items are returned. + + Args: + title (str): Title of feedback item. + subtitle (str, optional): Subtitle of feedback item. + icon (str, optional): Icon for feedback item. If not + specified, ``ICON_ERROR`` is used. + + Returns: + Item: Newly-created item. + + """ + if self._items: + return None + + icon = icon or ICON_ERROR + return self.add_item(title, subtitle, icon=icon) + + def send_feedback(self): + """Print stored items to console/Alfred as JSON.""" + if self.debugging: + json.dump(self.obj, sys.stdout, indent=2, separators=(",", ": ")) + else: + json.dump(self.obj, sys.stdout) sys.stdout.flush() #################################################################### @@ -2250,8 +2530,6 @@ def send_feedback(self): def first_run(self): """Return ``True`` if it's the first time this version has run. - .. versionadded:: 1.9.10 - Raises a :class:`ValueError` if :attr:`version` isn't set. """ @@ -2267,8 +2545,6 @@ def first_run(self): def last_version_run(self): """Return version of last version to run (or ``None``). - .. versionadded:: 1.9.10 - :returns: :class:`~workflow.update.Version` instance or ``None`` @@ -2277,8 +2553,6 @@ def last_version_run(self): version = self.settings.get("__workflow_last_version") if version: - from .update import Version - version = Version(version) self._last_version_run = version @@ -2290,11 +2564,9 @@ def last_version_run(self): def set_last_version(self, version=None): """Set :attr:`last_version_run` to current version. - .. versionadded:: 1.9.10 - :param version: version to store (default is current version) :type version: :class:`~workflow.update.Version` instance - or ``unicode`` + or ``str`` :returns: ``True`` if version is saved, else ``False`` """ @@ -2306,8 +2578,6 @@ def set_last_version(self, version=None): version = self.version if isinstance(version, str): - from .update import Version - version = Version(version) self.settings["__workflow_last_version"] = str(version) @@ -2320,8 +2590,6 @@ def set_last_version(self, version=None): def update_available(self): """Whether an update is available. - .. versionadded:: 1.9 - See :ref:`guide-updates` in the :ref:`user-manual` for detailed information on how to enable your workflow to update itself. @@ -2333,7 +2601,6 @@ def update_available(self): # is used (update.py is called without the user's settings) status = Workflow().cached_data(key, max_age=0) - # self.logger.debug('update status: %r', status) if not status or not status.get("available"): return False @@ -2343,8 +2610,6 @@ def update_available(self): def prereleases(self): """Whether workflow should update to pre-release versions. - .. versionadded:: 1.16 - :returns: ``True`` if pre-releases are enabled with the :ref:`magic argument ` or the ``update_settings`` dict, else ``False``. @@ -2358,8 +2623,6 @@ def prereleases(self): def check_update(self, force=False): """Call update script if it's time to check for a new release. - .. versionadded:: 1.9 - The update script will be run in the background, so it won't interfere in the execution of your workflow. @@ -2379,16 +2642,14 @@ def check_update(self, force=False): # Check for new version if it's time if force or not self.cached_data_fresh(key, frequency * 86400): + repo = self._update_settings["github_slug"] - # version = self._update_settings['version'] version = str(self.version) from .background import run_in_background - # update.py is adjacent to this file - update_script = os.path.join(os.path.dirname(__file__), "update.py") + cmd = ["/usr/bin/python3", "-m", "workflow.update", "check", repo, version] - cmd = [sys.executable, update_script, "check", repo, version] if self.prereleases: cmd.append("--prereleases") @@ -2402,8 +2663,6 @@ def check_update(self, force=False): def start_update(self): """Check for update and download and install new workflow file. - .. versionadded:: 1.9 - See :ref:`guide-updates` in the :ref:`user-manual` for detailed information on how to enable your workflow to update itself. @@ -2411,10 +2670,7 @@ def start_update(self): installed, else ``False`` """ - from . import update - repo = self._update_settings["github_slug"] - # version = self._update_settings['version'] version = str(self.version) if not update.check_update(repo, version, self.prereleases): @@ -2422,10 +2678,7 @@ def start_update(self): from .background import run_in_background - # update.py is adjacent to this file - update_script = os.path.join(os.path.dirname(__file__), "update.py") - - cmd = [sys.executable, update_script, "install", repo, version] + cmd = ["/usr/bin/python3", "-m", "workflow.update", "install", repo, version] if self.prereleases: cmd.append("--prereleases") @@ -2441,22 +2694,18 @@ def start_update(self): def save_password(self, account, password, service=None): """Save account credentials. - If the account exists, the old password will first be deleted (Keychain throws an error otherwise). - If something goes wrong, a :class:`KeychainError` exception will be raised. - :param account: name of the account the password is for, e.g. "Pinboard" - :type account: ``unicode`` + :type account: ``str`` :param password: the password to secure - :type password: ``unicode`` + :type password: ``str`` :param service: Name of the service. By default, this is the workflow's bundle ID - :type service: ``unicode`` - + :type service: ``str`` """ if not service: service = self.bundleid @@ -2483,18 +2732,15 @@ def save_password(self, account, password, service=None): def get_password(self, account, service=None): """Retrieve the password saved at ``service/account``. - Raise :class:`PasswordNotFound` exception if password doesn't exist. - :param account: name of the account the password is for, e.g. "Pinboard" - :type account: ``unicode`` + :type account: ``str`` :param service: Name of the service. By default, this is the workflow's bundle ID - :type service: ``unicode`` + :type service: ``str`` :returns: account password - :rtype: ``unicode`` - + :rtype: ``str`` """ if not service: service = self.bundleid @@ -2504,16 +2750,16 @@ def get_password(self, account, service=None): # Parsing of `security` output is adapted from python-keyring # by Jason R. Coombs # https://pypi.python.org/pypi/keyring - m = re.search( + match = re.search( r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?', output ) - if m: - groups = m.groupdict() - h = groups.get("hex") + if match: + groups = match.groupdict() + hex_ = groups.get("hex") password = groups.get("pw") - if h: - password = str(binascii.unhexlify(h), "utf-8") + if hex_: + password = str(binascii.unhexlify(hex_), "utf-8") self.logger.debug("got password : %s:%s", service, account) @@ -2521,16 +2767,13 @@ def get_password(self, account, service=None): def delete_password(self, account, service=None): """Delete the password stored at ``service/account``. - Raise :class:`PasswordNotFound` if account is unknown. - :param account: name of the account the password is for, e.g. "Pinboard" - :type account: ``unicode`` + :type account: ``str`` :param service: Name of the service. By default, this is the workflow's bundle ID - :type service: ``unicode`` - + :type service: ``str`` """ if not service: service = self.bundleid @@ -2543,7 +2786,7 @@ def delete_password(self, account, service=None): # Methods for workflow:* magic args #################################################################### - def _register_default_magic(self): # noqa: C901 + def _register_default_magic(self): """Register the built-in magic arguments.""" # TODO: refactor & simplify # Wrap callback and message with callable @@ -2618,8 +2861,8 @@ def prereleases_off(): def do_update(): if self.start_update(): return "Downloading and installing update ..." - else: - return "No update available" + + return "No update available" self.magic_arguments["autoupdate"] = update_on self.magic_arguments["noautoupdate"] = update_off @@ -2632,14 +2875,14 @@ def do_help(): if self.help_url: self.open_help() return "Opening workflow help URL in browser" - else: - return "Workflow has no help URL" + + return "Workflow has no help URL" def show_version(): if self.version: - return "Version: {0}".format(self.version) - else: - return "This workflow has no version number" + return f"Version: {self.version}" + + return "This workflow has no version number" def list_magic(): """Display all available magic args in Alfred.""" @@ -2703,27 +2946,29 @@ def reset(self): def open_log(self): """Open :attr:`logfile` in default app (usually Console.app).""" - subprocess.call(["open", self.logfile]) # nosec + subprocess.run(["/usr/bin/open", self.logfile], check=True) def open_cachedir(self): """Open the workflow's :attr:`cachedir` in Finder.""" - subprocess.call(["open", self.cachedir]) # nosec + subprocess.run(["/usr/bin/open", self.cachedir], check=True) def open_datadir(self): """Open the workflow's :attr:`datadir` in Finder.""" - subprocess.call(["open", self.datadir]) # nosec + subprocess.run(["/usr/bin/open", self.datadir], check=True) def open_workflowdir(self): """Open the workflow's :attr:`workflowdir` in Finder.""" - subprocess.call(["open", self.workflowdir]) # nosec + subprocess.run(["/usr/bin/open", self.workflowdir], check=True) def open_terminal(self): """Open a Terminal window at workflow's :attr:`workflowdir`.""" - subprocess.call(["open", "-a", "Terminal", self.workflowdir]) # nosec + subprocess.run( + ["/usr/bin/open", "-a", "Terminal", self.workflowdir], check=True + ) def open_help(self): """Open :attr:`help_url` in default browser.""" - subprocess.call(["open", self.help_url]) # nosec + subprocess.run(["/usr/bin/open", self.help_url], check=True) return "Opening workflow help URL in browser" @@ -2733,70 +2978,57 @@ def open_help(self): def decode(self, text, encoding=None, normalization=None): """Return ``text`` as normalised unicode. - If ``encoding`` and/or ``normalization`` is ``None``, the ``input_encoding``and ``normalization`` parameters passed to :class:`Workflow` are used. - :param text: string :type text: encoded or Unicode string. If ``text`` is already a Unicode string, it will only be normalised. :param encoding: The text encoding to use to decode ``text`` to Unicode. - :type encoding: ``unicode`` or ``None`` + :type encoding: ``str`` or ``None`` :param normalization: The nomalisation form to apply to ``text``. - :type normalization: ``unicode`` or ``None`` - :returns: decoded and normalised ``unicode`` - - :class:`Workflow` uses "NFC" normalisation by default. This is the + :type normalization: ``str`` or ``None`` + :returns: decoded and normalised ``str`` + :class:`Workflow` uses "NFC" normalization by default. This is the standard for Python and will work well with data from the web (via :mod:`~workflow.web` or :mod:`json`). - - macOS, on the other hand, uses "NFD" normalisation (nearly), so data + macOS, on the other hand, uses "NFD" normalization (nearly), so data coming from the system (e.g. via :mod:`subprocess` or :func:`os.listdir`/:mod:`os.path`) may not match. You should either - normalise this data, too, or change the default normalisation used by + normalise this data, too, or change the default normalization used by :class:`Workflow`. - """ encoding = encoding or self._input_encoding - normalization = normalization or self._normalizsation + normalization = normalization or self._normalization if not isinstance(text, str): text = str(text, encoding) return unicodedata.normalize(normalization, text) - def fold_to_ascii(self, text): + @staticmethod + def fold_to_ascii(text): """Convert non-ASCII characters to closest ASCII equivalent. - - .. versionadded:: 1.3 - .. note:: This only works for a subset of European languages. - :param text: text to convert - :type text: ``unicode`` + :type text: ``str`` :returns: text containing only ASCII characters - :rtype: ``unicode`` - + :rtype: ``str`` """ if isascii(text): return text text = "".join([ASCII_REPLACEMENTS.get(c, c) for c in text]) return unicodedata.normalize("NFKD", text) - def dumbify_punctuation(self, text): + @staticmethod + def dumbify_punctuation(text): """Convert non-ASCII punctuation to closest ASCII equivalent. - This method replaces "smart" quotes and n- or m-dashes with their workaday ASCII equivalents. This method is currently not used internally, but exists as a helper method for workflow authors. - - .. versionadded: 1.9.7 - :param text: text to convert - :type text: ``unicode`` + :type text: ``str`` :returns: text with only ASCII punctuation - :rtype: ``unicode`` - + :rtype: ``str`` """ if isascii(text): return text @@ -2806,13 +3038,11 @@ def dumbify_punctuation(self, text): def _delete_directory_contents(self, dirpath, filter_func): """Delete all files in a directory. - :param dirpath: path to directory to clear - :type dirpath: ``unicode`` or ``str`` + :type dirpath: ``str`` :param filter_func function to determine whether a file shall be deleted or not. :type filter_func ``callable`` - """ if os.path.exists(dirpath): for filename in os.listdir(dirpath): @@ -2823,7 +3053,7 @@ def _delete_directory_contents(self, dirpath, filter_func): shutil.rmtree(path) else: os.unlink(path) - self.logger.debug("deleted : %r", path) + self.logger.debug("deleted: %s", path) def _load_info_plist(self): """Load workflow info from ``info.plist``.""" @@ -2832,52 +3062,49 @@ def _load_info_plist(self): self._info = plistlib.load(file_obj) self._info_loaded = True - def _create(self, dirpath): + @staticmethod + def _create(dirpath): """Create directory `dirpath` if it doesn't exist. - :param dirpath: path to directory - :type dirpath: ``unicode`` + :type dirpath: ``str`` :returns: ``dirpath`` argument - :rtype: ``unicode`` - + :rtype: ``str`` """ if not os.path.exists(dirpath): os.makedirs(dirpath) return dirpath - def _call_security(self, action, service, account, *args): + @staticmethod + def _call_security(action, service, account, *args): """Call ``security`` CLI program that provides access to keychains. - May raise `PasswordNotFound`, `PasswordExists` or `KeychainError` exceptions (the first two are subclasses of `KeychainError`). - :param action: The ``security`` action to call, e.g. ``add-generic-password`` - :type action: ``unicode`` + :type action: ``str`` :param service: Name of the service. - :type service: ``unicode`` + :type service: ``str`` :param account: name of the account the password is for, e.g. "Pinboard" - :type account: ``unicode`` + :type account: ``str`` :param password: the password to secure - :type password: ``unicode`` + :type password: ``str`` :param *args: list of command line arguments to be passed to ``security`` :type *args: `list` or `tuple` - :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a - ``unicode`` string. - :rtype: `tuple` (`int`, ``unicode``) - + :returns: Data from stdout. + :rtype: ``str`` """ cmd = ["security", action, "-s", service, "-a", account] + list(args) - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - stdout, _ = p.communicate() - if p.returncode == 44: # password does not exist - raise PasswordNotFound() - elif p.returncode == 45: # password already exists - raise PasswordExists() - elif p.returncode > 0: - err = KeychainError("Unknown Keychain error : %s" % stdout) - err.retcode = p.returncode - raise err - return stdout.strip().decode("utf-8") + with subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) as proc: + stdout, _ = proc.communicate() + if proc.returncode == 44: # password does not exist + raise PasswordNotFound() + if proc.returncode == 45: # password already exists + raise PasswordExists() + if proc.returncode > 0: + err = KeychainError(f"Unknown Keychain error : {stdout}") + raise err + return stdout.strip().decode("utf-8")