├── .github ├── dependabot.yml └── workflows │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs.py ├── docs ├── actions.md ├── fields.md ├── new_action.md ├── python_tutorial.md └── tutorial.md ├── examples ├── ShortcutDB.toml ├── base64.toml ├── dictionary.toml ├── get_url.toml ├── if-else.toml ├── multiline.toml ├── repeat_flash.toml ├── send_photo.toml ├── shields.toml └── what_is_your_name.toml ├── setup.cfg ├── setup.py ├── shortcuts.py ├── shortcuts ├── __init__.py ├── actions │ ├── __init__.py │ ├── b64.py │ ├── base.py │ ├── calculation.py │ ├── conditions.py │ ├── date.py │ ├── device.py │ ├── dictionary.py │ ├── files.py │ ├── input.py │ ├── menu.py │ ├── messages.py │ ├── numbers.py │ ├── out.py │ ├── photo.py │ ├── registry.py │ ├── scripting.py │ ├── text.py │ ├── variables.py │ └── web.py ├── cli.py ├── dump.py ├── exceptions.py ├── loader.py ├── shortcut.py └── utils.py ├── tests ├── __init__.py ├── actions │ ├── __init__.py │ ├── b64 │ │ ├── __init__.py │ │ └── tests.py │ ├── base │ │ ├── __init__.py │ │ └── tests.py │ ├── conditions │ │ ├── __init__.py │ │ └── tests.py │ ├── date │ │ ├── __init__.py │ │ └── tests.py │ ├── device │ │ ├── __init__.py │ │ └── tests.py │ ├── dictionary │ │ ├── __init__.py │ │ └── tests.py │ ├── files │ │ ├── __init__.py │ │ └── tests.py │ ├── input │ │ ├── __init__.py │ │ └── tests.py │ ├── menu │ │ ├── __init__.py │ │ └── tests.py │ ├── numbers │ │ ├── __init__.py │ │ └── tests.py │ ├── out │ │ ├── __init__.py │ │ └── tests.py │ ├── photo │ │ ├── __init__.py │ │ └── tests.py │ ├── registry │ │ ├── __init__.py │ │ └── tests.py │ ├── scripting │ │ ├── __init__.py │ │ └── tests.py │ ├── tests.py │ ├── text │ │ ├── __init__.py │ │ └── tests.py │ ├── variables │ │ ├── __init__.py │ │ └── tests.py │ └── web │ │ ├── __init__.py │ │ └── tests.py ├── cli │ ├── __init__.py │ └── tests.py ├── conftest.py ├── examples │ ├── __init__.py │ └── tests.py ├── loader │ ├── __init__.py │ └── tests.py ├── shortcuts │ ├── __init__.py │ └── tests.py ├── templates.py └── utils │ ├── __init__.py │ └── tests.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | reviewers: 9 | - alexander-akhmetov 10 | assignees: 11 | - alexander-akhmetov 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: python-shortcuts tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: [3.6, 3.7, 3.8] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | 16 | - name: Set up python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox==3.15.1 tox-gh-actions==1.2.0 pipenv==2020.6.2 25 | 26 | - name: Tests 27 | run: tox 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | *.pyc 3 | .plist 4 | build 5 | dist 6 | MANIFEST 7 | *.plist 8 | *.shortcut 9 | *.toml 10 | !examples/*.toml 11 | !examples/*.shortcut 12 | .coverage 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.11.0] 05.03.2019 4 | 5 | - Added dockerized cli tool 6 | 7 | - New actions: 8 | - Choose From List 9 | - Open App 10 | 11 | ## [0.10.0] 26.10.2018 12 | 13 | - Added new example: database [ShortcutDB](/examples/ShortcutDB.md). Get version with auto update feature and read documentation on [the website](https://shortcutdb.aleks.sh) 14 | 15 | - Added system variables: 16 | - `shortcut_input` 17 | - `clipboard` 18 | - `current_date` 19 | 20 | - New actions: 21 | - AppendFileAction 22 | - GetDictionaryFromInputAction 23 | - RunShortcut 24 | - GetMyShortcutsAction 25 | - GetTimeBetweenDates 26 | - DetectDateAction 27 | - OpenURLAction 28 | 29 | - `IfAction` now supports variables in the parameter `compare_with`. 30 | 31 | ## [0.9.1] 27.09.2018 32 | 33 | - Now raises `UnknownWFTextTokenAttachment` error when loader can't load field with unknown token attachment type. 34 | 35 | ## [0.9.0] 27.09.2018 36 | 37 | - Added [fields documentation](/docs/fields.md) 38 | - New actions: 39 | - RepeatEachStartAction 40 | - RepeatEachEndAction 41 | - ChangeCaseAction 42 | - SplitTextAction 43 | - GetClipboardAction 44 | - NumberAction 45 | - HashAction 46 | - SetClipboardAction 47 | - SetDictionaryValueAction 48 | - URLEncodeAction 49 | - URLDecodeAction 50 | - AppendVariableAction 51 | - ShowDefinitionAction 52 | - ScanQRBarCode 53 | - GetTextFromInputAction 54 | - GetNameOfEmoji 55 | - DetectLanguageAction 56 | - ExpandURLAction 57 | 58 | ## [0.8.1] - 26.09.2018 59 | 60 | - Save shortcut from URL without deserializing if output format is plist 61 | 62 | ## [0.8.0] - 25.09.2018 63 | 64 | - New example: [shields.toml](/examples/shields.toml) 65 | - New action: SpeakTextAction 66 | - Added `default=True` to field `SetLowPowerModeAction.on`. 67 | - Removed `plutil` requirement 68 | - Added ability to download shortcuts with `shortcuts` cli directly from iCloud 69 | 70 | ## [0.7.0] - 25.09.2018 71 | 72 | - New actions: 73 | - SendMessageAction 74 | - MenuStartAction 75 | - MenuItemAction 76 | - MenuEndAction 77 | - New examples in the `/examples/` directory: 78 | - `send_photo.toml`: how to use `menu`, send photo with `send message` action and how to use `{{ask_when_run}}` 79 | - Updated documentation about supported actions `/docs/actions.md` 80 | - Supported `{{ask_when_run}}` system variable (read more about this in `/docs/actions.md`) 81 | 82 | ## [0.6.0] - 24.09.2018 83 | 84 | - New actions: 85 | - GetBatteryLevelAction 86 | - GetIPAddressAction 87 | - GetDeviceDetailsAction 88 | - SetAirplaneModeAction 89 | - SetBluetoothAction 90 | - SetBrightnessAction 91 | - SetMobileDataAction 92 | - SetDoNotDisturbAction 93 | - SetTorchAction 94 | - SetLowPowerModeAction 95 | - SetVolumeAction 96 | - SetWiFiAction 97 | - NothingAction 98 | - SetItemNameAction 99 | - ViewContentGraphAction 100 | - ContinueInShortcutAppAction 101 | - DelayAction 102 | - WaitToReturnAction 103 | - RepeatStartAction 104 | - RepeatEndAction 105 | - Renamed `type` to `itype` for action classes (class attribute *only*). 106 | - Removed `required=True` from `group_id` fields, now conditional group sets automatically. 107 | - Package name changed to `shortcuts`: `pip install shortcuts` (old name will be working too as an alias). 108 | 109 | ## [0.5.2] - 23.09.2018 110 | 111 | - Fixed installation from pypi: returned back `toml` dependency 112 | 113 | ## [0.5.1] - 23.09.2018 114 | 115 | - Fixed base64decode action 116 | 117 | ## [0.5.0] - 23.09.2018 118 | 119 | - Fixed POST form data with GetURLAction 120 | 121 | ## [0.4.0] - 23.09.2018 122 | 123 | - Added version inormation to the CLI tool 124 | 125 | ## [0.3.0] - 23.09.2018 126 | 127 | - Added BooleanField 128 | - Added DictionaryAction (only text items for now) 129 | - Added GetURLAction (simple support, only json and headers) 130 | - Added ExitAction 131 | - Added VibrateAction 132 | - Added FormatDateAction 133 | - Added PreviewDocumentAction 134 | - Added ImageConvertAction 135 | - Added GetVariableAction 136 | 137 | ## [0.2.3] - 22.09.2018 138 | 139 | - Fixed cli (`shortcuts`) 140 | 141 | ## [0.2.0] - 22.09.2018 142 | 143 | - Working convertation toml <-> shortcut 144 | 145 | ## [0.1.0] - 22.09.2018 146 | 147 | - It's alive! 148 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine3.8 2 | 3 | RUN pip install python-shortcuts==0.10.0 4 | 5 | ENTRYPOINT ["shortcuts"] 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Akhmetov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the README 2 | include *.md 3 | 4 | # Include the license file 5 | include LICENSE.txt 6 | 7 | recursive-include shortcuts *.py 8 | recursive-include docs * 9 | 10 | global-exclude *.pyc .git .tox __pycache__ 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | generate-docs: 2 | python docs.py docs/actions.md 3 | 4 | 5 | tests: 6 | tox 7 | 8 | 9 | release-pypi: 10 | test -n "$(VERSION)" 11 | @echo "\033[92mVERSION=$(VERSION)\033[0m" 12 | @echo "\033[92mStarting tests\033[0m" 13 | tox 14 | 15 | @echo "\033[92mReleasing python-shortcuts with VERSION=$(VERSION)\033[0m" 16 | 17 | @echo "\033[92mBuilding python-shortcuts\033[0m" 18 | sed -i '' "s/name='shortcuts'/name='python-shortcuts'/" setup.py 19 | python setup.py sdist 20 | 21 | @echo "\033[92mBuilding shortcuts\033[0m" 22 | sed -i '' "s/name='python-shortcuts'/name='shortcuts'/" setup.py 23 | python setup.py sdist 24 | 25 | @echo "\033[92mUploading...\033[0m" 26 | twine upload dist/python-shortcuts-$(VERSION).tar.gz 27 | twine upload dist/shortcuts-$(VERSION).tar.gz 28 | @echo "\033[92mDone\033[0m" 29 | 30 | 31 | isort-fix: 32 | isort -rc shortcuts 33 | 34 | 35 | docker-build-cli: 36 | test -n "$(TAG)" 37 | docker build -t akhmetov/shortcuts-cli:$(TAG) . 38 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | mock = "*" 9 | mypy = "*" 10 | "flake8" = "*" 11 | isort = "*" 12 | pytest-cov = "*" 13 | black = "==19.10b0" 14 | 15 | [packages] 16 | toml = "*" 17 | "flake8" = "*" 18 | mypy = "*" 19 | 20 | [requires] 21 | python_version = "3.8" 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WIP: python-shortcuts 2 | 3 | [![Build Status](https://travis-ci.org/alexander-akhmetov/python-shortcuts.svg?branch=master)](https://travis-ci.org/alexander-akhmetov/python-shortcuts) 4 | [![PyPI](https://img.shields.io/pypi/v/shortcuts.svg)](https://pypi.python.org/pypi/shortcuts) 5 | 6 | 🍏 + 🐍 = ❤️ 7 | 8 | **python-shortcuts** is a library to create [Siri Shortcuts](https://support.apple.com/en-ae/guide/shortcuts/welcome/ios) on your laptop with your favourite text editor. 9 | It uses [toml](https://github.com/toml-lang/toml) to represent shortcuts. 10 | 11 | The library is in a very early development state (PR welcome!), so it does not support all actions from Shortcuts app. 12 | 13 | * [Toml tutorial](docs/tutorial.md) 14 | * [Python tutorial](docs/python_tutorial.md) 15 | * [How to add a new action](docs/new_action.md) 16 | * [Supported actions](docs/actions.md) 17 | * [Examples](examples/) 18 | * [Changelog](CHANGELOG.md) 19 | * [Documentation](docs/) 20 | 21 | Supported Python version: **>=3.6**. 22 | 23 | ## Why 24 | 25 | I wanted to convert my shortcut to a file in human-readable format. :) 26 | 27 | From the code below this library can create a working shortcut: 28 | 29 | ```toml 30 | [[action]] 31 | type = "ask" 32 | question = "What is your name?" 33 | 34 | [[action]] 35 | type = "set_variable" 36 | name = "name" 37 | 38 | [[action]] 39 | type = "show_result" 40 | text = "Hello, {{name}}!" 41 | ``` 42 | 43 | Or the same with Python: 44 | 45 | ```python 46 | from shortcuts import Shortcut, actions 47 | 48 | 49 | sc = Shortcut() 50 | 51 | sc.actions = [ 52 | actions.AskAction(data={'question': 'What is your name?'}), 53 | actions.SetVariableAction(data={'name': 'name'}), 54 | actions.ShowResultAction(data={'text': 'Hello, {{name}}!'}) 55 | ] 56 | ``` 57 | 58 | ## How to use 59 | 60 | ### Installation 61 | 62 | ```bash 63 | pip install shortcuts 64 | ``` 65 | 66 | ### Usage 67 | 68 | ### shortcut → toml 69 | 70 | If you need to convert existing shortcut to a toml file, at first you need to export it. 71 | Go into Shortcuts app, open the shortcut and share it. Choose "Share as file" and use this file with this library. 72 | 73 | Convert shortcut file to `toml`: 74 | 75 | ```bash 76 | shortcuts what_is_your_name.shortcut what_is_your_name.toml 77 | ``` 78 | 79 | ### toml → shortcut 80 | 81 | Convert a `toml` file to a shortcut file. 82 | After you will need to open the file with iOS Shortcuts app. 83 | 84 | ```bash 85 | shortcuts examples/what_is_your_name.toml what_is_your_name.shortcut 86 | ``` 87 | 88 | More examples of `toml` files you can find [here](examples/). 89 | And [read the tutorial](docs/tutorial.md)! :) 90 | 91 | ### URL → [toml|shortcut] 92 | 93 | Also, you can download shortcut directly from iCloud. 94 | If somebody shared shortcut link with you, just run: 95 | 96 | ```bash 97 | shortcuts https://www.icloud.com/shortcuts/... my_shortcut.toml # or my_shortcut.shortcut 98 | ``` 99 | 100 | And it will download this shortcut and save it in `toml` or `shortcut` format. 101 | 102 | ### Docker 103 | 104 | It's possible to use [pre-built Docker container](https://cloud.docker.com/u/akhmetov/repository/docker/akhmetov/shortcuts-cli) with python-shortcuts inside: 105 | 106 | ```shell 107 | # convert s.toml from the current directory into s.shortcut 108 | 109 | docker run -v $(pwd):/files akhmetov/shortcuts-cli /files/s.toml /files/s.shortcut 110 | 111 | 112 | docker run -v $(pwd):/files akhmetov/shortcuts-cli --help 113 | 114 | usage: shortcuts [-h] [--version] [file] [output] 115 | 116 | Shortcuts: Siri shortcuts creator 117 | 118 | positional arguments: 119 | file Input file: *.(toml|shortcut|itunes url) 120 | output Output file: *.(toml|shortcut) 121 | 122 | optional arguments: 123 | -h, --help show this help message and exit 124 | --version Version information 125 | ``` 126 | 127 | ## Development 128 | 129 | ### Tests 130 | 131 | Run tests: 132 | 133 | ```bash 134 | tox 135 | ``` 136 | 137 | ### How to add a new action 138 | 139 | See [documentation](/docs/new_action.md). 140 | 141 | ### TODO 142 | 143 | * ☑ ~~Conditionals with auto-group_id: if-else, menu~~ 144 | * ☐ Nested fields: dict/array/etc 145 | * ☐ Support variables in every field which support them in Shortcuts app 146 | * ☐ Workflow types: widget, etc. 147 | * ☐ Import questions 148 | * ☐ Document all actions 149 | * ☐ Support magic variables 150 | * ☐ Support all current actions from Shortcuts app 151 | 152 | 153 | ### Similar projects 154 | 155 | * [Shortcuts JS](https://github.com/joshfarrant/shortcuts-js) 156 | -------------------------------------------------------------------------------- /docs.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Generates documentation for all available actions in the shortcuts.actions module 3 | ''' 4 | 5 | import argparse 6 | 7 | from shortcuts.actions import actions_registry 8 | from shortcuts.actions.base import VariablesField 9 | 10 | 11 | DOC_TEMPLATE = ''' 12 | # Supported Actions 13 | 14 | This is a list of all actions supported by **python-shortcuts**. 15 | 16 | Legend: 17 | 18 | * *keyword*: This keyword you can use in `toml` files to describe action 19 | * *shortcuts identifier*: (*itype*) this identifier will be used to generate an action in a shortcut 20 | 21 | System variables: 22 | 23 | * `{{{{ask_when_run}}}}` - ask the user for an input when the shortcut is running. 24 | 25 | ---- 26 | 27 | {actions} 28 | ''' 29 | 30 | 31 | def _build_docs(): 32 | actions_docs = [] 33 | actions_docs = [_build_action_doc(a) for a in actions_registry.actions] 34 | return DOC_TEMPLATE.format(actions='\n\n'.join(actions_docs)) 35 | 36 | 37 | ACTION_TEMPLATE = ''' 38 | ### {name} 39 | 40 | {doc} 41 | 42 | **keyword**: `{keyword}` 43 | **shortcuts identifier**: `{identifier}` 44 | 45 | {params} 46 | ''' 47 | 48 | 49 | def _build_action_doc(action): 50 | params = '\n'.join([_build_params_doc(f) for f in action().fields]).strip() 51 | params = f'params:\n\n{params}' if params else '' 52 | 53 | doc = '' 54 | if action.__doc__: 55 | # remove spaces from the beginning of _each_ line 56 | doc = '\n'.join([l.strip() for l in action.__doc__.splitlines()]) 57 | 58 | return ACTION_TEMPLATE.format( 59 | name=action.__name__, 60 | doc=doc, 61 | keyword=action.keyword, 62 | identifier=action.itype, 63 | params=params, 64 | ).strip() 65 | 66 | 67 | PARAM_TEMPLATE = '* {name} {opts}' 68 | 69 | 70 | def _build_params_doc(field): 71 | properties = ', '.join(_get_field_properties(field)) 72 | opts = f'({properties})' 73 | 74 | choices = getattr(field, 'choices', None) 75 | if choices: 76 | opts += f' {field.help} | _choices_:\n' 77 | opts += '\n'.join([f'\n * `{choice}`' for choice in choices]) 78 | return PARAM_TEMPLATE.format( 79 | name=field._attr, 80 | opts=opts, 81 | help=field.help, 82 | ).strip() 83 | 84 | 85 | def _get_field_properties(field): 86 | properties = [] 87 | if field.required: 88 | properties.append('*required*') 89 | if field.default: 90 | properties.append(f'default={field.default}') 91 | if isinstance(field, VariablesField): 92 | properties.append('*variables support*') 93 | return properties 94 | 95 | 96 | def main(): 97 | parser = argparse.ArgumentParser(description='Actions documentation generator') 98 | parser.add_argument('output', help='output file') 99 | args = parser.parse_args() 100 | 101 | doc = _build_docs() 102 | 103 | with open(args.output, 'w') as f: 104 | f.write(doc) 105 | 106 | 107 | if __name__ == '__main__': 108 | main() 109 | -------------------------------------------------------------------------------- /docs/fields.md: -------------------------------------------------------------------------------- 1 | # Fields 2 | 3 | These fields are available for creating [a new action](/docs/new_action.md). 4 | 5 | ## Field 6 | 7 | Simple text field. 8 | 9 | Params: 10 | 11 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 12 | * `default` [*optional*] 13 | * `required` [*optional, default=True*] 14 | * `capitalize` [*optional, default=False*] 15 | * `help` [*optional, default=''*] 16 | 17 | ## VariablesField 18 | 19 | A text field which supports `{{variables}}` in it. 20 | 21 | Params: 22 | 23 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 24 | * `default` [*optional*] 25 | * `required` [*optional, default=True*] 26 | * `capitalize` [*optional, default=False*] 27 | * `help` [*optional, default=''*] 28 | 29 | ## GroupIDField 30 | 31 | A field which creates group_id automatically if it's not presented. 32 | 33 | Params: 34 | 35 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 36 | * `default` [*optional*] 37 | * `required` [*optional, default=True*] 38 | * `capitalize` [*optional, default=False*] 39 | * `help` [*optional, default=''*] 40 | 41 | ## ChoiceField 42 | 43 | A field that can accept a value out of provided choices. 44 | 45 | Params: 46 | 47 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 48 | * `choices` - list of available choices for the field 49 | * `default` [*optional*] 50 | * `required` [*optional, default=True*] 51 | * `capitalize` [*optional, default=False*] 52 | * `help` [*optional, default=''* 53 | 54 | ## ArrayField 55 | 56 | A field that validates that value is a list. 57 | 58 | Params: 59 | 60 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 61 | * `default` [*optional*] 62 | * `required` [*optional, default=True*] 63 | * `capitalize` [*optional, default=False*] 64 | * `help` [*optional, default=''* 65 | 66 | ## FloatField 67 | 68 | A field that validates that value is a floating point number. 69 | 70 | Params: 71 | 72 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 73 | * `default` [*optional*] 74 | * `required` [*optional, default=True*] 75 | * `capitalize` [*optional, default=False*] 76 | * `help` [*optional, default=''* 77 | 78 | ## IntegerField 79 | 80 | A field that validates that value is a integer number. 81 | 82 | Params: 83 | 84 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 85 | * `default` [*optional*] 86 | * `required` [*optional, default=True*] 87 | * `capitalize` [*optional, default=False*] 88 | * `help` [*optional, default=''* 89 | 90 | ## BooleanField 91 | 92 | A boolean field. 93 | 94 | Params: 95 | 96 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 97 | * `default` [*optional*] 98 | * `required` [*optional, default=True*] 99 | * `capitalize` [*optional, default=False*] 100 | * `help` [*optional, default=''* 101 | 102 | ## DictionaryField 103 | 104 | A field that validates that value is a dictionary. 105 | 106 | Params: 107 | 108 | * `name` - name of the field in the shortcut's `plist` file (in the `WFWorkflowActionParameters` dictionary) 109 | * `default` [*optional*] 110 | * `required` [*optional, default=True*] 111 | * `capitalize` [*optional, default=False*] 112 | * `help` [*optional, default=''* 113 | -------------------------------------------------------------------------------- /docs/new_action.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | Sometimes (in the current state of the library - very often, honestly :) ), when you are trying to convert a shortcut to a toml file with this command you will see an error if the action is not supported by the library: 4 | 5 | ```bash 6 | $ shortcuts myshortcut.shortcut myshortcut.toml 7 | 8 | RuntimeError: 9 | 10 | Unknown shortcut action: is.workflow.actions.gettext 11 | 12 | Please, check documentation to add new shortcut action, or create an issue: 13 | Docs: https://github.com/alexander-akhmetov/python-shortcuts/tree/master/docs/new_action.md 14 | 15 | https://github.com/alexander-akhmetov/python-shortcuts/ 16 | 17 | Action dictionary: 18 | 19 | { 20 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.gettext', 21 | 'WFWorkflowActionParameters': {'WFTextActionText': 'some text'}, 22 | } 23 | ``` 24 | 25 | > To convert shortcut file from binary xml format to xml you can you this command on MacOS: 26 | > 27 | > ```shell 28 | > plutil -convert xml1 sc.shortcut 29 | > ``` 30 | 31 | So, how to fix this? 32 | You can create a new action somewhere class in the `src/actions/` directory 33 | and import it in the `shortcuts/actions/__init__.py`: 34 | 35 | ```python 36 | from actions.base import BaseAction, Field 37 | 38 | 39 | class GetTextAction(BaseAction): 40 | itype = 'is.workflow.actions.gettext' 41 | keyword = 'get_text' 42 | 43 | text = Field('WFTextActionText', help='Text to show') 44 | ``` 45 | 46 | * `itype` - `WFWorkflowActionIdentifier` from shortcut's plist 47 | * `keyword` - keyword for the action to be used in toml files 48 | 49 | Every parameter from `WFWorkflowActionParameters` must be presented as a `Field` attribute of the action class. 50 | If this parameter is not required, you can pass `required=False` to the `Field`. 51 | 52 | That's all, now this action is supported by the library, and you can convert your shortcut: 53 | 54 | ```bash 55 | $ shortcuts myshortcut.shortcut myshortcut.toml 56 | $ cat myshortcut.toml 57 | 58 | [[action]] 59 | type = "get_text" 60 | text = "some text" 61 | ``` 62 | 63 | And now, you need to do only one last thing. Please, send a pull request :) 64 | 65 | ## VariablesField 66 | 67 | If the field supports variables, instead of `Field` you can use `VariablesField`: 68 | 69 | ```python 70 | from actions.base import BaseAction, VariablesField 71 | 72 | 73 | class GetTextAction(BaseAction): 74 | itype = 'is.workflow.actions.gettext' 75 | keyword = 'get_text' 76 | 77 | text = VariablesField('WFTextActionText', help='Text to show') 78 | ``` 79 | 80 | And then you can pass text data with `{{variable}}` inside. 81 | 82 | Other available [fields](/docs/fields.md) 83 | -------------------------------------------------------------------------------- /docs/python_tutorial.md: -------------------------------------------------------------------------------- 1 | # Python tutorial 2 | 3 | You need to [install](/README.md#installation) **shortcuts** first. 4 | 5 | --- 6 | 7 | How to use this package from your python code: 8 | 9 | ```python 10 | 11 | from shortcuts import Shortcut, actions, FMT_SHORTCUT 12 | 13 | sc = Shortcut() 14 | 15 | sc.actions = [ 16 | actions.TextAction(data={'text': 'Hello, world!'}), 17 | actions.Base64EncodeAction(), 18 | actions.SetVariableAction(data={'name': 'var'}), 19 | actions.ShowResultAction(data={'text': 'Encoded variable: {{var}}'}) 20 | ] 21 | 22 | file_path = 's.shortcut' 23 | 24 | with open(file_path, 'wb') as f: 25 | sc.dump(f, file_format=FMT_SHORTCUT) 26 | ``` 27 | 28 | Now you can upload `s.shortcut` to your phone and open it with Shortcuts app. 29 | 30 | Description of all supported actions you can find here: [/docs/actions.md](/docs/actions.md). 31 | -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorial 2 | 3 | You need to [install](/README.md#installation) **shortcuts** first. 4 | 5 | --- 6 | 7 | This project uses [toml](https://github.com/toml-lang/toml) to describe shortcuts. 8 | 9 | A shortcut is a sequence of actions. Every action can receive some input and can produce output. 10 | 11 | Let's write a simple shortcut, which asks the name of our user and then prints it back in an alert. 12 | 13 | At first, we need to ask our user: 14 | 15 | ```toml 16 | [[action]] 17 | type = "ask" 18 | question = "What is your name?" 19 | ``` 20 | 21 | You can see above that we created an array item in the `[[action]]`. It will create a Shortcut action which asks our user and returns his answer as an output. Now we can do something with the answer. 22 | 23 | Let's save it to a variable. 24 | 25 | ```toml 26 | [[action]] 27 | type = "set_variable" 28 | name = "name" 29 | ``` 30 | 31 | And now let's print a message for the user: 32 | 33 | ```toml 34 | [[action]] 35 | type = "show_result" 36 | text = "Hello, {{name}}!" 37 | ``` 38 | 39 | The most important thing here is `{{name}}`. We took our variable `name` and put it to the text string. 40 | And the user will see something like `Hello, Alexander!`. 41 | 42 | Now, convert the file to a shortcut: 43 | 44 | ```bash 45 | shortcuts t.toml t.shortcut 46 | ``` 47 | 48 | And open the `t.shortcut` file in the Shortcuts app. 49 | 50 | ## Full toml file 51 | 52 | ```toml 53 | [[action]] 54 | type = "ask" 55 | question = "What is your name?" 56 | 57 | [[action]] 58 | type = "set_variable" 59 | name = "name" 60 | 61 | [[action]] 62 | type = "show_result" 63 | text = "Hello, {{name}}!" 64 | ``` 65 | 66 | Description of all supported actions you can find here: [/docs/actions.md](/docs/actions.md). 67 | -------------------------------------------------------------------------------- /examples/ShortcutDB.toml: -------------------------------------------------------------------------------- 1 | [[action]] 2 | type = "comment" 3 | text = "https://shortcutdb.aleks.sh\nDatabase for your Apple Shortcuts" 4 | 5 | [[action]] 6 | type = "get_dictionary" 7 | 8 | [[action]] 9 | name = "request" 10 | type = "set_variable" 11 | 12 | [[action]] 13 | key = "operation" 14 | type = "get_value_for_key" 15 | 16 | [[action]] 17 | name = "operation" 18 | type = "set_variable" 19 | 20 | [[action]] 21 | text = "shortcuts_db" 22 | type = "text" 23 | 24 | [[action]] 25 | name = "db_dir" 26 | type = "set_variable" 27 | 28 | [[action]] 29 | text = "database" 30 | type = "text" 31 | 32 | [[action]] 33 | name = "db_name" 34 | type = "set_variable" 35 | 36 | [[action]] 37 | path = "{{db_dir}}" 38 | type = "create_folder" 39 | 40 | [[action]] 41 | name = "operation" 42 | type = "get_variable" 43 | 44 | [[action]] 45 | compare_with = "get" 46 | condition = "Equals" 47 | group_id = "8F484202-64AF-426C-90A4-F73E6788913E" 48 | type = "if" 49 | 50 | [[action]] 51 | not_found_error = true 52 | path = "{{db_dir}}/{{db_name}}.txt" 53 | show_picker = false 54 | type = "read_file" 55 | 56 | [[action]] 57 | separator_type = "New Lines" 58 | type = "split_text" 59 | 60 | [[action]] 61 | group_id = "9CAA8D7F-DC5C-4028-B58C-134E97FE56CA" 62 | type = "repeat_with_each_start" 63 | 64 | [[action]] 65 | type = "get_dictionary" 66 | 67 | [[action]] 68 | name = "row" 69 | type = "set_variable" 70 | 71 | [[action]] 72 | key = "key" 73 | type = "get_value_for_key" 74 | 75 | [[action]] 76 | name = "row_key" 77 | type = "set_variable" 78 | 79 | [[action]] 80 | name = "request" 81 | type = "get_variable" 82 | 83 | [[action]] 84 | key = "key" 85 | type = "get_value_for_key" 86 | 87 | [[action]] 88 | compare_with = "{{row_key}}" 89 | condition = "Equals" 90 | group_id = "83BDEADB-9605-4B86-A0F0-307D20367F02" 91 | type = "if" 92 | 93 | [[action]] 94 | name = "row" 95 | type = "get_variable" 96 | 97 | [[action]] 98 | key = "value" 99 | type = "get_value_for_key" 100 | 101 | [[action]] 102 | name = "result" 103 | type = "set_variable" 104 | 105 | [[action]] 106 | group_id = "83BDEADB-9605-4B86-A0F0-307D20367F02" 107 | type = "endif" 108 | 109 | [[action]] 110 | group_id = "9CAA8D7F-DC5C-4028-B58C-134E97FE56CA" 111 | type = "repeat_with_each_end" 112 | 113 | [[action]] 114 | group_id = "8F484202-64AF-426C-90A4-F73E6788913E" 115 | type = "endif" 116 | 117 | [[action]] 118 | compare_with = "set" 119 | condition = "Equals" 120 | group_id = "B5B99AC5-2588-4698-B9D3-193A092F0085" 121 | type = "if" 122 | 123 | [[action]] 124 | name = "request" 125 | type = "get_variable" 126 | 127 | [[action]] 128 | key = "key" 129 | type = "get_value_for_key" 130 | 131 | [[action]] 132 | name = "keyw" 133 | type = "set_variable" 134 | 135 | [[action]] 136 | name = "request" 137 | type = "get_variable" 138 | 139 | [[action]] 140 | key = "value" 141 | type = "get_value_for_key" 142 | 143 | [[action]] 144 | name = "valuew" 145 | type = "set_variable" 146 | 147 | [[action]] 148 | type = "dictionary" 149 | [[action.items]] 150 | key = "key" 151 | value = "{{keyw}}" 152 | 153 | [[action.items]] 154 | key = "value" 155 | value = "{{valuew}}" 156 | 157 | 158 | [[action]] 159 | path = "{{db_dir}}/{{db_name}}.txt" 160 | type = "append_file" 161 | 162 | [[action]] 163 | type = "dictionary" 164 | [[action.items]] 165 | key = "result" 166 | value = "ok" 167 | 168 | 169 | [[action]] 170 | name = "result" 171 | type = "set_variable" 172 | 173 | [[action]] 174 | group_id = "B5B99AC5-2588-4698-B9D3-193A092F0085" 175 | type = "endif" 176 | 177 | [[action]] 178 | name = "key" 179 | type = "set_variable" 180 | 181 | [[action]] 182 | name = "result" 183 | type = "get_variable" 184 | 185 | -------------------------------------------------------------------------------- /examples/base64.toml: -------------------------------------------------------------------------------- 1 | name = "Base64 Example" 2 | 3 | [[action]] 4 | type = "text" 5 | text = "ping" 6 | 7 | [[action]] 8 | type = "set_variable" 9 | name = "variable" 10 | 11 | [[action]] 12 | type = "base64_encode" 13 | 14 | [[action]] 15 | type = "set_variable" 16 | name = "variable_encoded" 17 | 18 | [[action]] 19 | type = "base64_decode" 20 | 21 | [[action]] 22 | type = "set_variable" 23 | name = "variable_decoded" 24 | 25 | [[action]] 26 | type = "show_result" 27 | text = """ 28 | Hello, world! 29 | 30 | original_variable: {{variable}} 31 | variable_encoded: {{variable_encoded}} 32 | variable_decoded: {{variable_decoded}} 33 | """ 34 | -------------------------------------------------------------------------------- /examples/dictionary.toml: -------------------------------------------------------------------------------- 1 | [[action]] 2 | type = "dictionary" 3 | [[action.items]] 4 | key = "some key" 5 | value = "some value" 6 | 7 | [[action.items]] 8 | key = "another key" 9 | value = "{{x}}" 10 | -------------------------------------------------------------------------------- /examples/get_url.toml: -------------------------------------------------------------------------------- 1 | [[action]] 2 | type = "url" 3 | url = "http://ipinfo.io/ip" 4 | 5 | [[action]] 6 | type = "get_url" 7 | method = "GET" 8 | advanced = true 9 | 10 | [[action.headers]] 11 | key = "header1" 12 | value = "value1" 13 | 14 | [[action.headers]] 15 | key = "authorization" 16 | value = "{{authorization}}" 17 | 18 | [[action]] 19 | type = "set_variable" 20 | name = "var" 21 | 22 | [[action]] 23 | type = "show_result" 24 | text = "response: {{var}}" 25 | -------------------------------------------------------------------------------- /examples/if-else.toml: -------------------------------------------------------------------------------- 1 | [[action]] 2 | type = "text" 3 | text = "test" 4 | 5 | [[action]] 6 | type = "if" 7 | condition = "equals" 8 | compare_with = "test" 9 | 10 | [[action]] 11 | type = "show_result" 12 | text = "true!" 13 | 14 | [[action]] 15 | type = "else" 16 | 17 | [[action]] 18 | type = "show_result" 19 | text = "false :(" 20 | 21 | [[action]] 22 | type = "endif" 23 | -------------------------------------------------------------------------------- /examples/multiline.toml: -------------------------------------------------------------------------------- 1 | [[action]] 2 | type = "comment" 3 | text = """ 4 | Hi! 5 | This is an example shortcut, created with python-shortcuts. 6 | 7 | See more here: https://github.com/alexander-akhmetov/python-shortcuts 8 | """ 9 | -------------------------------------------------------------------------------- /examples/repeat_flash.toml: -------------------------------------------------------------------------------- 1 | [[action]] 2 | type = "repeat_start" 3 | count = 3 4 | 5 | [[action]] 6 | type = "set_torch" 7 | mode = "On" 8 | 9 | [[action]] 10 | type = "delay" 11 | time = 1.0 12 | 13 | [[action]] 14 | type = "set_torch" 15 | mode = "Off" 16 | 17 | [[action]] 18 | type = "repeat_end" 19 | -------------------------------------------------------------------------------- /examples/send_photo.toml: -------------------------------------------------------------------------------- 1 | # This shortcut presents a menu with two options: 2 | # "Select photo" and "Camera". 3 | # If the user selects the first - it asks to select a photo, 4 | # the second - starts the camera to take it. 5 | # 6 | # After the shortcut asks to whom the user want to send the photo 7 | # and sends it with Messages app. 8 | 9 | 10 | [[action]] 11 | type = "start_menu" 12 | menu_items = ["Select photo", "Camera"] 13 | 14 | [[action]] 15 | type = "menu_item" 16 | title = "Select photo" 17 | 18 | [[action]] 19 | type = "select_photo" 20 | 21 | [[action]] 22 | type = "menu_item" 23 | title = "Camera" 24 | 25 | [[action]] 26 | type = "take_photo" 27 | 28 | [[action]] 29 | type = "end_menu" 30 | 31 | 32 | [[action]] 33 | type = "set_variable" 34 | name = "photo" 35 | 36 | [[action]] 37 | type = "send_message" 38 | recepients = "{{ask_when_run}}" 39 | text = "Look!\n\n{{photo}}" 40 | 41 | -------------------------------------------------------------------------------- /examples/shields.toml: -------------------------------------------------------------------------------- 1 | # Slightly modified nice shortcut from 2 | # https://www.reddit.com/r/shortcuts/comments/9it4uf/i_couldnt_help_but_do_this_the_inner_nerd_in_me/ 3 | # :-) 4 | 5 | [[action]] 6 | type = "get_battery_level" 7 | 8 | [[action]] 9 | name = "battery" 10 | type = "set_variable" 11 | 12 | [[action]] 13 | text = "Shields are at {{battery}}% and falling, Shall I engage the Warp Drive?" 14 | type = "text" 15 | 16 | [[action]] 17 | language = "English (United States)" 18 | type = "speak_text" 19 | 20 | [[action]] 21 | text = "Shall I engage the Warp Drive?" 22 | title = "Shields status" 23 | type = "alert" 24 | 25 | [[action]] 26 | type = "set_low_power_mode" 27 | -------------------------------------------------------------------------------- /examples/what_is_your_name.toml: -------------------------------------------------------------------------------- 1 | name = "What is your name?" 2 | 3 | [[action]] 4 | type = "ask" 5 | question = "What is your name?" 6 | 7 | [[action]] 8 | type = "set_variable" 9 | name = "name" 10 | 11 | [[action]] 12 | type = "show_result" 13 | text = "Hello, {{name}}!" 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | python_files=test*.py 3 | 4 | [flake8] 5 | max-line-length = 119 6 | 7 | [mypy] 8 | ignore_missing_imports = true 9 | 10 | [isort] 11 | combine_as_imports = true 12 | from_first = false 13 | include_trailing_comma = true 14 | length_sort = false 15 | multi_line_output = 3 16 | not_skip = __init__.py 17 | order_by_type = true 18 | use_parenthesis = true 19 | line_length = 119 20 | lines_after_imports = 2 21 | sections = FUTURE,STDLIB,THIRDPARTY,OTHER,FIRSTPARTY,LOCALFOLDER 22 | known_first_party = 23 | shortcuts 24 | known_third_party = 25 | toml 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import re 4 | from distutils.core import setup 5 | 6 | 7 | def get_version(package): 8 | """ 9 | Returns version of a package (`__version__` in `init.py`). 10 | """ 11 | init_py = open(os.path.join(package, '__init__.py')).read() 12 | 13 | return re.match("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 14 | 15 | 16 | version = get_version('shortcuts') 17 | 18 | 19 | with open('README.md', 'r', encoding='utf8') as f: 20 | readme = f.read() 21 | 22 | 23 | setup( 24 | name='shortcuts', 25 | version=version, 26 | description='Python library to create and parse Siri Shortcuts', 27 | long_description=readme, 28 | long_description_content_type='text/markdown', 29 | author='Alexander Akhmetov', 30 | author_email='me@aleks.sh', 31 | url='https://github.com/alexander-akhmetov/python-shortcuts', 32 | python_requires="~=3.6", 33 | packages=['shortcuts', 'shortcuts.actions'], 34 | install_requires=['toml'], 35 | entry_points={'console_scripts': ['shortcuts = shortcuts.cli:main']}, 36 | ) 37 | -------------------------------------------------------------------------------- /shortcuts.py: -------------------------------------------------------------------------------- 1 | from shortcuts.cli import main 2 | 3 | 4 | if __name__ == '__main__': 5 | main() 6 | -------------------------------------------------------------------------------- /shortcuts/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.11.0' 2 | 3 | from .shortcut import FMT_SHORTCUT, FMT_TOML, Shortcut # noqa 4 | 5 | 6 | VERSION = __version__ 7 | -------------------------------------------------------------------------------- /shortcuts/actions/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from shortcuts.actions.b64 import Base64DecodeAction, Base64EncodeAction 4 | from shortcuts.actions.base import BaseAction 5 | from shortcuts.actions.calculation import CountAction 6 | from shortcuts.actions.conditions import ElseAction, EndIfAction, IfAction 7 | from shortcuts.actions.date import ( 8 | DateAction, 9 | DetectDateAction, 10 | FormatDateAction, 11 | GetTimeBetweenDates, 12 | ) 13 | from shortcuts.actions.device import ( 14 | GetBatteryLevelAction, 15 | GetDeviceDetailsAction, 16 | GetIPAddressAction, 17 | SetAirplaneModeAction, 18 | SetBluetoothAction, 19 | SetBrightnessAction, 20 | SetDoNotDisturbAction, 21 | SetLowPowerModeAction, 22 | SetMobileDataAction, 23 | SetTorchAction, 24 | SetVolumeAction, 25 | SetWiFiAction, 26 | ) 27 | from shortcuts.actions.dictionary import ( 28 | DictionaryAction, 29 | GetDictionaryFromInputAction, 30 | GetDictionaryValueAction, 31 | SetDictionaryValueAction, 32 | ) 33 | from shortcuts.actions.files import ( 34 | AppendFileAction, 35 | CreateFolderAction, 36 | PreviewDocumentAction, 37 | ReadFileAction, 38 | SaveFileAction, 39 | ) 40 | from shortcuts.actions.input import AskAction, GetClipboardAction 41 | from shortcuts.actions.menu import MenuEndAction, MenuItemAction, MenuStartAction 42 | from shortcuts.actions.messages import SendMessageAction 43 | from shortcuts.actions.numbers import NumberAction 44 | from shortcuts.actions.out import ( 45 | ExitAction, 46 | NotificationAction, 47 | SetClipboardAction, 48 | ShowAlertAction, 49 | ShowResultAction, 50 | SpeakTextAction, 51 | VibrateAction, 52 | ) 53 | from shortcuts.actions.photo import ( 54 | CameraAction, 55 | GetLastPhotoAction, 56 | ImageConvertAction, 57 | SelectPhotoAction, 58 | ) 59 | from shortcuts.actions.registry import ActionsRegistry 60 | from shortcuts.actions.scripting import ( 61 | ChooseFromListAction, 62 | ContinueInShortcutAppAction, 63 | DelayAction, 64 | GetMyShortcutsAction, 65 | HashAction, 66 | NothingAction, 67 | OpenAppAction, 68 | RepeatEachEndAction, 69 | RepeatEachStartAction, 70 | RepeatEndAction, 71 | RepeatStartAction, 72 | RunShortcutAction, 73 | SetItemNameAction, 74 | ViewContentGraphAction, 75 | WaitToReturnAction, 76 | ) 77 | from shortcuts.actions.text import ( 78 | ChangeCaseAction, 79 | CommentAction, 80 | DetectLanguageAction, 81 | GetNameOfEmojiAction, 82 | GetTextFromInputAction, 83 | ScanQRBarCodeAction, 84 | ShowDefinitionAction, 85 | SplitTextAction, 86 | TextAction, 87 | ) 88 | from shortcuts.actions.variables import ( 89 | AppendVariableAction, 90 | GetVariableAction, 91 | SetVariableAction, 92 | ) 93 | from shortcuts.actions.web import ( 94 | ExpandURLAction, 95 | GetURLAction, 96 | OpenURLAction, 97 | URLAction, 98 | URLDecodeAction, 99 | URLEncodeAction, 100 | ) 101 | 102 | 103 | # flake8: noqa 104 | 105 | logger = logging.getLogger(__name__) 106 | 107 | actions_registry = ActionsRegistry() 108 | 109 | 110 | def _register_actions(): 111 | # register all imported actions in the actions registry 112 | for _, val in globals().items(): 113 | if isinstance(val, type) and issubclass(val, BaseAction) and val.keyword: 114 | actions_registry.register_action(val) 115 | logging.debug(f'Registered actions: {len(actions_registry.actions)}') 116 | 117 | 118 | _register_actions() 119 | -------------------------------------------------------------------------------- /shortcuts/actions/b64.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction 2 | 3 | 4 | class Base64EncodeAction(BaseAction): 5 | '''Base64 encode''' 6 | 7 | itype = 'is.workflow.actions.base64encode' 8 | keyword = 'base64_encode' 9 | 10 | _additional_identifier_field = 'WFEncodeMode' 11 | _default_class = True 12 | 13 | default_fields = { 14 | 'WFEncodeMode': 'Encode', 15 | } 16 | 17 | 18 | class Base64DecodeAction(BaseAction): 19 | '''Base64 decode''' 20 | 21 | itype = 'is.workflow.actions.base64encode' 22 | keyword = 'base64_decode' 23 | 24 | _additional_identifier_field = 'WFEncodeMode' 25 | 26 | default_fields = { 27 | 'WFEncodeMode': 'Decode', 28 | } 29 | -------------------------------------------------------------------------------- /shortcuts/actions/base.py: -------------------------------------------------------------------------------- 1 | import re 2 | from copy import deepcopy 3 | from typing import Any, Dict, List, Optional, Tuple, Union 4 | 5 | 6 | class BaseAction: 7 | itype: Union[ 8 | str, None 9 | ] = None # identificator from shortcut source (being used by iOS app): WFWorkflowActionIdentifier 10 | keyword: Union[str, None] = None # this keyword is being used in the toml file 11 | default_fields: Dict = {} # noqa dictionary with default parameters fields 12 | _additional_identifier_field: Union[str, None] = None 13 | _default_class: Optional[bool] = None 14 | 15 | def __init__(self, data: Union[Dict, None] = None) -> None: 16 | self.data = data if data is not None else {} 17 | self.default_fields = deepcopy(self.default_fields) 18 | 19 | def dump(self) -> Dict: 20 | data: Dict[str, Any] = { 21 | 'WFWorkflowActionIdentifier': self.itype, 22 | 'WFWorkflowActionParameters': {}, 23 | } 24 | 25 | data['WFWorkflowActionParameters'].update( # type: ignore 26 | self._get_parameters(), 27 | ) 28 | 29 | return data 30 | 31 | def _get_parameters(self) -> Dict: 32 | ''' 33 | Returns dictionary with action parameters for a plist. 34 | It iterates over all fields, and processes the action's params to get values for all fields. 35 | 36 | As an output we have dictionary, and values of the dictionary 37 | are prepared data by corresponding field's "process_value" method. 38 | ''' 39 | params = {} 40 | 41 | if self.default_fields: 42 | params.update(self.default_fields) 43 | 44 | for field in self.fields: 45 | try: 46 | data_value = self.data[field._attr] 47 | except KeyError: 48 | if field.default is not None: 49 | data_value = field.default 50 | elif field.required: 51 | raise ValueError( 52 | f'{self}, Field is required: {field._attr}:{field.name}' 53 | ) 54 | else: 55 | continue 56 | 57 | params[field.name] = field.process_value(data_value) 58 | 59 | return params 60 | 61 | @property 62 | def fields(self) -> List[Any]: 63 | '''Returns list of all fields of the action (with memoization).''' 64 | if not hasattr(self, '_fields'): 65 | self._fields: List['Field'] = [] 66 | for attr in dir(self): 67 | field = getattr(self, attr) 68 | if isinstance(field, (Field, VariablesField)): 69 | field._attr = attr 70 | self._fields.append(field) 71 | 72 | return self._fields 73 | 74 | 75 | class Field: 76 | _attr: Union[None, str] = None 77 | 78 | def __init__(self, name, default=None, required=True, capitalize=False, help=''): 79 | self.name = name 80 | self.required = required 81 | self.capitalize = capitalize 82 | self.help = help 83 | self.default = default 84 | 85 | def process_value(self, value): 86 | '''Prepares the value to be represented in the Shortcuts app''' 87 | if self.capitalize: 88 | value = value.capitalize() 89 | 90 | return value 91 | 92 | 93 | class GroupIDField(Field): 94 | def __init__(self, *args, **kwargs): 95 | kwargs['required'] = False 96 | super().__init__(*args, **kwargs) 97 | 98 | 99 | class ChoiceField(Field): 100 | def __init__( 101 | self, name, choices, default=None, required=True, capitalize=False, help='' 102 | ): 103 | super().__init__( 104 | name=name, 105 | required=required, 106 | default=default, 107 | capitalize=capitalize, 108 | help=help, 109 | ) 110 | self.choices = choices 111 | 112 | def process_value(self, value): 113 | value = super().process_value(value) 114 | if value not in self.choices: 115 | raise ValueError(f'Value "{value}" not in the choices list: {self.choices}') 116 | return value 117 | 118 | 119 | class ArrayField(Field): 120 | def process_value(self, value): 121 | if not isinstance(value, (list, tuple)): 122 | raise ValueError(f'{self._attr}:{self.__class__}: Value must be a list') 123 | 124 | return super().process_value(value) 125 | 126 | 127 | class FloatField(Field): 128 | def process_value(self, value): 129 | return float(super().process_value(value)) 130 | 131 | 132 | class IntegerField(Field): 133 | def process_value(self, value): 134 | return int(super().process_value(value)) 135 | 136 | 137 | class BooleanField(Field): 138 | def process_value(self, value): 139 | if isinstance(value, bool): 140 | return value 141 | 142 | value = value.lower().strip() 143 | if value == 'true': 144 | return True 145 | elif value == 'false': 146 | return False 147 | 148 | raise ValueError(f'BooleanField: incorrect value: {value} type: {type(value)}') 149 | 150 | 151 | class WFVariableField(Field): 152 | def process_value(self, value): 153 | return { 154 | 'Value': { 155 | 'Type': 'Variable', 156 | 'VariableName': super().process_value(value), 157 | }, 158 | 'WFSerializationType': 'WFTextTokenAttachment', 159 | } 160 | 161 | 162 | # system variables are special variables supported by Shortcuts app. 163 | # Like "Ask when run", "Clipboard", "Current date" and others 164 | # Keys in the dictionary are variable names in the python-shortcuts 165 | # and values are names in the app 166 | SYSTEM_VARIABLES = { 167 | 'ask_when_run': 'Ask', 168 | 'shortcut_input': 'ExtensionInput', 169 | 'clipboard': 'Clipboard', 170 | 'current_date': 'CurrentDate', 171 | } 172 | SYSTEM_VARIABLES_TYPE_TO_VAR = {v: k for k, v in SYSTEM_VARIABLES.items()} 173 | 174 | 175 | class VariablesField(Field): 176 | _regexp = re.compile(r'({{[A-Za-z0-9_-]+}})') 177 | 178 | def process_value(self, value: str) -> Dict: 179 | token = self._check_token_match(value) 180 | if token: 181 | return token 182 | 183 | return { 184 | 'Value': self._get_variables_dict(value), 185 | 'WFSerializationType': 'WFTextTokenString', 186 | } 187 | 188 | def _check_token_match(self, value): 189 | variable = value.strip('{}') 190 | var_type = SYSTEM_VARIABLES.get(variable) 191 | if var_type: 192 | return { 193 | 'WFSerializationType': 'WFTextTokenAttachment', 194 | 'Value': {'Type': var_type}, 195 | } 196 | 197 | return None 198 | 199 | def _get_variables_dict(self, value: str) -> Dict: 200 | attachments_by_range, string = self._get_variables_from_text(value) 201 | return { 202 | 'attachmentsByRange': attachments_by_range, 203 | 'string': string, 204 | } 205 | 206 | def _get_variables_from_text( 207 | self, value: str 208 | ) -> Tuple[Dict[str, Dict[str, str]], str]: 209 | attachments_by_range = {} 210 | offset = 0 211 | for m in self._regexp.finditer(value): 212 | variable_name = m.group().strip('{}') 213 | var_type = SYSTEM_VARIABLES.get(variable_name, 'Variable') 214 | variable_range = f'{{{m.start() - offset}, {1}}}' 215 | attachments_by_range[variable_range] = { 216 | 'Type': var_type, 217 | } 218 | if var_type == 'Variable': 219 | attachments_by_range[variable_range]['VariableName'] = variable_name 220 | offset += len(m.group()) 221 | 222 | # replacing all variables with char 65523 (OBJECT REPLACEMENT CHARACTER) 223 | string = self._regexp.sub('', value) 224 | return attachments_by_range, string 225 | 226 | 227 | class DictionaryField(VariablesField): 228 | ''' 229 | { 230 | 'Value': { 231 | 'WFDictionaryFieldValueItems': [ 232 | { 233 | 'WFItemType': 0, 234 | 'WFKey': { 235 | 'Value': {'attachmentsByRange': {}, 'string': 'k'}, 236 | 'WFSerializationType': 'WFTextTokenString' 237 | }, 238 | 'WFValue': { 239 | 'Value': {'attachmentsByRange': {}, 'string': 'v'}, 240 | 'WFSerializationType': 'WFTextTokenString' 241 | } 242 | } 243 | ] 244 | }, 245 | 'WFSerializationType': 'WFDictionaryFieldValue' 246 | } 247 | ''' 248 | 249 | def process_value(self, value): 250 | return { 251 | 'Value': { 252 | 'WFDictionaryFieldValueItems': [ 253 | self._process_single_value(v) for v in value 254 | ], 255 | }, 256 | 'WFSerializationType': 'WFDictionaryFieldValue', 257 | } 258 | 259 | def _process_single_value(self, value): 260 | key = super().process_value(value['key']) 261 | value = super().process_value(value['value']) 262 | return { 263 | 'WFItemType': 0, # text 264 | 'WFKey': key, 265 | 'WFValue': value, 266 | } 267 | -------------------------------------------------------------------------------- /shortcuts/actions/calculation.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, Field 2 | 3 | 4 | class CountAction(BaseAction): 5 | '''Count''' 6 | 7 | itype = 'is.workflow.actions.count' 8 | keyword = 'count' 9 | 10 | count = Field('WFCountType', capitalize=True) 11 | -------------------------------------------------------------------------------- /shortcuts/actions/conditions.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, ChoiceField, GroupIDField, VariablesField 2 | 3 | 4 | IF_CHOICES = ( 5 | 'Equals', 6 | 'Contains', 7 | ) 8 | 9 | 10 | class IfAction(BaseAction): 11 | '''If''' 12 | 13 | itype = 'is.workflow.actions.conditional' 14 | keyword = 'if' 15 | 16 | _additional_identifier_field = 'WFControlFlowMode' 17 | 18 | condition = ChoiceField( 19 | 'WFCondition', choices=IF_CHOICES, capitalize=True, default=IF_CHOICES[0] 20 | ) 21 | compare_with = VariablesField('WFConditionalActionString') 22 | group_id = GroupIDField('GroupingIdentifier') 23 | 24 | default_fields = { 25 | 'WFControlFlowMode': 0, 26 | } 27 | 28 | 29 | class ElseAction(BaseAction): 30 | '''Else''' 31 | 32 | itype = 'is.workflow.actions.conditional' 33 | keyword = 'else' 34 | 35 | _additional_identifier_field = 'WFControlFlowMode' 36 | 37 | group_id = GroupIDField('GroupingIdentifier') 38 | 39 | default_fields = { 40 | 'WFControlFlowMode': 1, 41 | } 42 | 43 | 44 | class EndIfAction(BaseAction): 45 | '''EndIf: end a condition''' 46 | 47 | itype = 'is.workflow.actions.conditional' 48 | keyword = 'endif' 49 | 50 | _additional_identifier_field = 'WFControlFlowMode' 51 | 52 | group_id = GroupIDField('GroupingIdentifier') 53 | 54 | default_fields = { 55 | 'WFControlFlowMode': 2, 56 | } 57 | -------------------------------------------------------------------------------- /shortcuts/actions/date.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, ChoiceField, Field, VariablesField 2 | 3 | 4 | class DateAction(BaseAction): 5 | '''Date''' 6 | 7 | itype = 'is.workflow.actions.date' 8 | keyword = 'date' 9 | 10 | default_answer = VariablesField('WFAskActionDefaultAnswer', required=False) 11 | 12 | 13 | class FormatDateAction(BaseAction): 14 | '''Format date''' 15 | 16 | itype = 'is.workflow.actions.format.date' 17 | keyword = 'format_date' 18 | 19 | format = Field('WFDateFormat') 20 | 21 | default_fields = { 22 | 'WFDateFormatStyle': 'Custom', 23 | } 24 | 25 | 26 | class DetectDateAction(BaseAction): 27 | '''Detect date''' 28 | 29 | itype = 'is.workflow.actions.detect.date' 30 | keyword = 'detect_date' 31 | 32 | 33 | DATE_DIFF_UNITS_CHOICES = ( 34 | 'Total Time', 35 | 'Seconds', 36 | 'Minutes', 37 | 'Hours', 38 | 'Days', 39 | 'Weeks', 40 | 'Years', 41 | 'Other', 42 | ) 43 | 44 | 45 | class GetTimeBetweenDates(BaseAction): 46 | '''Get time difference between dates''' 47 | 48 | itype = 'is.workflow.actions.gettimebetweendates' 49 | keyword = 'get_time_between_dates' 50 | 51 | units = ChoiceField( 52 | 'WFTimeUntilUnit', 53 | choices=DATE_DIFF_UNITS_CHOICES, 54 | default=DATE_DIFF_UNITS_CHOICES[0], 55 | ) 56 | custom_date = Field('WFTimeUntilCustomDate', required=False) 57 | -------------------------------------------------------------------------------- /shortcuts/actions/device.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, BooleanField, ChoiceField, FloatField 2 | 3 | 4 | class GetBatteryLevelAction(BaseAction): 5 | '''Get battery level''' 6 | 7 | itype = 'is.workflow.actions.getbatterylevel' 8 | keyword = 'get_battery_level' 9 | 10 | 11 | class GetIPAddressAction(BaseAction): 12 | '''Get current IP address''' 13 | 14 | itype = 'is.workflow.actions.getipaddress' 15 | keyword = 'get_ip_address' 16 | 17 | source = ChoiceField('WFIPAddressSourceOption', choices=('Local', 'Global')) 18 | address_type = ChoiceField('WFIPAddressTypeOption', choices=('IPv4', 'IPv6')) 19 | 20 | 21 | DEVICE_DETAIL_CHOICES = ( 22 | 'Device Name', 23 | 'Device Model', 24 | 'System Version', 25 | 'Screen Width', 26 | 'Screen Height', 27 | 'Current Volume', 28 | 'Current Brightness', 29 | ) 30 | 31 | 32 | class GetDeviceDetailsAction(BaseAction): 33 | '''Get device details''' 34 | 35 | itype = 'is.workflow.actions.getdevicedetails' 36 | keyword = 'get_device_details' 37 | 38 | detail = ChoiceField('WFDeviceDetail', choices=DEVICE_DETAIL_CHOICES) 39 | 40 | 41 | class SetAirplaneModeAction(BaseAction): 42 | '''Set airplane mode''' 43 | 44 | itype = 'is.workflow.actions.airplanemode.set' 45 | keyword = 'set_airplane_mode' 46 | 47 | on = BooleanField('OnValue') 48 | 49 | 50 | class SetBluetoothAction(BaseAction): 51 | '''Set bluetooth''' 52 | 53 | itype = 'is.workflow.actions.bluetooth.set' 54 | keyword = 'set_bluetooth' 55 | 56 | on = BooleanField('OnValue') 57 | 58 | 59 | class SetBrightnessAction(BaseAction): 60 | '''Set brightness''' 61 | 62 | itype = 'is.workflow.actions.setbrightness' 63 | keyword = 'set_brightness' 64 | 65 | level = FloatField('WFBrightness') 66 | 67 | 68 | class SetMobileDataAction(BaseAction): 69 | '''Set mobile data''' 70 | 71 | itype = 'is.workflow.actions.cellulardata.set' 72 | keyword = 'set_mobile_data' 73 | 74 | on = BooleanField('OnValue') 75 | 76 | 77 | class SetDoNotDisturbAction(BaseAction): 78 | '''Set Do Not Disturb''' 79 | 80 | itype = 'is.workflow.actions.dnd.set' 81 | keyword = 'set_do_not_disturb' 82 | 83 | default_fields = { 84 | 'AssertionType': 'Turned Off', # todo: support more "until" 85 | } 86 | 87 | enabled = BooleanField('Enabled') 88 | 89 | 90 | class SetTorchAction(BaseAction): 91 | '''Set Torch''' 92 | 93 | itype = 'is.workflow.actions.flashlight' 94 | keyword = 'set_torch' 95 | 96 | mode = ChoiceField('WFFlashlightSetting', choices=('Off', 'On', 'Toggle')) 97 | 98 | 99 | class SetLowPowerModeAction(BaseAction): 100 | '''Set Low Power mode''' 101 | 102 | itype = 'is.workflow.actions.lowpowermode.set' 103 | keyword = 'set_low_power_mode' 104 | 105 | on = BooleanField('OnValue', default=True) 106 | 107 | 108 | class SetVolumeAction(BaseAction): 109 | '''Set volume''' 110 | 111 | itype = 'is.workflow.actions.setvolume' 112 | keyword = 'set_volume' 113 | 114 | level = FloatField('WFVolume') 115 | 116 | 117 | class SetWiFiAction(BaseAction): 118 | '''Set WiFi''' 119 | 120 | itype = 'is.workflow.actions.wifi.set' 121 | keyword = 'set_wifi' 122 | 123 | on = BooleanField('OnValue') 124 | -------------------------------------------------------------------------------- /shortcuts/actions/dictionary.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, DictionaryField, VariablesField 2 | 3 | 4 | class GetDictionaryValueAction(BaseAction): 5 | '''Get dictionary value''' 6 | 7 | itype = 'is.workflow.actions.getvalueforkey' 8 | keyword = 'get_value_for_key' 9 | 10 | key = VariablesField('WFDictionaryKey') 11 | 12 | 13 | class SetDictionaryValueAction(BaseAction): 14 | '''Set dictionary value''' 15 | 16 | itype = 'is.workflow.actions.setvalueforkey' 17 | keyword = 'set_value_for_key' 18 | 19 | key = VariablesField('WFDictionaryKey') 20 | value = VariablesField('WFDictionaryValue') 21 | 22 | 23 | class DictionaryAction(BaseAction): 24 | '''Dictionary''' 25 | 26 | itype = 'is.workflow.actions.dictionary' 27 | keyword = 'dictionary' 28 | 29 | items = DictionaryField('WFItems') 30 | 31 | 32 | class GetDictionaryFromInputAction(BaseAction): 33 | '''Get dictionary from input''' 34 | 35 | itype = 'is.workflow.actions.detect.dictionary' 36 | keyword = 'get_dictionary' 37 | -------------------------------------------------------------------------------- /shortcuts/actions/files.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, BooleanField, Field, VariablesField 2 | 3 | 4 | class ReadFileAction(BaseAction): 5 | '''Get file''' 6 | 7 | itype = 'is.workflow.actions.documentpicker.open' 8 | keyword = 'read_file' 9 | 10 | path = VariablesField('WFGetFilePath') 11 | not_found_error = BooleanField('WFFileErrorIfNotFound') 12 | show_picker = BooleanField('WFShowFilePicker') 13 | 14 | 15 | class SaveFileAction(BaseAction): 16 | '''Save file''' 17 | 18 | itype = 'is.workflow.actions.documentpicker.save' 19 | keyword = 'save_file' 20 | 21 | path = VariablesField('WFFileDestinationPath') 22 | overwrite = Field('WFSaveFileOverwrite') 23 | show_picker = BooleanField('WFAskWhereToSave') 24 | 25 | 26 | class AppendFileAction(BaseAction): 27 | '''Append file''' 28 | 29 | itype = 'is.workflow.actions.file.append' 30 | keyword = 'append_file' 31 | 32 | path = VariablesField('WFFilePath') 33 | 34 | 35 | class CreateFolderAction(BaseAction): 36 | '''Create folder''' 37 | 38 | itype = 'is.workflow.actions.file.createfolder' 39 | keyword = 'create_folder' 40 | 41 | path = VariablesField('WFFilePath') 42 | 43 | 44 | class PreviewDocumentAction(BaseAction): 45 | '''Preview document''' 46 | 47 | itype = 'is.workflow.actions.previewdocument' 48 | keyword = 'preview' 49 | -------------------------------------------------------------------------------- /shortcuts/actions/input.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, Field, VariablesField 2 | 3 | 4 | class AskAction(BaseAction): 5 | '''Ask for input''' 6 | 7 | itype = 'is.workflow.actions.ask' 8 | keyword = 'ask' 9 | 10 | question = Field('WFAskActionPrompt') 11 | input_type = Field('WFInputType', required=False) 12 | default_answer = VariablesField('WFAskActionDefaultAnswer', required=False) 13 | 14 | 15 | class GetClipboardAction(BaseAction): 16 | itype = 'is.workflow.actions.getclipboard' 17 | keyword = 'get_clipboard' 18 | -------------------------------------------------------------------------------- /shortcuts/actions/menu.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import ArrayField, BaseAction, Field, GroupIDField 2 | 3 | 4 | class MenuStartAction(BaseAction): 5 | ''' 6 | Start menu 7 | 8 | To build a menu, you have to write at least three actions: 9 | * Start menu 10 | * Menu item 11 | * End menu 12 | 13 | So the menu with two items will look like: 14 | 15 | ``` 16 | Start menu 17 | 18 | Menu item title=1 19 | ... some actions... 20 | 21 | Menu item title=2 22 | ...other actions... 23 | 24 | End menu 25 | ``` 26 | 27 | As in other actions which have `group_id` field, you don't need to specify it, it will be generated automatically. 28 | ''' 29 | 30 | itype = 'is.workflow.actions.choosefrommenu' 31 | keyword = 'start_menu' 32 | 33 | _additional_identifier_field = 'WFControlFlowMode' 34 | 35 | group_id = GroupIDField('GroupingIdentifier') 36 | 37 | menu_items = ArrayField('WFMenuItems') 38 | 39 | default_fields = { 40 | 'WFControlFlowMode': 0, 41 | } 42 | 43 | 44 | class MenuItemAction(BaseAction): 45 | ''' 46 | Menu item 47 | 48 | You must specify the title for the item. 49 | After this action write all actions which you want to be executed when a user selects this option in the menu. 50 | ''' 51 | 52 | itype = 'is.workflow.actions.choosefrommenu' 53 | keyword = 'menu_item' 54 | 55 | _additional_identifier_field = 'WFControlFlowMode' 56 | 57 | group_id = GroupIDField('GroupingIdentifier') 58 | title = Field('WFMenuItemTitle') 59 | 60 | default_fields = { 61 | 'WFControlFlowMode': 1, 62 | } 63 | 64 | 65 | class MenuEndAction(BaseAction): 66 | '''End menu''' 67 | 68 | itype = 'is.workflow.actions.choosefrommenu' 69 | keyword = 'end_menu' 70 | 71 | _additional_identifier_field = 'WFControlFlowMode' 72 | 73 | group_id = GroupIDField('GroupingIdentifier') 74 | 75 | default_fields = { 76 | 'WFControlFlowMode': 2, 77 | } 78 | -------------------------------------------------------------------------------- /shortcuts/actions/messages.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, VariablesField 2 | 3 | 4 | class SendMessageAction(BaseAction): 5 | '''Send Message''' 6 | 7 | itype = 'is.workflow.actions.sendmessage' 8 | keyword = 'send_message' 9 | 10 | recepients = VariablesField('WFSendMessageActionRecipients') 11 | text = VariablesField('WFSendMessageContent') 12 | 13 | default_fields = { 14 | 'IntentAppIdentifier': 'com.apple.MobileSMS', 15 | } 16 | -------------------------------------------------------------------------------- /shortcuts/actions/numbers.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, FloatField 2 | 3 | 4 | class NumberAction(BaseAction): 5 | itype = 'is.workflow.actions.number' 6 | keyword = 'number' 7 | 8 | number = FloatField('WFNumberActionNumber') 9 | -------------------------------------------------------------------------------- /shortcuts/actions/out.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import ( 2 | BaseAction, 3 | BooleanField, 4 | ChoiceField, 5 | Field, 6 | FloatField, 7 | VariablesField, 8 | ) 9 | 10 | 11 | class ShowResultAction(BaseAction): 12 | '''Show result: shows a result''' 13 | 14 | itype = 'is.workflow.actions.showresult' 15 | keyword = 'show_result' 16 | 17 | text = VariablesField('Text') 18 | 19 | 20 | class ShowAlertAction(BaseAction): 21 | '''Show alert''' 22 | 23 | itype = 'is.workflow.actions.alert' 24 | keyword = 'alert' 25 | 26 | show_cancel_button = BooleanField('WFAlertActionCancelButtonShown', default=True) 27 | text = VariablesField('WFAlertActionMessage') 28 | title = VariablesField('WFAlertActionTitle') 29 | 30 | 31 | class NotificationAction(BaseAction): 32 | '''Show notification''' 33 | 34 | itype = 'is.workflow.actions.notification' 35 | keyword = 'notification' 36 | 37 | play_sound = BooleanField('WFNotificationActionSound', default=True) 38 | text = VariablesField('WFNotificationActionBody') 39 | title = VariablesField('WFNotificationActionTitle') 40 | 41 | 42 | class ExitAction(BaseAction): 43 | '''Exit''' 44 | 45 | itype = 'is.workflow.actions.exit' 46 | keyword = 'exit' 47 | 48 | 49 | class VibrateAction(BaseAction): 50 | '''Vibrate''' 51 | 52 | itype = 'is.workflow.actions.vibrate' 53 | keyword = 'vibrate' 54 | 55 | 56 | SPEAK_LANGUAGE_CHOICES = ( 57 | 'Čeština (Česko)', 58 | 'Dansk (Danmark)', 59 | 'Deutsch (Deutschland)', 60 | 'English (Australia)', 61 | 'English (Ireland)', 62 | 'English (South Africa)', 63 | 'English (United Kingdom)', 64 | 'English (United States)', 65 | 'Español (España)', 66 | 'Español (México)', 67 | ) 68 | 69 | 70 | class SpeakTextAction(BaseAction): 71 | '''Speak text''' 72 | 73 | itype = 'is.workflow.actions.speaktext' 74 | keyword = 'speak_text' 75 | 76 | language = ChoiceField('WFSpeakTextLanguage', choices=SPEAK_LANGUAGE_CHOICES) 77 | pitch = FloatField('WFSpeakTextPitch', default=0.95) 78 | rate = FloatField('WFSpeakTextRate', default=0.44) 79 | wait_until_finished = BooleanField('WFSpeakTextWait', default=True) 80 | 81 | 82 | class SetClipboardAction(BaseAction): 83 | itype = 'is.workflow.actions.setclipboard' 84 | keyword = 'set_clipboard' 85 | 86 | local_only = BooleanField('WFLocalOnly', required=False) 87 | expiration_date = Field('WFExpirationDate', required=False) 88 | -------------------------------------------------------------------------------- /shortcuts/actions/photo.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, BooleanField, Field 2 | 3 | 4 | class CameraAction(BaseAction): 5 | '''Take photo''' 6 | 7 | itype = 'is.workflow.actions.takephoto' 8 | keyword = 'take_photo' 9 | 10 | 11 | class GetLastPhotoAction(BaseAction): 12 | '''Get latest photos''' 13 | 14 | itype = 'is.workflow.actions.getlastphoto' 15 | keyword = 'get_last_photo' 16 | 17 | 18 | class SelectPhotoAction(BaseAction): 19 | '''Select photos''' 20 | 21 | itype = 'is.workflow.actions.selectphoto' 22 | keyword = 'select_photo' 23 | 24 | 25 | class ImageConvertAction(BaseAction): 26 | '''Image convert''' 27 | 28 | itype = 'is.workflow.actions.image.convert' 29 | keyword = 'convert_image' 30 | 31 | compression_quality = Field('WFImageCompressionQuality') 32 | format = Field('WFImageFormat') 33 | preserve_metadata = BooleanField('WFImagePreserveMetadata') 34 | -------------------------------------------------------------------------------- /shortcuts/actions/registry.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type 2 | 3 | from shortcuts import exceptions 4 | from shortcuts.actions.base import BaseAction 5 | 6 | 7 | class ActionsRegistry: 8 | def __init__(self): 9 | """ 10 | _keyword_to_action_map looks simple: Dict[keyword, action] 11 | 12 | but _itype_to_action_map is more complex. Sometimes actions in the Shortcuts app 13 | have the same itype, but different name and behavior. 14 | 15 | They have different field in parameters, for example 'WFEncodeMode' in is.workflow.actions.base64encode 16 | can have values "Encode" or "Decode". For python-shortcuts they are different action classes. 17 | When action class is being registered, ActionsRegistry checks attribute "_additional_identifier_field" 18 | and based on this stores information. 19 | 20 | So to find proper class ActionsRegistry stores _itype_to_action_map in this format: 21 | 22 | _itype_to_action_map = { 23 | 'some-itype': { 24 | # simple itype -> class mapping 25 | 'type': 'class', 26 | 'value': FirstAction, 27 | }, 28 | 'another-itype': { 29 | # more complex situation, the same itype, 30 | # but different classes defined by WFEncodeMode field 31 | 'type': 'property_based', 32 | 'field': 'WFEncodeMode', 33 | 'value': { 34 | 'Encode': Base64EncodeAction, 35 | 'Decode': Base64DecodeAction, 36 | }, 37 | } 38 | } 39 | 40 | """ 41 | self._keyword_to_action_map: Dict[str, Type[BaseAction]] = {} 42 | self._itype_to_action_map = {} 43 | 44 | def register_action(self, action_class: Type[BaseAction]) -> None: 45 | '''Registers action class in the registry''' 46 | self._keyword_to_action_map[action_class.keyword] = action_class # type: ignore 47 | 48 | if action_class._additional_identifier_field: 49 | self._create_class_field_if_needed(action_class) 50 | field_value = action_class.default_fields[ 51 | action_class._additional_identifier_field 52 | ] 53 | self._itype_to_action_map[action_class.itype]['value'][ 54 | field_value 55 | ] = action_class 56 | if action_class._default_class: 57 | self._itype_to_action_map[action_class.itype]['value'][ 58 | None 59 | ] = action_class 60 | else: 61 | self._itype_to_action_map[action_class.itype] = { 62 | 'type': 'class', 63 | 'value': action_class, 64 | } 65 | 66 | def _create_class_field_if_needed(self, action_class: Type[BaseAction]) -> None: 67 | '''creates all necessary fields for itype in _itype_to_action_map''' 68 | if action_class.itype in self._itype_to_action_map: 69 | return 70 | 71 | self._itype_to_action_map[action_class.itype] = { 72 | 'type': 'property_based', 73 | 'field': action_class._additional_identifier_field, 74 | 'value': {}, 75 | } 76 | 77 | def get_by_itype(self, itype: str, action_params: Dict) -> Type[BaseAction]: 78 | '''Returns action class by itype and plist parameters''' 79 | class_info = self._itype_to_action_map.get(itype) 80 | if not class_info: 81 | raise exceptions.UnknownActionError(itype, action_dict=action_params) 82 | 83 | value_type = class_info.get('type') 84 | 85 | if value_type == 'class': 86 | return class_info['value'] 87 | elif value_type == 'property_based': 88 | params = action_params.get('WFWorkflowActionParameters', {}) 89 | field_value = params.get(class_info['field']) 90 | if field_value in class_info['value']: 91 | return class_info['value'][field_value] 92 | 93 | raise exceptions.UnknownActionError(itype, action_dict=action_params) 94 | 95 | def get_by_keyword(self, keyword: str) -> Type[BaseAction]: 96 | '''Returns action class by keyword''' 97 | if keyword not in self._keyword_to_action_map: 98 | raise exceptions.UnknownActionError(keyword) 99 | return self._keyword_to_action_map[keyword] 100 | 101 | @property 102 | def actions(self): 103 | return [a for _, a in sorted(self._keyword_to_action_map.items())] 104 | -------------------------------------------------------------------------------- /shortcuts/actions/scripting.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import ( 2 | BaseAction, 3 | BooleanField, 4 | ChoiceField, 5 | Field, 6 | FloatField, 7 | GroupIDField, 8 | IntegerField, 9 | ) 10 | 11 | 12 | class NothingAction(BaseAction): 13 | '''Nothing''' 14 | 15 | itype = 'is.workflow.actions.nothing' 16 | keyword = 'nothing' 17 | 18 | 19 | class SetItemNameAction(BaseAction): 20 | '''Set item name''' 21 | 22 | # todo: advanced 23 | # 24 | # WFWorkflowActionIdentifier 25 | # is.workflow.actions.setitemname 26 | # WFWorkflowActionParameters 27 | # 28 | # Advanced 29 | # 30 | # WFDontIncludeFileExtension 31 | # 32 | # 33 | # 34 | 35 | itype = 'is.workflow.actions.setitemname' 36 | keyword = 'set_item_name' 37 | 38 | 39 | class ViewContentGraphAction(BaseAction): 40 | '''View content graph''' 41 | 42 | itype = 'is.workflow.actions.viewresult' 43 | keyword = 'view_content_graph' 44 | 45 | 46 | class ContinueInShortcutAppAction(BaseAction): 47 | '''Continue in shortcut app''' 48 | 49 | itype = 'is.workflow.actions.handoff' 50 | keyword = 'continue_in_shortcut_app' 51 | 52 | 53 | class ChooseFromListAction(BaseAction): 54 | '''Choose from list''' 55 | 56 | itype = 'is.workflow.actions.choosefromlist' 57 | keyword = 'choose_from_list' 58 | prompt = Field('WFChooseFromListActionPrompt', required=False) 59 | select_multiple = BooleanField( 60 | 'WFChooseFromListActionSelectMultiple', required=False 61 | ) 62 | select_all_initially = BooleanField( 63 | 'WFChooseFromListActionSelectAll', required=False 64 | ) 65 | 66 | 67 | class DelayAction(BaseAction): 68 | '''Delay''' 69 | 70 | itype = 'is.workflow.actions.delay' 71 | keyword = 'delay' 72 | 73 | time = FloatField('WFDelayTime') 74 | 75 | 76 | class WaitToReturnAction(BaseAction): 77 | '''Wait to return''' 78 | 79 | itype = 'is.workflow.actions.waittoreturn' 80 | keyword = 'wait_to_return' 81 | 82 | 83 | class RepeatStartAction(BaseAction): 84 | '''Repeat''' 85 | 86 | itype = 'is.workflow.actions.repeat.count' 87 | keyword = 'repeat_start' 88 | 89 | _additional_identifier_field = 'WFControlFlowMode' 90 | 91 | group_id = GroupIDField('GroupingIdentifier') 92 | count = IntegerField('WFRepeatCount') 93 | 94 | default_fields = { 95 | 'WFControlFlowMode': 0, 96 | } 97 | 98 | 99 | class RepeatEndAction(BaseAction): 100 | '''Repeat''' 101 | 102 | itype = 'is.workflow.actions.repeat.count' 103 | keyword = 'repeat_end' 104 | 105 | _additional_identifier_field = 'WFControlFlowMode' 106 | 107 | group_id = GroupIDField('GroupingIdentifier') 108 | 109 | default_fields = { 110 | 'WFControlFlowMode': 2, 111 | } 112 | 113 | 114 | class RepeatEachStartAction(BaseAction): 115 | '''Repeat with each start''' 116 | 117 | itype = 'is.workflow.actions.repeat.each' 118 | keyword = 'repeat_with_each_start' 119 | 120 | _additional_identifier_field = 'WFControlFlowMode' 121 | 122 | group_id = GroupIDField('GroupingIdentifier') 123 | 124 | default_fields = { 125 | 'WFControlFlowMode': 0, 126 | } 127 | 128 | 129 | class RepeatEachEndAction(BaseAction): 130 | '''Repeat with each end''' 131 | 132 | itype = 'is.workflow.actions.repeat.each' 133 | keyword = 'repeat_with_each_end' 134 | 135 | _additional_identifier_field = 'WFControlFlowMode' 136 | 137 | group_id = GroupIDField('GroupingIdentifier') 138 | 139 | default_fields = { 140 | 'WFControlFlowMode': 2, 141 | } 142 | 143 | 144 | HASH_CHOICES = ( 145 | 'MD5', 146 | 'SHA1', 147 | 'SHA256', 148 | 'SHA512', 149 | ) 150 | 151 | 152 | class HashAction(BaseAction): 153 | '''Hash action''' 154 | 155 | itype = 'is.workflow.actions.hash' 156 | keyword = 'hash' 157 | 158 | hash_type = ChoiceField('WFHashType', choices=HASH_CHOICES, default=HASH_CHOICES[0]) 159 | 160 | 161 | class GetMyShortcutsAction(BaseAction): 162 | '''Get my shortcuts''' 163 | 164 | itype = 'is.workflow.actions.getmyworkflows' 165 | keyword = 'get_my_shortcuts' 166 | 167 | 168 | class RunShortcutAction(BaseAction): 169 | '''Run shortcut''' 170 | 171 | itype = 'is.workflow.actions.runworkflow' 172 | keyword = 'run_shortcut' 173 | 174 | show = BooleanField('WFShowWorkflow', default=False) 175 | shortcut_name = Field('WFWorkflowName') 176 | 177 | 178 | class OpenAppAction(BaseAction): 179 | '''Opens the specified app.''' 180 | 181 | itype = 'is.workflow.actions.openapp' 182 | keyword = 'open_app' 183 | 184 | app = Field('WFAppIdentifier') 185 | -------------------------------------------------------------------------------- /shortcuts/actions/text.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, ChoiceField, Field, VariablesField 2 | 3 | 4 | class CommentAction(BaseAction): 5 | '''Comment: just a comment''' 6 | 7 | itype = 'is.workflow.actions.comment' 8 | keyword = 'comment' 9 | 10 | text = Field('WFCommentActionText', help='Text to show in the comment') 11 | 12 | 13 | class TextAction(BaseAction): 14 | '''Text: returns text as an output''' 15 | 16 | itype = 'is.workflow.actions.gettext' 17 | keyword = 'text' 18 | 19 | text = VariablesField('WFTextActionText', help='Output of this action') 20 | 21 | 22 | CASE_CHOICES = ( 23 | 'UPPERCASE', 24 | 'lowercase', 25 | 'Capitalize Every Word', 26 | 'Capitalize with Title Case', 27 | 'Capitalize with sentence case.', 28 | 'cApItAlIzE wItH aLtErNaTiNg CaSe.', 29 | ) 30 | 31 | 32 | class ChangeCaseAction(BaseAction): 33 | '''Change case''' 34 | 35 | itype = 'is.workflow.actions.text.changecase' 36 | keyword = 'change_case' 37 | 38 | case_type = ChoiceField('WFCaseType', choices=CASE_CHOICES) 39 | 40 | 41 | SPLIT_SEPARATOR_CHOICES = ( 42 | 'New Lines', 43 | 'Spaces', 44 | 'Every Character', 45 | 'Custom', 46 | ) 47 | 48 | 49 | class SplitTextAction(BaseAction): 50 | '''Split text''' 51 | 52 | itype = 'is.workflow.actions.text.split' 53 | keyword = 'split_text' 54 | 55 | separator_type = ChoiceField( 56 | 'WFTextSeparator', 57 | choices=SPLIT_SEPARATOR_CHOICES, 58 | default=SPLIT_SEPARATOR_CHOICES[0], 59 | ) 60 | custom_separator = Field( 61 | 'WFTextCustomSeparator', 62 | help='Works only with "Custom" `separator_type`', 63 | required=False, 64 | ) 65 | 66 | 67 | class DetectLanguageAction(BaseAction): 68 | '''Detect Language with Microsoft''' 69 | 70 | itype = 'is.workflow.actions.detectlanguage' 71 | keyword = 'detect_language' 72 | 73 | 74 | class GetNameOfEmojiAction(BaseAction): 75 | '''Get name of emoji''' 76 | 77 | itype = 'is.workflow.actions.getnameofemoji' 78 | keyword = 'get_name_of_emoji' 79 | 80 | 81 | class GetTextFromInputAction(BaseAction): 82 | ''' 83 | Get text from input 84 | 85 | Returns text from the previous action's input. 86 | For example, this action can get the name of a photo 87 | or song, or the text of a web page. 88 | ''' 89 | 90 | itype = 'is.workflow.actions.detect.text' 91 | keyword = 'get_text_from_input' 92 | 93 | 94 | class ScanQRBarCodeAction(BaseAction): 95 | '''Scan QR/Barcode''' 96 | 97 | itype = 'is.workflow.actions.scanbarcode' 98 | keyword = 'scan_barcode' 99 | 100 | 101 | class ShowDefinitionAction(BaseAction): 102 | '''Show definition''' 103 | 104 | itype = 'is.workflow.actions.showdefinition' 105 | keyword = 'show_definition' 106 | -------------------------------------------------------------------------------- /shortcuts/actions/variables.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction, Field, WFVariableField 2 | 3 | 4 | class SetVariableAction(BaseAction): 5 | '''Set variable: saves input to a variable with a name=`name`''' 6 | 7 | itype = 'is.workflow.actions.setvariable' 8 | keyword = 'set_variable' 9 | 10 | name = Field('WFVariableName') 11 | 12 | 13 | class GetVariableAction(BaseAction): 14 | '''Get variable: returns variable with name=`name` in the output''' 15 | 16 | itype = 'is.workflow.actions.getvariable' 17 | keyword = 'get_variable' 18 | 19 | name = WFVariableField('WFVariable') 20 | 21 | 22 | class AppendVariableAction(BaseAction): 23 | '''Append input to varaible''' 24 | 25 | itype = 'is.workflow.actions.appendvariable' 26 | keyword = 'append_variable' 27 | 28 | name = Field('WFVariableName') 29 | -------------------------------------------------------------------------------- /shortcuts/actions/web.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union 2 | 3 | from shortcuts.actions.base import BaseAction, BooleanField, DictionaryField, Field 4 | 5 | 6 | class URLAction(BaseAction): 7 | '''URL: returns url as an output''' 8 | 9 | itype = 'is.workflow.actions.url' 10 | keyword = 'url' 11 | 12 | url = Field('WFURLActionURL') 13 | 14 | 15 | class HTTPMethodField(Field): 16 | methods = ( 17 | 'GET', 18 | 'POST', 19 | 'PUT', 20 | 'PATCH', 21 | 'DELETE', 22 | ) 23 | 24 | def process_value(self, value): 25 | value = super().process_value(value).upper() 26 | if value not in self.methods: 27 | raise ValueError( 28 | f'Unsupported HTTP method: {value}. \nSupported: {self.methods}' 29 | ) 30 | return value 31 | 32 | 33 | class GetURLAction(BaseAction): 34 | '''Get URL''' 35 | 36 | itype = 'is.workflow.actions.downloadurl' 37 | keyword = 'get_url' 38 | 39 | advanced = BooleanField('Advanced', required=False) 40 | method = HTTPMethodField('WFHTTPMethod', required=False) 41 | headers = DictionaryField('WFHTTPHeaders', required=False) 42 | json = DictionaryField('WFJSONValues', required=False) # todo: array or dict 43 | form = DictionaryField('WFFormValues', required=False) # todo: array or dict 44 | 45 | def __init__(self, data: Union[Dict, None] = None) -> None: 46 | self.default_fields = {} 47 | super().__init__(data=data) 48 | 49 | if data and data.get('form'): 50 | self.default_fields['WFHTTPBodyType'] = 'Form' 51 | elif data and data.get('json'): 52 | self.default_fields['WFHTTPBodyType'] = 'Json' 53 | 54 | if data and data.get('headers'): 55 | self.default_fields['ShowHeaders'] = True 56 | 57 | 58 | class URLEncodeAction(BaseAction): 59 | '''URL Encode''' 60 | 61 | itype = 'is.workflow.actions.urlencode' 62 | keyword = 'urlencode' 63 | 64 | _additional_identifier_field = 'WFEncodeMode' 65 | _default_class = True 66 | 67 | default_fields = { 68 | 'WFEncodeMode': 'Encode', 69 | } 70 | 71 | 72 | class URLDecodeAction(BaseAction): 73 | '''URL Dencode''' 74 | 75 | itype = 'is.workflow.actions.urlencode' 76 | keyword = 'urldecode' 77 | 78 | _additional_identifier_field = 'WFEncodeMode' 79 | 80 | default_fields = { 81 | 'WFEncodeMode': 'Decode', 82 | } 83 | 84 | 85 | class ExpandURLAction(BaseAction): 86 | ''' 87 | Expand URL: This action expands and cleans up URLs 88 | that have been shortened by a URL shortening 89 | service like TinyURL or bit.ly 90 | ''' 91 | 92 | itype = 'is.workflow.actions.url.expand' 93 | keyword = 'expand_url' 94 | 95 | 96 | class OpenURLAction(BaseAction): 97 | '''Open URL from previous action''' 98 | 99 | itype = 'is.workflow.actions.openurl' 100 | keyword = 'open_url' 101 | -------------------------------------------------------------------------------- /shortcuts/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os.path 3 | 4 | import shortcuts 5 | from shortcuts.utils import download_shortcut, is_shortcut_url 6 | 7 | 8 | def convert_shortcut(input_filepath: str, out_filepath: str) -> None: 9 | ''' 10 | Args: 11 | input_filepath: input file with a shortcut 12 | out_filepath: where save the shortcut 13 | 14 | Detects input and output file formats and converts input file into output. 15 | There is 4 possible (and supported) situations: 16 | 1. URL -> .toml (downloads, converts and saves) 17 | 2. URL -> .shortcut (downloads and saves directly) 18 | 3. toml -> shortcut (converts and saves) 19 | 4. shortcut -> toml (converts and saves) 20 | 21 | Supported URLs are www.icloud.com/shortcuts/... and icloud.com/shortcuts 22 | ''' 23 | input_format = _get_format(input_filepath) 24 | out_format = _get_format(out_filepath) 25 | 26 | if input_format == 'url': 27 | sc_data = download_shortcut(input_filepath) 28 | if out_format == shortcuts.FMT_SHORTCUT: 29 | # if output format is .shortcut just save 30 | # downloaded data without any conversion 31 | with open(out_filepath, 'wb') as f: 32 | f.write(sc_data) 33 | return 34 | sc = shortcuts.Shortcut.loads(sc_data, file_format=shortcuts.FMT_SHORTCUT) 35 | else: 36 | with open(input_filepath, 'rb') as f: 37 | sc = shortcuts.Shortcut.load(f, file_format=input_format) 38 | 39 | with open(out_filepath, 'wb') as f: 40 | sc.dump(f, file_format=out_format) 41 | 42 | 43 | def _get_format(filepath: str) -> str: 44 | ''' 45 | Args: 46 | filepath: path for a file which format needs to be determined 47 | 48 | Returns: 49 | file format (shortcut, toml or url) 50 | ''' 51 | if is_shortcut_url(filepath): 52 | return 'url' 53 | 54 | _, ext = os.path.splitext(filepath) 55 | ext = ext.strip('.') 56 | if ext in (shortcuts.FMT_SHORTCUT, 'plist'): 57 | return shortcuts.FMT_SHORTCUT 58 | elif ext == 'toml': 59 | return shortcuts.FMT_TOML 60 | 61 | raise RuntimeError(f'Unsupported file format: {filepath}: "{ext}"') 62 | 63 | 64 | def main(): 65 | parser = argparse.ArgumentParser(description='Shortcuts: Siri shortcuts creator') 66 | parser.add_argument( 67 | 'file', nargs='?', help='Input file: *.(toml|shortcut|itunes url)' 68 | ) 69 | parser.add_argument('output', nargs='?', help='Output file: *.(toml|shortcut)') 70 | parser.add_argument('--version', action='store_true', help='Version information') 71 | 72 | args = parser.parse_args() 73 | 74 | if not any([args.version, args.file, args.output]): 75 | parser.error('the following arguments are required: file, output') 76 | 77 | if args.version: 78 | print(f'Shortcuts v{shortcuts.VERSION}') 79 | return 80 | 81 | convert_shortcut(args.file, args.output) 82 | 83 | 84 | if __name__ == '__main__': 85 | main() 86 | -------------------------------------------------------------------------------- /shortcuts/dump.py: -------------------------------------------------------------------------------- 1 | import plistlib 2 | from typing import TYPE_CHECKING, Any, BinaryIO, Dict, Type 3 | 4 | import toml 5 | 6 | 7 | if TYPE_CHECKING: 8 | from shortcuts import Shortcut # noqa 9 | from shortcuts.actions.base import BaseAction # noqa 10 | 11 | 12 | class BaseDumper: 13 | ''' 14 | Base class to dump shortcuts 15 | ''' 16 | 17 | def __init__(self, shortcut: 'Shortcut') -> None: 18 | self.shortcut = shortcut 19 | 20 | def dump(self, file_obj: BinaryIO) -> None: 21 | data = self.dumps() 22 | 23 | if isinstance(data, str): 24 | data = data.encode('utf-8') # type: ignore 25 | file_obj.write(data) # type: ignore 26 | 27 | def dumps(self) -> str: 28 | raise NotImplementedError() 29 | 30 | 31 | class PListDumper(BaseDumper): 32 | ''' 33 | PListDumper is a class which dumps shortcuts to 34 | binary plist files supported by Apple Shortcuts app 35 | ''' 36 | 37 | def dump(self, file_obj: BinaryIO) -> None: # type: ignore 38 | binary = plistlib.dumps( # todo: change dumps to binary and remove this 39 | plistlib.loads(self.dumps().encode('utf-8')), # type: ignore 40 | fmt=plistlib.FMT_BINARY, 41 | ) 42 | file_obj.write(binary) 43 | 44 | def dumps(self) -> str: 45 | data = { 46 | 'WFWorkflowActions': self.shortcut._get_actions(), 47 | 'WFWorkflowImportQuestions': self.shortcut._get_import_questions(), 48 | 'WFWorkflowClientRelease': self.shortcut.client_release, 49 | 'WFWorkflowClientVersion': self.shortcut.client_version, 50 | 'WFWorkflowTypes': ['NCWidget', 'WatchKit'], # todo: change me 51 | 'WFWorkflowIcon': self.shortcut._get_icon(), 52 | 'WFWorkflowInputContentItemClasses': self.shortcut._get_input_content_item_classes(), 53 | } 54 | 55 | return plistlib.dumps(data).decode('utf-8') 56 | 57 | 58 | class TomlDumper(BaseDumper): 59 | '''TomlDumper is a class which dumps shortcuts to toml files''' 60 | 61 | def dumps(self) -> str: 62 | data = { 63 | 'action': [self._process_action(a) for a in self.shortcut.actions], 64 | } 65 | 66 | return toml.dumps(data) 67 | 68 | def _process_action(self, action: Type['BaseAction']) -> Dict[str, Any]: 69 | data = { 70 | f._attr: action.data[f._attr] 71 | for f in action.fields # type: ignore 72 | if f._attr in action.data 73 | } 74 | data['type'] = action.keyword 75 | 76 | return data 77 | -------------------------------------------------------------------------------- /shortcuts/exceptions.py: -------------------------------------------------------------------------------- 1 | class ShortcutsException(Exception): 2 | '''Base exception for shortcuts''' 3 | 4 | 5 | class UnknownActionError(ShortcutsException): 6 | '''Action is not supported''' 7 | 8 | def __init__(self, itype, action_dict=None): 9 | message = f''' 10 | Unknown shortcut action: "{itype}" 11 | 12 | Please, check documentation to add new shortcut action, or create an issue: 13 | Docs: https://github.com/alexander-akhmetov/python-shortcuts/tree/master/docs/new_action.md 14 | 15 | https://github.com/alexander-akhmetov/python-shortcuts/ 16 | ''' 17 | if action_dict: 18 | message += f''' 19 | Action dictionary: 20 | 21 | {action_dict} 22 | ''' 23 | super().__init__(message) 24 | 25 | 26 | class UnknownWFTextTokenAttachment(ShortcutsException): 27 | '''Unknown WFTextTokenAttachment type''' 28 | 29 | 30 | class UnknownWFEncodeModeError(ShortcutsException): 31 | '''Unknown value of the WFEncodeMode field''' 32 | 33 | 34 | class UnknownVariableError(ShortcutsException): 35 | '''Unknown variable''' 36 | 37 | 38 | class UnknownSerializationType(ShortcutsException): 39 | '''Unknown serialization type''' 40 | 41 | 42 | class IncompleteCycleError(ShortcutsException): 43 | '''Incomplete cycle error''' 44 | 45 | 46 | class InvalidShortcutURLError(ShortcutsException, ValueError): 47 | '''Invalid shortcut URL''' 48 | -------------------------------------------------------------------------------- /shortcuts/loader.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import copy 3 | import plistlib 4 | from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Type, Union 5 | 6 | import toml 7 | 8 | from shortcuts import exceptions 9 | from shortcuts.actions import actions_registry 10 | from shortcuts.actions.base import SYSTEM_VARIABLES_TYPE_TO_VAR 11 | 12 | 13 | if TYPE_CHECKING: 14 | from shortcuts import Shortcut # noqa 15 | from shortcuts.actions.base import BaseAction # noqa 16 | 17 | 18 | class BaseLoader: 19 | '''Base class for all classes which load shortcuts from files or strings''' 20 | 21 | @classmethod 22 | def load(cls, file_obj: BinaryIO) -> 'Shortcut': 23 | content = file_obj.read() 24 | 25 | return cls.loads(content) # type: ignore 26 | 27 | @classmethod 28 | def loads(cls, string: str) -> 'Shortcut': 29 | raise NotImplementedError() 30 | 31 | 32 | class TomlLoader(BaseLoader): 33 | @classmethod 34 | def loads(cls, string: str) -> 'Shortcut': 35 | from shortcuts import Shortcut # noqa 36 | 37 | if isinstance(string, (bytearray, bytes)): 38 | string = string.decode('utf-8') 39 | 40 | shortcut_dict = toml.loads(string) 41 | shortcut = Shortcut(name=shortcut_dict.get('name', 'python-shortcuts')) 42 | 43 | if not isinstance(shortcut_dict.get('action'), list): 44 | raise ValueError('toml file must contain "action" array with actions') 45 | 46 | for params in shortcut_dict['action']: 47 | action_params = copy.deepcopy(params) 48 | del action_params['type'] 49 | 50 | action_class = actions_registry.get_by_keyword(params['type']) 51 | action = action_class(data=action_params) 52 | shortcut.actions.append(action) 53 | 54 | return shortcut 55 | 56 | 57 | class PListLoader(BaseLoader): 58 | @classmethod 59 | def loads(cls, string: Union[str, bytes]) -> 'Shortcut': 60 | from shortcuts import Shortcut # noqa 61 | 62 | if isinstance(string, str): 63 | string = string.encode('utf-8') 64 | 65 | shortcut_dict: Dict = plistlib.loads(string) 66 | shortcut = Shortcut( 67 | name=shortcut_dict.get('name', 'python-shortcuts'), 68 | client_release=shortcut_dict['WFWorkflowClientRelease'], 69 | client_version=shortcut_dict['WFWorkflowClientVersion'], 70 | ) 71 | 72 | for action in shortcut_dict['WFWorkflowActions']: 73 | shortcut.actions.append(cls._action_from_dict(action)) 74 | 75 | return shortcut 76 | 77 | @classmethod 78 | def _action_from_dict(cls, action_dict: Dict) -> 'BaseAction': 79 | '''Returns action instance from the dictionary with all necessary parameters''' 80 | identifier = action_dict['WFWorkflowActionIdentifier'] 81 | action_class = actions_registry.get_by_itype( 82 | itype=identifier, action_params=action_dict, 83 | ) 84 | shortcut_name_to_field_name = {f.name: f._attr for f in action_class().fields} 85 | params = { 86 | shortcut_name_to_field_name[p]: WFDeserializer(v).deserialized_data 87 | for p, v in action_dict['WFWorkflowActionParameters'].items() 88 | if p in shortcut_name_to_field_name 89 | } 90 | 91 | return action_class(data=params) 92 | 93 | 94 | class WFDeserializer: 95 | """ 96 | Deserializer for WF fields (from shortcuts plist) 97 | which converts their data to a format acceptable by Actions 98 | """ 99 | 100 | def __init__(self, data) -> None: 101 | self._data = data 102 | 103 | @property 104 | def deserialized_data(self) -> Union[str, List, Dict]: 105 | if not isinstance(self._data, dict): 106 | # todo: check if there are other types 107 | 108 | return self._data 109 | 110 | # based on 'WFSerializationType' from the self._data 111 | # we need to choose a proper class to deserialize it 112 | serialization_to_field_map: Dict[str, Type[WFDeserializer]] = { 113 | 'WFTextTokenString': WFVariableStringField, 114 | 'WFDictionaryFieldValue': WFDictionaryField, 115 | 'WFTextTokenAttachment': WFTextTokenAttachmentField, 116 | 'WFTokenAttachmentParameterState': WFTokenAttachmentParameterStateField, 117 | } 118 | 119 | deserializer = serialization_to_field_map[self._data.get('WFSerializationType')] # type: ignore 120 | 121 | if deserializer: 122 | return deserializer(self._data).deserialized_data 123 | 124 | raise exceptions.UnknownSerializationType( 125 | f'Unknown serialization type: {self._data.get("WFSerializationType")}', 126 | ) 127 | 128 | 129 | class WFTokenAttachmentParameterStateField(WFDeserializer): 130 | def __init__(self, data) -> None: 131 | self._data = data['Value'] 132 | 133 | 134 | class WFTextTokenAttachmentField(WFDeserializer): 135 | @property 136 | def deserialized_data(self) -> str: 137 | value = self._data.get('Value', {}) 138 | field_type = value.get('Type') 139 | 140 | if field_type in SYSTEM_VARIABLES_TYPE_TO_VAR: 141 | return '{{%s}}' % SYSTEM_VARIABLES_TYPE_TO_VAR[field_type] 142 | 143 | if field_type == 'Variable': 144 | return value.get('VariableName') # todo: #2 145 | 146 | raise exceptions.UnknownWFTextTokenAttachment( 147 | f'Unknown token attachment type: {field_type}', 148 | ) 149 | 150 | 151 | class WFDictionaryField(WFDeserializer): 152 | @property 153 | def deserialized_data(self) -> List[Dict[str, Any]]: 154 | result = [] 155 | 156 | for item in self._data['Value']['WFDictionaryFieldValueItems']: 157 | key = WFDeserializer(item['WFKey']).deserialized_data 158 | value = WFDeserializer(item['WFValue']).deserialized_data 159 | result.append({'key': key, 'value': value}) 160 | 161 | return result 162 | 163 | 164 | class WFVariableStringField(WFDeserializer): 165 | """ 166 | Converts wf variable string (dictionary) 167 | 168 | Value 169 | 170 | attachmentsByRange 171 | 172 | {7, 1} 173 | 174 | Type 175 | Variable 176 | VariableName 177 | name 178 | 179 | 180 | string 181 | Hello, ! 182 | 183 | WFSerializationType 184 | WFTextTokenString 185 | 186 | 187 | to a shortcuts-string: 188 | "Hello, {{var}}!" 189 | """ 190 | 191 | @property 192 | def deserialized_data(self) -> str: 193 | ''' 194 | Raises: 195 | shortcuts.exceptions.UnknownVariableError: if variable's type is not supported 196 | ''' 197 | # if this field is a string with variables, 198 | # we need to convert it into our representation 199 | value = self._data['Value'] 200 | value_string = value['string'] 201 | 202 | positions = {} 203 | 204 | # sometimes variables are system (read more near SYSTEM_VARIABLES_TYPE_TO_VAR definition) 205 | # and we need to detect this by checking variable's type 206 | # if it is not supported - raise an exception 207 | supported_types = list(SYSTEM_VARIABLES_TYPE_TO_VAR.keys()) + ['Variable'] 208 | 209 | for variable_range, variable_data in value['attachmentsByRange'].items(): 210 | if variable_data['Type'] not in supported_types: 211 | # it doesn't support magic variables yet 212 | raise exceptions.UnknownVariableError( 213 | f'Unknown variable type: {variable_data["Type"]} (possibly it is a magic variable)', 214 | ) 215 | 216 | variable_type = variable_data['Type'] 217 | 218 | if variable_type == 'Variable': 219 | variable_name = variable_data['VariableName'] 220 | elif variable_type in SYSTEM_VARIABLES_TYPE_TO_VAR: 221 | variable_name = SYSTEM_VARIABLES_TYPE_TO_VAR[variable_type] 222 | 223 | # let's find positions of all variables in the string 224 | position = self._get_position(variable_range) 225 | positions[position] = '{{%s}}' % variable_name 226 | 227 | # and then replace them with '{{variable_name}}' 228 | offset = 0 229 | 230 | for pos, variable in collections.OrderedDict(sorted(positions.items())).items(): 231 | value_string = ( 232 | value_string[: pos + offset] 233 | + variable 234 | + value_string[pos + offset :] # noqa 235 | ) 236 | offset += len(variable) 237 | 238 | return value_string 239 | 240 | def _get_position(self, range_str: str) -> int: 241 | ranges = list(map(lambda x: int(x.strip()), range_str.strip('{} ').split(','))) 242 | 243 | return ranges[0] 244 | -------------------------------------------------------------------------------- /shortcuts/shortcut.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import plistlib 3 | import uuid 4 | from typing import Any, BinaryIO, Dict, List, Type 5 | 6 | from shortcuts import exceptions 7 | from shortcuts.actions import MenuEndAction, MenuItemAction, MenuStartAction 8 | from shortcuts.actions.base import GroupIDField 9 | from shortcuts.dump import BaseDumper, PListDumper, TomlDumper 10 | from shortcuts.loader import BaseLoader, PListLoader, TomlLoader 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | FMT_TOML = 'toml' 16 | FMT_SHORTCUT = 'shortcut' 17 | 18 | 19 | class Shortcut: 20 | def __init__( 21 | self, 22 | name: str = '', 23 | client_release: str = '2.0', 24 | client_version: str = '700', 25 | minimal_client_version: int = 411, 26 | actions: List = None, 27 | ) -> None: 28 | self.name = name 29 | self.client_release = client_release 30 | self.client_version = client_version 31 | self.minimal_client_version = minimal_client_version 32 | self.actions = actions if actions else [] 33 | 34 | @classmethod 35 | def load(cls, file_object: BinaryIO, file_format: str = FMT_TOML) -> 'Shortcut': 36 | ''' 37 | Returns a Shortcut instance from given file_object 38 | 39 | Params: 40 | file_object (BinaryIO) 41 | file_format: format of the string, FMT_TOML by default 42 | ''' 43 | return cls._get_loader_class(file_format).load(file_object) 44 | 45 | @classmethod 46 | def loads(cls, string: str, file_format: str = FMT_TOML) -> 'Shortcut': 47 | ''' 48 | Returns a Shortcut instance from given string 49 | 50 | Params: 51 | string: representation of a shortcut in string 52 | file_format: format of the string, FMT_TOML by default 53 | ''' 54 | return cls._get_loader_class(file_format).loads(string) 55 | 56 | @classmethod 57 | def _get_loader_class(self, file_format: str) -> Type[BaseLoader]: 58 | """Based on file_format returns loader class for the format""" 59 | supported_formats = { 60 | FMT_SHORTCUT: PListLoader, 61 | FMT_TOML: TomlLoader, 62 | } 63 | if file_format in supported_formats: 64 | logger.debug(f'Loading shortcut from file format: {file_format}') 65 | return supported_formats[file_format] 66 | 67 | raise RuntimeError(f'Unknown file_format: {file_format}') 68 | 69 | def dump(self, file_object: BinaryIO, file_format: str = FMT_TOML) -> None: 70 | ''' 71 | Dumps the shortcut instance to file_object 72 | 73 | Params: 74 | file_object (BinaryIO) 75 | file_format: format of the string, FMT_TOML by default 76 | ''' 77 | self._get_dumper_class(file_format)(shortcut=self).dump(file_object) 78 | 79 | def dumps(self, file_format: str = FMT_TOML) -> str: 80 | ''' 81 | Dumps the shortcut instance and returns a string representation 82 | 83 | Params: 84 | file_format: format of the string, FMT_TOML by default 85 | ''' 86 | return self._get_dumper_class(file_format)(shortcut=self).dumps() 87 | 88 | def _get_dumper_class(self, file_format: str) -> Type[BaseDumper]: 89 | """Based on file_format returns dumper class""" 90 | supported_formats = { 91 | FMT_SHORTCUT: PListDumper, 92 | FMT_TOML: TomlDumper, 93 | } 94 | if file_format in supported_formats: 95 | logger.debug(f'Dumping shortcut to file format: {file_format}') 96 | return supported_formats[file_format] 97 | 98 | raise RuntimeError(f'Unknown file_format: {file_format}') 99 | 100 | def _get_actions(self) -> List[str]: 101 | """returns list of all actions""" 102 | self._set_group_ids() 103 | self._set_menu_items() 104 | return [a.dump() for a in self.actions] 105 | 106 | def _set_group_ids(self): 107 | """ 108 | Automatically sets group_id based on WFControlFlowMode param 109 | Uses list as a stack to hold generated group_ids 110 | 111 | Each cycle or condition (if-else, repeat) in Shortcuts app must have group id. 112 | Start and end of the cycle must have the same group_id. To do this, 113 | we use stack to save generated or readed group_id to save it to all actions of the cycle 114 | """ 115 | ids = [] 116 | for action in self.actions: 117 | # if action has GroupIDField, we may need to generate it's value automatically 118 | if not isinstance(getattr(action, 'group_id', None), GroupIDField): 119 | continue 120 | 121 | control_mode = action.default_fields['WFControlFlowMode'] 122 | if control_mode == 0: 123 | # 0 means beginning of the group 124 | group_id = action.data.get('group_id', str(uuid.uuid4())) 125 | action.data['group_id'] = group_id # if wasn't defined 126 | ids.append(group_id) 127 | elif control_mode == 1: 128 | # 1 - else, so we don't need to remove group_id from the stack 129 | # we need to just use the latest one 130 | action.data['group_id'] = ids[-1] 131 | elif control_mode == 2: 132 | # end of the group, we must remove group_id 133 | try: 134 | action.data['group_id'] = ids.pop() 135 | except IndexError: 136 | # if actions are correct, all groups must be compelted 137 | # (group complete if it has start and end actions) 138 | raise exceptions.IncompleteCycleError('Incomplete cycle') 139 | 140 | def _set_menu_items(self): 141 | ''' 142 | Menu consists of many items: 143 | start menu 144 | menu item 1 145 | menu item2 146 | end menu 147 | And start menu must know all items (titles). 148 | So this function iterates over all actions, finds menu items and saves information 149 | about them to a corresponding "start menu" action. 150 | 151 | # todo: move to menu item logic 152 | ''' 153 | menus = [] 154 | for action in self.actions: 155 | if isinstance(action, MenuStartAction): 156 | action.data['menu_items'] = [] 157 | menus.append(action) 158 | elif isinstance(action, MenuItemAction): 159 | menus[-1].data['menu_items'].append(action.data['title']) 160 | elif isinstance(action, MenuEndAction): 161 | try: 162 | menus.pop() 163 | except IndexError: 164 | raise exceptions.IncompleteCycleError('Incomplete menu cycle') 165 | 166 | def _get_import_questions(self) -> List: 167 | # todo: change me 168 | return [] 169 | 170 | def _get_icon(self) -> Dict[str, Any]: 171 | # todo: change me 172 | return { 173 | 'WFWorkflowIconGlyphNumber': 59511, 174 | 'WFWorkflowIconImageData': bytes(b''), 175 | 'WFWorkflowIconStartColor': 431817727, 176 | } 177 | 178 | def _get_input_content_item_classes(self) -> List[str]: 179 | # todo: change me 180 | return [ 181 | 'WFAppStoreAppContentItem', 182 | 'WFArticleContentItem', 183 | 'WFContactContentItem', 184 | 'WFDateContentItem', 185 | 'WFEmailAddressContentItem', 186 | 'WFGenericFileContentItem', 187 | 'WFImageContentItem', 188 | 'WFiTunesProductContentItem', 189 | 'WFLocationContentItem', 190 | 'WFDCMapsLinkContentItem', 191 | 'WFAVAssetContentItem', 192 | 'WFPDFContentItem', 193 | 'WFPhoneNumberContentItem', 194 | 'WFRichTextContentItem', 195 | 'WFSafariWebPageContentItem', 196 | 'WFStringContentItem', 197 | 'WFURLContentItem', 198 | ] 199 | -------------------------------------------------------------------------------- /shortcuts/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | import uuid 4 | from typing import Dict 5 | from urllib.parse import urlparse 6 | from urllib.request import urlopen 7 | 8 | from shortcuts import exceptions 9 | 10 | 11 | def download_shortcut(url: str): 12 | '''Downloads shortcut file if possible and returns a string with plist''' 13 | shortcut_id = _get_shortcut_uuid(url) 14 | shortcut_info = _get_shortcut_info(shortcut_id) 15 | download_url = shortcut_info['fields']['shortcut']['value']['downloadURL'] 16 | response = _make_request(download_url) 17 | 18 | return response.read() 19 | 20 | 21 | def _get_shortcut_uuid(url: str) -> str: 22 | ''' 23 | Returns uuid from shortcut's public URL. 24 | Public url example: https://www.icloud.com/shortcuts/{uuid}/ 25 | 26 | Raises: 27 | shortcuts.exceptions.InvalidShortcutURLError if the "url" parameter is not valid 28 | ''' 29 | 30 | if not is_shortcut_url(url): 31 | raise exceptions.InvalidShortcutURLError('Not a shortcut URL!') 32 | 33 | parsed_url = urlparse(url) 34 | splitted_path = os.path.split(parsed_url.path) 35 | 36 | if splitted_path: 37 | shortcut_id = splitted_path[-1] 38 | try: 39 | uuid.UUID( 40 | shortcut_id 41 | ) # just for validation, raises an error if it's not a valid UUID 42 | except ValueError: 43 | raise exceptions.InvalidShortcutURLError( 44 | f'Can not find shortcut id in "{url}"' 45 | ) 46 | 47 | return shortcut_id 48 | 49 | 50 | def is_shortcut_url(url: str) -> bool: 51 | '''Determines is it a shortcut URL or not''' 52 | parsed_url = urlparse(url) 53 | 54 | if parsed_url.netloc not in ('www.icloud.com', 'icloud.com'): 55 | return False 56 | 57 | if not parsed_url.path.startswith('/shortcuts/'): 58 | return False 59 | 60 | return True 61 | 62 | 63 | def _get_shortcut_info(shortcut_id: str) -> Dict: 64 | ''' 65 | Downloads shortcut information from a public (and not official) API 66 | 67 | Returns: 68 | dictioanry with shortcut information 69 | ''' 70 | url = f'https://www.icloud.com/shortcuts/api/records/{shortcut_id}/' 71 | response = _make_request(url) 72 | 73 | return json.loads(response.read()) 74 | 75 | 76 | def _make_request(url: str): 77 | ''' 78 | Makes HTTP request 79 | 80 | Raises: 81 | RuntimeError if response.status != 200 82 | ''' 83 | response = urlopen(url) 84 | 85 | if response.status != 200: # type: ignore 86 | raise RuntimeError( 87 | f'Can not get shortcut information from API: response code {response.status}' # type: ignore 88 | ) 89 | 90 | return response 91 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/__init__.py -------------------------------------------------------------------------------- /tests/actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/__init__.py -------------------------------------------------------------------------------- /tests/actions/b64/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/b64/__init__.py -------------------------------------------------------------------------------- /tests/actions/b64/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts import Shortcut, FMT_SHORTCUT 2 | from shortcuts.actions import Base64DecodeAction, Base64EncodeAction 3 | 4 | from tests.conftest import ActionTomlLoadsMixin 5 | 6 | 7 | class TestBase64EncodeAction(ActionTomlLoadsMixin): 8 | def test_dumps(self): 9 | action = Base64EncodeAction() 10 | exp_dump = { 11 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.base64encode', 12 | 'WFWorkflowActionParameters': { 13 | 'WFEncodeMode': 'Encode', 14 | } 15 | } 16 | assert action.dump() == exp_dump 17 | 18 | def test_loads_toml(self): 19 | toml = f''' 20 | [[action]] 21 | type = "base64_encode" 22 | ''' 23 | self._assert_toml_loads(toml, Base64EncodeAction, {}) 24 | 25 | 26 | class TestBase64DecodeAction(ActionTomlLoadsMixin): 27 | def test_dumps(self): 28 | action = Base64DecodeAction() 29 | exp_dump = { 30 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.base64encode', 31 | 'WFWorkflowActionParameters': { 32 | 'WFEncodeMode': 'Decode', 33 | } 34 | } 35 | assert action.dump() == exp_dump 36 | 37 | def test_loads_toml(self): 38 | toml = f''' 39 | [[action]] 40 | type = "base64_decode" 41 | ''' 42 | self._assert_toml_loads(toml, Base64DecodeAction, {}) 43 | 44 | 45 | class TestBase64Actions: 46 | def test_loads_plist(self): 47 | plist = ' WFWorkflowActions WFWorkflowActionIdentifier is.workflow.actions.base64encode WFWorkflowActionParameters WFEncodeMode Encode WFWorkflowActionIdentifier is.workflow.actions.base64encode WFWorkflowActionParameters WFEncodeMode Decode WFWorkflowClientRelease 2.0 WFWorkflowClientVersion 700 WFWorkflowIcon WFWorkflowIconGlyphNumber 59511 WFWorkflowIconImageData WFWorkflowIconStartColor 4271458815 WFWorkflowImportQuestions WFWorkflowInputContentItemClasses WFAppStoreAppContentItem WFArticleContentItem WFContactContentItem WFDateContentItem WFEmailAddressContentItem WFGenericFileContentItem WFImageContentItem WFiTunesProductContentItem WFLocationContentItem WFDCMapsLinkContentItem WFAVAssetContentItem WFPDFContentItem WFPhoneNumberContentItem WFRichTextContentItem WFSafariWebPageContentItem WFStringContentItem WFURLContentItem WFWorkflowTypes NCWidget WatchKit ' 48 | 49 | sc = Shortcut.loads(plist, file_format=FMT_SHORTCUT) 50 | 51 | assert isinstance(sc.actions[0], Base64EncodeAction) is True 52 | assert isinstance(sc.actions[1], Base64DecodeAction) is True 53 | -------------------------------------------------------------------------------- /tests/actions/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/base/__init__.py -------------------------------------------------------------------------------- /tests/actions/base/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from shortcuts.actions.base import ( 4 | BaseAction, 5 | BooleanField, 6 | ChoiceField, 7 | FloatField, 8 | IntegerField, 9 | ArrayField, 10 | VariablesField, 11 | ) 12 | 13 | 14 | class TestBaseAction: 15 | def test_get_parameters(self): 16 | base_action = BaseAction() 17 | base_action.itype = '123' 18 | dump = base_action.dump() 19 | 20 | exp_dump = { 21 | 'WFWorkflowActionIdentifier': base_action.itype, 22 | 'WFWorkflowActionParameters': {}, 23 | } 24 | assert dump == exp_dump 25 | 26 | 27 | class TestBooleanField: 28 | def test_boolean_field(self): 29 | f = BooleanField('test') 30 | 31 | assert f.process_value(True) is True 32 | assert f.process_value(False) is False 33 | assert f.process_value('False') is False 34 | assert f.process_value('True') is True 35 | 36 | with pytest.raises(ValueError): 37 | f.process_value('wrong value') 38 | 39 | 40 | class TestChoiceField: 41 | def test_choice_field(self): 42 | choices = ('first', 'second') 43 | f = ChoiceField('test', choices=choices) 44 | 45 | assert f.process_value('first') == 'first' 46 | assert f.process_value('second') == 'second' 47 | 48 | with pytest.raises(ValueError): 49 | assert f.process_value('third') 50 | 51 | def test_choice_field_with_capitalization(self): 52 | choices = ('First', ) 53 | f = ChoiceField('test', choices=choices, capitalize=True) 54 | 55 | assert f.process_value('first') == 'First' 56 | 57 | 58 | class TestFloatField: 59 | def test_field(self): 60 | f = FloatField('t') 61 | 62 | assert f.process_value(4.4) == 4.4 63 | assert f.process_value('15.0') == 15.0 64 | assert type(f.process_value(4)) is float 65 | 66 | with pytest.raises(ValueError): 67 | f.process_value('asd') 68 | 69 | 70 | class TestIntegerField: 71 | def test_field(self): 72 | f = IntegerField('t') 73 | 74 | assert f.process_value(4.4) == 4 75 | assert f.process_value('15') == 15 76 | assert type(f.process_value(4.4)) is int 77 | 78 | with pytest.raises(ValueError): 79 | f.process_value('asd') 80 | 81 | 82 | class TestArrayField: 83 | def test_field(self): 84 | f = ArrayField('t') 85 | 86 | assert f.process_value(['1', '2']) == ['1', '2'] 87 | 88 | with pytest.raises(ValueError): 89 | f.process_value('value') 90 | 91 | 92 | class TestActionWithAskWhenRunField: 93 | def test_action(self): 94 | identifier = 'my.identifier' 95 | field_name = 'WFSomeField' 96 | 97 | class MyAction(BaseAction): 98 | itype = identifier 99 | 100 | my_field = VariablesField(field_name) 101 | 102 | action = MyAction(data={'my_field': '{{ask_when_run}}'}) 103 | 104 | dump = action.dump() 105 | exp_dump = { 106 | 'WFWorkflowActionIdentifier': identifier, 107 | 'WFWorkflowActionParameters': { 108 | field_name: { 109 | 'WFSerializationType': 'WFTextTokenAttachment', 110 | 'Value': { 111 | 'Type': 'Ask', 112 | }, 113 | }, 114 | }, 115 | } 116 | assert dump == exp_dump 117 | 118 | 119 | class TestVariablesField: 120 | @pytest.mark.parametrize('variable, exp_data', [ 121 | ( 122 | '{{var}}', 123 | {'attachmentsByRange': {'{0, 1}': {'Type': 'Variable', 'VariableName': 'var'}}, 'string': ''}, 124 | ), 125 | ( 126 | '{{var1}} + {{var2}}', 127 | { 128 | 'attachmentsByRange': { 129 | '{0, 1}': {'Type': 'Variable', 'VariableName': 'var1'}, 130 | '{3, 1}': {'Type': 'Variable', 'VariableName': 'var2'}, 131 | }, 132 | 'string': ' + ', 133 | }, 134 | ), 135 | ]) 136 | def test_field_with_variables(self, variable, exp_data): 137 | f = VariablesField('') 138 | 139 | exp_data = { 140 | 'Value': exp_data, 141 | 'WFSerializationType': 'WFTextTokenString', 142 | } 143 | 144 | assert f.process_value(variable) == exp_data 145 | 146 | @pytest.mark.parametrize('variable, exp_data', [ 147 | ( 148 | '{{clipboard}}', 149 | {'Type': 'Clipboard'}, 150 | ), 151 | ]) 152 | def test_field_with_variables_with_token_only(self, variable, exp_data): 153 | f = VariablesField('') 154 | 155 | exp_data = { 156 | 'Value': exp_data, 157 | 'WFSerializationType': 'WFTextTokenAttachment', 158 | } 159 | 160 | assert f.process_value(variable) == exp_data 161 | -------------------------------------------------------------------------------- /tests/actions/conditions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/conditions/__init__.py -------------------------------------------------------------------------------- /tests/actions/date/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/date/__init__.py -------------------------------------------------------------------------------- /tests/actions/date/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import DetectDateAction, GetTimeBetweenDates 2 | 3 | from tests.conftest import SimpleBaseDumpsLoadsTest 4 | 5 | 6 | class TestDetectDateAction(SimpleBaseDumpsLoadsTest): 7 | action_class = DetectDateAction 8 | itype = 'is.workflow.actions.detect.date' 9 | toml = '[[action]]\ntype = "detect_date"' 10 | action_xml = ''' 11 | 12 | WFWorkflowActionIdentifier 13 | is.workflow.actions.detect.date 14 | WFWorkflowActionParameters 15 | 16 | 17 | 18 | ''' 19 | 20 | 21 | class TestGetTimeBetweenDates(SimpleBaseDumpsLoadsTest): 22 | action_class = GetTimeBetweenDates 23 | itype = 'is.workflow.actions.gettimebetweendates' 24 | 25 | dump_data = {'units': 'Seconds'} 26 | dump_params = { 27 | 'WFTimeUntilUnit': 'Seconds' 28 | } 29 | 30 | toml = '[[action]]\ntype = "get_time_between_dates"\nunits = "Days"' 31 | exp_toml_params = {'units': 'Days'} 32 | 33 | action_xml = ''' 34 | 35 | WFWorkflowActionIdentifier 36 | is.workflow.actions.gettimebetweendates 37 | WFWorkflowActionParameters 38 | 39 | WFTimeUntilUnit 40 | Minutes 41 | 42 | 43 | ''' 44 | exp_xml_params = {'units': 'Minutes'} 45 | -------------------------------------------------------------------------------- /tests/actions/device/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/device/__init__.py -------------------------------------------------------------------------------- /tests/actions/device/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import ( 2 | GetBatteryLevelAction, 3 | GetIPAddressAction, 4 | GetDeviceDetailsAction, 5 | SetAirplaneModeAction, 6 | SetBluetoothAction, 7 | SetLowPowerModeAction, 8 | SetMobileDataAction, 9 | SetWiFiAction, 10 | SetTorchAction, 11 | SetDoNotDisturbAction, 12 | SetBrightnessAction, 13 | SetVolumeAction, 14 | ) 15 | 16 | from tests.conftest import ActionTomlLoadsMixin 17 | 18 | 19 | class TestGetBatteryLevelAction(ActionTomlLoadsMixin): 20 | def test_dumps(self): 21 | action = GetBatteryLevelAction() 22 | exp_dump = { 23 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.getbatterylevel', 24 | 'WFWorkflowActionParameters': {} 25 | } 26 | assert action.dump() == exp_dump 27 | 28 | def test_loads_toml(self): 29 | toml = ''' 30 | [[action]] 31 | type = "get_battery_level" 32 | ''' 33 | self._assert_toml_loads(toml, GetBatteryLevelAction, {}) 34 | 35 | 36 | class TestGetIPAddressAction(ActionTomlLoadsMixin): 37 | def test_dumps(self): 38 | action = GetIPAddressAction(data={'source': 'Global', 'address_type': 'IPv6'}) 39 | exp_dump = { 40 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.getipaddress', 41 | 'WFWorkflowActionParameters': { 42 | 'WFIPAddressTypeOption': 'IPv6', 43 | 'WFIPAddressSourceOption': 'Global', 44 | }, 45 | } 46 | assert action.dump() == exp_dump 47 | 48 | def test_loads_toml(self): 49 | toml = ''' 50 | [[action]] 51 | type = "get_ip_address" 52 | source = "Local" 53 | address_type = "IPv4" 54 | ''' 55 | self._assert_toml_loads(toml, GetIPAddressAction, {'source': 'Local', 'address_type': 'IPv4'}) 56 | 57 | 58 | class TestGetDeviceDetailsAction(ActionTomlLoadsMixin): 59 | def test_dumps(self): 60 | action = GetDeviceDetailsAction(data={'detail': 'Device Name'}) 61 | exp_dump = { 62 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.getdevicedetails', 63 | 'WFWorkflowActionParameters': {'WFDeviceDetail': 'Device Name'}, 64 | } 65 | assert action.dump() == exp_dump 66 | 67 | def test_loads_toml(self): 68 | details = ( 69 | 'Device Name', 'Device Model', 'System Version', 70 | 'Screen Width', 'Screen Height', 'Current Volume', 71 | 'Current Brightness', 72 | ) 73 | for detail_name in details: 74 | toml = f''' 75 | [[action]] 76 | type = "get_device_details" 77 | detail = "{detail_name}" 78 | ''' 79 | self._assert_toml_loads(toml, GetDeviceDetailsAction, {'detail': detail_name}) 80 | 81 | 82 | class TestBooleanSwitchAction(ActionTomlLoadsMixin): 83 | def _assert_dump(self, cls, expected_id): 84 | for mode in (True, False): 85 | action = cls(data={'on': mode}) 86 | exp_dump = { 87 | 'WFWorkflowActionIdentifier': expected_id, 88 | 'WFWorkflowActionParameters': {'OnValue': mode}, 89 | } 90 | assert action.dump() == exp_dump 91 | 92 | def _assert_loads_toml(self, cls, keyword): 93 | for mode in (True, False): 94 | toml = f''' 95 | [[action]] 96 | type = "{keyword}" 97 | on = {str(mode).lower()} 98 | ''' 99 | self._assert_toml_loads(toml, cls, {'on': mode}) 100 | 101 | 102 | class TestSetAirplaneModeAction(TestBooleanSwitchAction): 103 | def test_dumps(self): 104 | self._assert_dump(SetAirplaneModeAction, 'is.workflow.actions.airplanemode.set') 105 | 106 | def test_loads_toml(self): 107 | self._assert_loads_toml(SetAirplaneModeAction, 'set_airplane_mode') 108 | 109 | 110 | class TestSetBluetoothAction(TestBooleanSwitchAction): 111 | def test_dumps(self): 112 | self._assert_dump(SetBluetoothAction, 'is.workflow.actions.bluetooth.set') 113 | 114 | def test_loads_toml(self): 115 | self._assert_loads_toml(SetBluetoothAction, 'set_bluetooth') 116 | 117 | 118 | class TestSetMobileDataAction(TestBooleanSwitchAction): 119 | def test_dumps(self): 120 | self._assert_dump(SetMobileDataAction, 'is.workflow.actions.cellulardata.set') 121 | 122 | def test_loads_toml(self): 123 | self._assert_loads_toml(SetMobileDataAction, 'set_mobile_data') 124 | 125 | 126 | class TestSetLowPowerModeAction(TestBooleanSwitchAction): 127 | def test_dumps(self): 128 | self._assert_dump(SetLowPowerModeAction, 'is.workflow.actions.lowpowermode.set') 129 | 130 | def test_loads_toml(self): 131 | self._assert_loads_toml(SetLowPowerModeAction, 'set_low_power_mode') 132 | 133 | 134 | class TestSetWiFiAction(TestBooleanSwitchAction): 135 | def test_dumps(self): 136 | self._assert_dump(SetWiFiAction, 'is.workflow.actions.wifi.set') 137 | 138 | def test_loads_toml(self): 139 | self._assert_loads_toml(SetWiFiAction, 'set_wifi') 140 | 141 | 142 | class TestSetTorchAction(ActionTomlLoadsMixin): 143 | def test_dumps(self): 144 | for mode in ('Off', 'On', 'Toggle'): 145 | action = SetTorchAction(data={'mode': mode}) 146 | exp_dump = { 147 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.flashlight', 148 | 'WFWorkflowActionParameters': {'WFFlashlightSetting': mode}, 149 | } 150 | assert action.dump() == exp_dump 151 | 152 | def test_loads_toml(self): 153 | modes = ('Off', 'On', 'Toggle') 154 | for mode in modes: 155 | toml = f''' 156 | [[action]] 157 | type = "set_torch" 158 | mode = "{mode}" 159 | ''' 160 | self._assert_toml_loads(toml, SetTorchAction, {'mode': mode}) 161 | 162 | 163 | class TestSetDoNotDisturbAction(ActionTomlLoadsMixin): 164 | def test_dumps(self): 165 | for mode in (True, False): 166 | action = SetDoNotDisturbAction(data={'enabled': mode}) 167 | exp_dump = { 168 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.dnd.set', 169 | 'WFWorkflowActionParameters': {'Enabled': mode, 'AssertionType': 'Turned Off'}, 170 | } 171 | assert action.dump() == exp_dump 172 | 173 | def test_loads_toml(self): 174 | for mode in (True, False): 175 | toml = f''' 176 | [[action]] 177 | type = "set_do_not_disturb" 178 | enabled = {str(mode).lower()} 179 | ''' 180 | self._assert_toml_loads(toml, SetDoNotDisturbAction, {'enabled': mode}) 181 | 182 | 183 | class TestSetVolumeAction(ActionTomlLoadsMixin): 184 | def test_dumps(self): 185 | level = 87.56 186 | action = SetVolumeAction(data={'level': level}) 187 | exp_dump = { 188 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.setvolume', 189 | 'WFWorkflowActionParameters': {'WFVolume': level}, 190 | } 191 | assert action.dump() == exp_dump 192 | 193 | def test_loads_toml(self): 194 | toml = ''' 195 | [[action]] 196 | type = "set_volume" 197 | level = 51.10 198 | ''' 199 | self._assert_toml_loads(toml, SetVolumeAction, {'level': 51.10}) 200 | 201 | 202 | class TestSetBrightnessAction(ActionTomlLoadsMixin): 203 | def test_dumps(self): 204 | level = 87.56 205 | action = SetBrightnessAction(data={'level': level}) 206 | exp_dump = { 207 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.setbrightness', 208 | 'WFWorkflowActionParameters': {'WFBrightness': level}, 209 | } 210 | assert action.dump() == exp_dump 211 | 212 | def test_loads_toml(self): 213 | toml = ''' 214 | [[action]] 215 | type = "set_brightness" 216 | level = 55.21 217 | ''' 218 | self._assert_toml_loads(toml, SetBrightnessAction, {'level': 55.21}) 219 | -------------------------------------------------------------------------------- /tests/actions/dictionary/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/dictionary/__init__.py -------------------------------------------------------------------------------- /tests/actions/dictionary/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import DictionaryAction, SetDictionaryValueAction, GetDictionaryFromInputAction 2 | from shortcuts import Shortcut, FMT_SHORTCUT 3 | 4 | from tests.conftest import ActionTomlLoadsMixin, SimpleBaseDumpsLoadsTest 5 | 6 | 7 | class TestDictionaryAction: 8 | def test_dump(self): 9 | data = { 10 | 'items': [ 11 | {'key': 'k1', 'value': 'v1'}, 12 | {'key': 'k2', 'value': '{{var}}'}, 13 | ], 14 | } 15 | action = DictionaryAction(data=data) 16 | 17 | exp_dump = { 18 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.dictionary', 19 | 'WFWorkflowActionParameters': { 20 | 'WFItems': { 21 | 'Value': { 22 | 'WFDictionaryFieldValueItems': [ 23 | { 24 | 'WFItemType': 0, 25 | 'WFKey': { 26 | 'Value': {'attachmentsByRange': {}, 'string': 'k1'}, 27 | 'WFSerializationType': 'WFTextTokenString', 28 | }, 29 | 'WFValue': { 30 | 'Value': {'attachmentsByRange': {}, 'string': 'v1'}, 31 | 'WFSerializationType': 'WFTextTokenString', 32 | } 33 | }, 34 | { 35 | 'WFItemType': 0, 36 | 'WFKey': { 37 | 'Value': {'attachmentsByRange': {}, 'string': 'k2'}, 38 | 'WFSerializationType': 'WFTextTokenString', 39 | }, 40 | 'WFValue': { 41 | 'Value': { 42 | 'attachmentsByRange': { 43 | '{0, 1}': {'Type': 'Variable', 'VariableName': 'var'} 44 | }, 45 | 'string': '', 46 | }, 47 | 'WFSerializationType': 'WFTextTokenString', 48 | } 49 | } 50 | ] 51 | }, 52 | 'WFSerializationType': 'WFDictionaryFieldValue', 53 | }, 54 | }, 55 | } 56 | assert action.dump() == exp_dump 57 | 58 | 59 | class TestShortcutWithDictionary: 60 | toml_string = ''' 61 | [[action]] 62 | type = "dictionary" 63 | 64 | [[action.items]] 65 | key = "some key" 66 | value = "some value" 67 | 68 | [[action.items]] 69 | key = "another key" 70 | value = "{{x}}" 71 | ''' 72 | 73 | def test_loads_from_toml(self): 74 | sc = Shortcut.loads(self.toml_string) 75 | 76 | assert len(sc.actions) == 1 77 | 78 | action = sc.actions[0] 79 | assert isinstance(action, DictionaryAction) is True 80 | 81 | exp_data = { 82 | 'items': [ 83 | {'key': 'some key', 'value': 'some value'}, 84 | {'key': 'another key', 'value': '{{x}}'}, 85 | ], 86 | } 87 | assert action.data == exp_data 88 | 89 | def test_dumps_to_plist(self): 90 | sc = Shortcut.loads(self.toml_string) 91 | dump = sc.dumps(file_format=FMT_SHORTCUT) 92 | 93 | exp_dump = '\n\n\n\n\tWFWorkflowActions\n\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.dictionary\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tWFItems\n\t\t\t\t\n\t\t\t\t\tValue\n\t\t\t\t\t\n\t\t\t\t\t\tWFDictionaryFieldValueItems\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFItemType\n\t\t\t\t\t\t\t\t0\n\t\t\t\t\t\t\t\tWFKey\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\tsome key\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFValue\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\tsome value\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFItemType\n\t\t\t\t\t\t\t\t0\n\t\t\t\t\t\t\t\tWFKey\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\tanother key\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFValue\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{0, 1}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tType\n\t\t\t\t\t\t\t\t\t\t\t\tVariable\n\t\t\t\t\t\t\t\t\t\t\t\tVariableName\n\t\t\t\t\t\t\t\t\t\t\t\tx\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tWFSerializationType\n\t\t\t\t\tWFDictionaryFieldValue\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\tWFWorkflowClientRelease\n\t2.0\n\tWFWorkflowClientVersion\n\t700\n\tWFWorkflowIcon\n\t\n\t\tWFWorkflowIconGlyphNumber\n\t\t59511\n\t\tWFWorkflowIconImageData\n\t\t\n\t\t\n\t\tWFWorkflowIconStartColor\n\t\t431817727\n\t\n\tWFWorkflowImportQuestions\n\t\n\tWFWorkflowInputContentItemClasses\n\t\n\t\tWFAppStoreAppContentItem\n\t\tWFArticleContentItem\n\t\tWFContactContentItem\n\t\tWFDateContentItem\n\t\tWFEmailAddressContentItem\n\t\tWFGenericFileContentItem\n\t\tWFImageContentItem\n\t\tWFiTunesProductContentItem\n\t\tWFLocationContentItem\n\t\tWFDCMapsLinkContentItem\n\t\tWFAVAssetContentItem\n\t\tWFPDFContentItem\n\t\tWFPhoneNumberContentItem\n\t\tWFRichTextContentItem\n\t\tWFSafariWebPageContentItem\n\t\tWFStringContentItem\n\t\tWFURLContentItem\n\t\n\tWFWorkflowTypes\n\t\n\t\tNCWidget\n\t\tWatchKit\n\t\n\n\n' 94 | assert dump == exp_dump 95 | 96 | 97 | class TestSetDictionaryValueAction(ActionTomlLoadsMixin): 98 | def test_dumps(self): 99 | key = 'key1' 100 | value = 'value1' 101 | action = SetDictionaryValueAction(data={'key': key, 'value': value}) 102 | exp_dump = { 103 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.setvalueforkey', 104 | 'WFWorkflowActionParameters': { 105 | 'WFDictionaryKey': { 106 | 'Value': {'attachmentsByRange': {}, 'string': key}, 107 | 'WFSerializationType': 'WFTextTokenString', 108 | }, 109 | 'WFDictionaryValue': { 110 | 'Value': {'attachmentsByRange': {}, 'string': value}, 111 | 'WFSerializationType': 'WFTextTokenString', 112 | }, 113 | } 114 | } 115 | assert action.dump() == exp_dump 116 | 117 | def test_loads_toml(self): 118 | key = 'key2' 119 | value = 'value2' 120 | toml = f''' 121 | [[action]] 122 | type = "set_value_for_key" 123 | key = "{key}" 124 | value = "{value}" 125 | ''' 126 | self._assert_toml_loads(toml, SetDictionaryValueAction, {'key': key, 'value': value}) 127 | 128 | 129 | class TestGetDictionaryFromInputAction(SimpleBaseDumpsLoadsTest): 130 | action_class = GetDictionaryFromInputAction 131 | itype = 'is.workflow.actions.detect.dictionary' 132 | toml = '[[action]]\ntype = "get_dictionary"' 133 | action_xml = ''' 134 | 135 | WFWorkflowActionIdentifier 136 | is.workflow.actions.detect.dictionary 137 | WFWorkflowActionParameters 138 | 139 | 140 | ''' 141 | -------------------------------------------------------------------------------- /tests/actions/files/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/files/__init__.py -------------------------------------------------------------------------------- /tests/actions/files/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import AppendFileAction 2 | 3 | from tests.conftest import SimpleBaseDumpsLoadsTest 4 | 5 | 6 | class TestAppendFileAction(SimpleBaseDumpsLoadsTest): 7 | action_class = AppendFileAction 8 | itype = 'is.workflow.actions.file.append' 9 | 10 | dump_data = {'path': '/123'} 11 | dump_params = { 12 | 'WFFilePath': { 13 | 'Value': { 14 | 'attachmentsByRange': {}, 'string': '/123' 15 | }, 16 | 'WFSerializationType': 'WFTextTokenString', 17 | }, 18 | } 19 | 20 | toml = ''' 21 | [[action]] 22 | type = "append_file" 23 | path = "{{db_dir}}/{{db_name}}.txt" 24 | ''' 25 | exp_toml_params = {'path': '{{db_dir}}/{{db_name}}.txt'} 26 | 27 | action_xml = ''' 28 | 29 | WFWorkflowActionIdentifier 30 | is.workflow.actions.file.append 31 | WFWorkflowActionParameters 32 | 33 | WFFilePath 34 | 35 | Value 36 | 37 | attachmentsByRange 38 | 39 | {0, 1} 40 | 41 | Type 42 | Variable 43 | VariableName 44 | db_dir 45 | 46 | {2, 1} 47 | 48 | Type 49 | Variable 50 | VariableName 51 | db_name 52 | 53 | 54 | string 55 | /.txt 56 | 57 | WFSerializationType 58 | WFTextTokenString 59 | 60 | 61 | 62 | ''' 63 | exp_xml_params = {'path': '{{db_dir}}/{{db_name}}.txt'} 64 | -------------------------------------------------------------------------------- /tests/actions/input/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/input/__init__.py -------------------------------------------------------------------------------- /tests/actions/input/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import GetClipboardAction 2 | 3 | from tests.conftest import ActionTomlLoadsMixin 4 | 5 | 6 | class TestGetClipboardAction(ActionTomlLoadsMixin): 7 | def test_dumps(self): 8 | action = GetClipboardAction() 9 | exp_dump = { 10 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.getclipboard', 11 | 'WFWorkflowActionParameters': {} 12 | } 13 | assert action.dump() == exp_dump 14 | 15 | def test_loads_toml(self): 16 | toml = ''' 17 | [[action]] 18 | type = "get_clipboard" 19 | ''' 20 | self._assert_toml_loads(toml, GetClipboardAction, {}) 21 | -------------------------------------------------------------------------------- /tests/actions/menu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/menu/__init__.py -------------------------------------------------------------------------------- /tests/actions/menu/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts import Shortcut, FMT_SHORTCUT, actions 2 | 3 | 4 | class TestMenuLoads: 5 | def test_loads_from_plist(self): 6 | plist = ' WFWorkflowActions WFWorkflowActionIdentifier is.workflow.actions.choosefrommenu WFWorkflowActionParameters GroupingIdentifier 76C3AEC3-29EF-4FC8-9744-1CF34D07CA54 WFControlFlowMode 0 WFMenuItems F S T WFWorkflowActionIdentifier is.workflow.actions.choosefrommenu WFWorkflowActionParameters GroupingIdentifier 76C3AEC3-29EF-4FC8-9744-1CF34D07CA54 WFControlFlowMode 1 WFMenuItemTitle F WFWorkflowActionIdentifier is.workflow.actions.choosefrommenu WFWorkflowActionParameters GroupingIdentifier 76C3AEC3-29EF-4FC8-9744-1CF34D07CA54 WFControlFlowMode 1 WFMenuItemTitle S WFWorkflowActionIdentifier is.workflow.actions.choosefrommenu WFWorkflowActionParameters GroupingIdentifier 76C3AEC3-29EF-4FC8-9744-1CF34D07CA54 WFControlFlowMode 1 WFMenuItemTitle T WFWorkflowActionIdentifier is.workflow.actions.choosefrommenu WFWorkflowActionParameters GroupingIdentifier 76C3AEC3-29EF-4FC8-9744-1CF34D07CA54 WFControlFlowMode 2 WFWorkflowClientRelease 2.0 WFWorkflowClientVersion 700 WFWorkflowIcon WFWorkflowIconGlyphNumber 59511 WFWorkflowIconImageData WFWorkflowIconStartColor 3679049983 WFWorkflowImportQuestions WFWorkflowInputContentItemClasses WFAppStoreAppContentItem WFArticleContentItem WFContactContentItem WFDateContentItem WFEmailAddressContentItem WFGenericFileContentItem WFImageContentItem WFiTunesProductContentItem WFLocationContentItem WFDCMapsLinkContentItem WFAVAssetContentItem WFPDFContentItem WFPhoneNumberContentItem WFRichTextContentItem WFSafariWebPageContentItem WFStringContentItem WFURLContentItem WFWorkflowTypes NCWidget WatchKit ' 7 | 8 | sc = Shortcut.loads(plist, file_format=FMT_SHORTCUT) 9 | 10 | assert isinstance(sc.actions[0], actions.MenuStartAction) is True 11 | assert isinstance(sc.actions[1], actions.MenuItemAction) is True 12 | assert isinstance(sc.actions[2], actions.MenuItemAction) is True 13 | assert isinstance(sc.actions[3], actions.MenuItemAction) is True 14 | assert isinstance(sc.actions[4], actions.MenuEndAction) is True 15 | -------------------------------------------------------------------------------- /tests/actions/numbers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/numbers/__init__.py -------------------------------------------------------------------------------- /tests/actions/numbers/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import NumberAction 2 | 3 | from tests.conftest import ActionTomlLoadsMixin 4 | 5 | 6 | class TestNumberAction(ActionTomlLoadsMixin): 7 | def test_dumps(self): 8 | number = 0.56 9 | action = NumberAction(data={'number': number}) 10 | exp_dump = { 11 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.number', 12 | 'WFWorkflowActionParameters': { 13 | 'WFNumberActionNumber': number, 14 | } 15 | } 16 | assert action.dump() == exp_dump 17 | 18 | def test_loads_toml(self): 19 | number = 15.5 20 | toml = f''' 21 | [[action]] 22 | type = "number" 23 | number = {number} 24 | ''' 25 | self._assert_toml_loads(toml, NumberAction, {'number': number}) 26 | -------------------------------------------------------------------------------- /tests/actions/out/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/out/__init__.py -------------------------------------------------------------------------------- /tests/actions/out/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import ShowResultAction, ShowAlertAction, SetClipboardAction, NotificationAction 2 | 3 | from tests.conftest import ActionTomlLoadsMixin 4 | 5 | 6 | class TestShowResultAction: 7 | def test_get_parameters(self): 8 | text = '{{v1}}##{{v2}}' 9 | action = ShowResultAction(data={'text': text}) 10 | 11 | dump = action._get_parameters() 12 | 13 | exp_dump = { 14 | 'Text': { 15 | 'Value': { 16 | 'attachmentsByRange': { 17 | '{0, 1}': {'Type': 'Variable', 'VariableName': 'v1'}, 18 | '{2, 1}': {'Type': 'Variable', 'VariableName': 'v2'}, 19 | }, 20 | 'string': '##', 21 | }, 22 | 'WFSerializationType': 'WFTextTokenString', 23 | }, 24 | } 25 | assert dump == exp_dump 26 | 27 | 28 | class TestShowAlertAction: 29 | def test_get_parameters(self): 30 | title = 'some title' 31 | text = 'some text' 32 | show_cancel_button = True 33 | action = ShowAlertAction( 34 | dict(title=title, text=text, show_cancel_button=show_cancel_button) 35 | ) 36 | 37 | dump = action._get_parameters() 38 | 39 | exp_dump = { 40 | 'WFAlertActionCancelButtonShown': show_cancel_button, 41 | 'WFAlertActionMessage': { 42 | 'Value': {'attachmentsByRange': {}, 'string': text}, 43 | 'WFSerializationType': 'WFTextTokenString', 44 | }, 45 | 'WFAlertActionTitle': { 46 | 'Value': {'attachmentsByRange': {}, 'string': title}, 47 | 'WFSerializationType': 'WFTextTokenString', 48 | }, 49 | } 50 | assert dump == exp_dump 51 | 52 | 53 | class TestSetClipboardAction(ActionTomlLoadsMixin): 54 | def test_dumps(self): 55 | action = SetClipboardAction() 56 | exp_dump = { 57 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.setclipboard', 58 | 'WFWorkflowActionParameters': {} 59 | } 60 | assert action.dump() == exp_dump 61 | 62 | def test_dumps_with_parameters(self): 63 | expiration_date = 'Tomorrow at 2am' 64 | local_only = True 65 | action = SetClipboardAction(data={'local_only': local_only, 'expiration_date': expiration_date}) 66 | exp_dump = { 67 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.setclipboard', 68 | 'WFWorkflowActionParameters': { 69 | 'WFLocalOnly': local_only, 70 | 'WFExpirationDate': expiration_date, 71 | } 72 | } 73 | assert action.dump() == exp_dump 74 | 75 | def test_loads_toml(self): 76 | toml = ''' 77 | [[action]] 78 | type = "set_clipboard" 79 | ''' 80 | self._assert_toml_loads(toml, SetClipboardAction, {}) 81 | 82 | def test_loads_toml_with_parameters(self): 83 | toml = ''' 84 | [[action]] 85 | type = "set_clipboard" 86 | expiration_date = "tomorrow" 87 | local_only = false 88 | ''' 89 | self._assert_toml_loads( 90 | toml, 91 | SetClipboardAction, 92 | {'local_only': False, 'expiration_date': 'tomorrow'}, 93 | ) 94 | 95 | 96 | class TestNotificationAction(ActionTomlLoadsMixin): 97 | def test_dumps(self): 98 | text = 't' 99 | title = 'tt' 100 | play_sound = True 101 | action = NotificationAction(data={ 102 | 'text': text, 103 | 'title': title, 104 | 'play_sound': play_sound, 105 | }) 106 | exp_dump = { 107 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.notification', 108 | 'WFWorkflowActionParameters': { 109 | 'WFNotificationActionSound': play_sound, 110 | 'WFNotificationActionBody': { 111 | 'Value': {'attachmentsByRange': {}, 'string': text}, 112 | 'WFSerializationType': 'WFTextTokenString', 113 | }, 114 | 'WFNotificationActionTitle': { 115 | 'Value': {'attachmentsByRange': {}, 'string': title}, 116 | 'WFSerializationType': 'WFTextTokenString', 117 | }, 118 | }, 119 | } 120 | 121 | assert action.dump() == exp_dump 122 | 123 | def test_loads_toml(self): 124 | toml = ''' 125 | [[action]] 126 | type = "notification" 127 | title = "title" 128 | text = "text" 129 | play_sound = true 130 | ''' 131 | self._assert_toml_loads( 132 | toml, 133 | NotificationAction, 134 | {'play_sound': True, 'text': 'text', 'title': 'title'}, 135 | ) 136 | -------------------------------------------------------------------------------- /tests/actions/photo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/photo/__init__.py -------------------------------------------------------------------------------- /tests/actions/photo/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions import ( 2 | CameraAction, 3 | GetLastPhotoAction, 4 | SelectPhotoAction, 5 | ) 6 | 7 | 8 | class BasePhotoTest: 9 | def test_get_parameters(self): 10 | action = self.action_class() 11 | dump = action._get_parameters() 12 | assert dump == {} 13 | 14 | 15 | class TestCameraAction(BasePhotoTest): 16 | action_class = CameraAction 17 | 18 | 19 | class TestGetLastPhotoAction(BasePhotoTest): 20 | action_class = GetLastPhotoAction 21 | 22 | 23 | class TestSelectPhotoAction(BasePhotoTest): 24 | action_class = SelectPhotoAction 25 | -------------------------------------------------------------------------------- /tests/actions/registry/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/registry/__init__.py -------------------------------------------------------------------------------- /tests/actions/registry/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts.actions.base import BaseAction 2 | from shortcuts.actions.registry import ActionsRegistry 3 | 4 | 5 | class SimpleTestAction(BaseAction): 6 | itype = 'sh.aleks.simple_action' 7 | keyword = 'simple_action' 8 | 9 | 10 | class IfTestAction(BaseAction): 11 | itype = 'sh.aleks.if' 12 | keyword = 'if' 13 | 14 | _additional_identifier_field = 'WFControlFlowMode' 15 | 16 | default_fields = { 17 | 'WFControlFlowMode': 0, 18 | } 19 | 20 | 21 | class ElseTestAction(BaseAction): 22 | itype = 'sh.aleks.if' 23 | keyword = 'else' 24 | 25 | _additional_identifier_field = 'WFControlFlowMode' 26 | 27 | default_fields = { 28 | 'WFControlFlowMode': 1, 29 | } 30 | 31 | 32 | class TestActionsRegistry: 33 | def test_init(self): 34 | registry = ActionsRegistry() 35 | 36 | assert registry._keyword_to_action_map == {} 37 | assert registry._itype_to_action_map == {} 38 | 39 | def test_register_simple_action(self): 40 | registry = ActionsRegistry() 41 | 42 | registry.register_action(SimpleTestAction) 43 | 44 | # check that get methods work 45 | assert registry.get_by_itype(SimpleTestAction.itype, action_params=None) == SimpleTestAction 46 | assert registry.get_by_keyword(SimpleTestAction.keyword) == SimpleTestAction 47 | 48 | # check internal structures 49 | assert registry._keyword_to_action_map == { 50 | 'simple_action': SimpleTestAction, 51 | } 52 | assert registry._itype_to_action_map == { 53 | 'sh.aleks.simple_action': { 54 | 'type': 'class', 55 | 'value': SimpleTestAction, 56 | } 57 | } 58 | 59 | def test_register_complex_action(self): 60 | # check registration of complex action: two actions with the same itype 61 | # but different fields 62 | registry = ActionsRegistry() 63 | 64 | registry.register_action(IfTestAction) 65 | registry.register_action(ElseTestAction) 66 | 67 | # check that get methods work 68 | params = { 69 | 'WFWorkflowActionParameters': { 70 | 'WFControlFlowMode': IfTestAction.default_fields['WFControlFlowMode'], 71 | } 72 | } 73 | assert registry.get_by_itype(IfTestAction.itype, action_params=params) == IfTestAction 74 | assert registry.get_by_keyword(IfTestAction.keyword) == IfTestAction 75 | 76 | params = { 77 | 'WFWorkflowActionParameters': { 78 | 'WFControlFlowMode': ElseTestAction.default_fields['WFControlFlowMode'], 79 | } 80 | } 81 | assert registry.get_by_itype(ElseTestAction.itype, action_params=params) == ElseTestAction 82 | assert registry.get_by_keyword(ElseTestAction.keyword) == ElseTestAction 83 | 84 | # check internal structures 85 | assert registry._keyword_to_action_map == { 86 | 'if': IfTestAction, 87 | 'else': ElseTestAction, 88 | } 89 | assert registry._itype_to_action_map == { 90 | 'sh.aleks.if': { 91 | 'type': 'property_based', 92 | 'field': 'WFControlFlowMode', 93 | 'value': { 94 | 0: IfTestAction, 95 | 1: ElseTestAction, 96 | }, 97 | }, 98 | } 99 | 100 | def test_actions(self): 101 | registry = ActionsRegistry() 102 | 103 | # before it's empty 104 | assert registry.actions == [] 105 | 106 | registry.register_action(SimpleTestAction) 107 | 108 | assert registry.actions == [SimpleTestAction] 109 | 110 | def test_create_class_field_if_needed(self): 111 | registry = ActionsRegistry() 112 | assert registry._itype_to_action_map == {} 113 | 114 | registry._create_class_field_if_needed(IfTestAction) 115 | 116 | assert registry._itype_to_action_map == { 117 | 'sh.aleks.if': { 118 | 'type': 'property_based', 119 | 'field': 'WFControlFlowMode', 120 | 'value': {}, 121 | }, 122 | } 123 | -------------------------------------------------------------------------------- /tests/actions/scripting/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/scripting/__init__.py -------------------------------------------------------------------------------- /tests/actions/scripting/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import mock 3 | 4 | from shortcuts.actions import ( 5 | NothingAction, 6 | SetItemNameAction, 7 | ViewContentGraphAction, 8 | ContinueInShortcutAppAction, 9 | DelayAction, 10 | WaitToReturnAction, 11 | RepeatEachStartAction, 12 | RepeatEachEndAction, 13 | HashAction, 14 | GetMyShortcutsAction, 15 | RunShortcutAction, 16 | ChooseFromListAction, 17 | OpenAppAction 18 | ) 19 | from shortcuts.actions.scripting import HASH_CHOICES 20 | from shortcuts import Shortcut, FMT_SHORTCUT 21 | 22 | from tests.conftest import ActionTomlLoadsMixin, SimpleBaseDumpsLoadsTest 23 | 24 | 25 | class TestNothingAction(ActionTomlLoadsMixin): 26 | def test_dumps(self): 27 | action = NothingAction() 28 | exp_dump = { 29 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.nothing', 30 | 'WFWorkflowActionParameters': {} 31 | } 32 | assert action.dump() == exp_dump 33 | 34 | def test_loads_toml(self): 35 | toml = ''' 36 | [[action]] 37 | type = "nothing" 38 | ''' 39 | self._assert_toml_loads(toml, NothingAction, {}) 40 | 41 | 42 | class TestSetItemNameAction(ActionTomlLoadsMixin): 43 | def test_dumps(self): 44 | action = SetItemNameAction() 45 | exp_dump = { 46 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.setitemname', 47 | 'WFWorkflowActionParameters': {} 48 | } 49 | assert action.dump() == exp_dump 50 | 51 | def test_loads_toml(self): 52 | toml = ''' 53 | [[action]] 54 | type = "set_item_name" 55 | ''' 56 | self._assert_toml_loads(toml, SetItemNameAction, {}) 57 | 58 | 59 | class TestViewContentGraphAction(ActionTomlLoadsMixin): 60 | def test_dumps(self): 61 | action = ViewContentGraphAction() 62 | exp_dump = { 63 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.viewresult', 64 | 'WFWorkflowActionParameters': {} 65 | } 66 | assert action.dump() == exp_dump 67 | 68 | def test_loads_toml(self): 69 | toml = ''' 70 | [[action]] 71 | type = "view_content_graph" 72 | ''' 73 | self._assert_toml_loads(toml, ViewContentGraphAction, {}) 74 | 75 | 76 | class TestContinueInShortcutAppAction(ActionTomlLoadsMixin): 77 | def test_dumps(self): 78 | action = ContinueInShortcutAppAction() 79 | exp_dump = { 80 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.handoff', 81 | 'WFWorkflowActionParameters': {} 82 | } 83 | assert action.dump() == exp_dump 84 | 85 | def test_loads_toml(self): 86 | toml = ''' 87 | [[action]] 88 | type = "continue_in_shortcut_app" 89 | ''' 90 | self._assert_toml_loads(toml, ContinueInShortcutAppAction, {}) 91 | 92 | 93 | class TestDelayAction(ActionTomlLoadsMixin): 94 | def test_dumps(self): 95 | action = DelayAction(data={'time': 0.75}) 96 | exp_dump = { 97 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.delay', 98 | 'WFWorkflowActionParameters': {'WFDelayTime': 0.75}, 99 | } 100 | assert action.dump() == exp_dump 101 | 102 | def test_loads_toml(self): 103 | toml = ''' 104 | [[action]] 105 | type = "delay" 106 | time = 0.58 107 | ''' 108 | self._assert_toml_loads(toml, DelayAction, {'time': 0.58}) 109 | 110 | 111 | class TestWaitToReturnAction(ActionTomlLoadsMixin): 112 | def test_dumps(self): 113 | action = WaitToReturnAction() 114 | exp_dump = { 115 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.waittoreturn', 116 | 'WFWorkflowActionParameters': {} 117 | } 118 | assert action.dump() == exp_dump 119 | 120 | def test_loads_toml(self): 121 | toml = ''' 122 | [[action]] 123 | type = "wait_to_return" 124 | ''' 125 | self._assert_toml_loads(toml, WaitToReturnAction, {}) 126 | 127 | 128 | class TestRepeatWithEachActions: 129 | toml_string = ''' 130 | [[action]] 131 | type = "repeat_with_each_start" 132 | 133 | [[action]] 134 | type = "repeat_with_each_end" 135 | ''' 136 | 137 | def test_loads_from_toml(self): 138 | sc = Shortcut.loads(self.toml_string) 139 | 140 | assert len(sc.actions) == 2 141 | 142 | assert isinstance(sc.actions[0], RepeatEachStartAction) is True 143 | assert isinstance(sc.actions[1], RepeatEachEndAction) is True 144 | 145 | def test_dumps_to_plist(self): 146 | sc = Shortcut.loads(self.toml_string) 147 | 148 | mocked_uuid = mock.Mock() 149 | mocked_uuid.uuid4.return_value = 'some-uuid' 150 | 151 | with mock.patch('shortcuts.shortcut.uuid', mocked_uuid): 152 | dump = sc.dumps(file_format=FMT_SHORTCUT) 153 | 154 | exp_dump = '\n\n\n\n\tWFWorkflowActions\n\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.repeat.each\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tGroupingIdentifier\n\t\t\t\tsome-uuid\n\t\t\t\tWFControlFlowMode\n\t\t\t\t0\n\t\t\t\n\t\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.repeat.each\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tGroupingIdentifier\n\t\t\t\tsome-uuid\n\t\t\t\tWFControlFlowMode\n\t\t\t\t2\n\t\t\t\n\t\t\n\t\n\tWFWorkflowClientRelease\n\t2.0\n\tWFWorkflowClientVersion\n\t700\n\tWFWorkflowIcon\n\t\n\t\tWFWorkflowIconGlyphNumber\n\t\t59511\n\t\tWFWorkflowIconImageData\n\t\t\n\t\t\n\t\tWFWorkflowIconStartColor\n\t\t431817727\n\t\n\tWFWorkflowImportQuestions\n\t\n\tWFWorkflowInputContentItemClasses\n\t\n\t\tWFAppStoreAppContentItem\n\t\tWFArticleContentItem\n\t\tWFContactContentItem\n\t\tWFDateContentItem\n\t\tWFEmailAddressContentItem\n\t\tWFGenericFileContentItem\n\t\tWFImageContentItem\n\t\tWFiTunesProductContentItem\n\t\tWFLocationContentItem\n\t\tWFDCMapsLinkContentItem\n\t\tWFAVAssetContentItem\n\t\tWFPDFContentItem\n\t\tWFPhoneNumberContentItem\n\t\tWFRichTextContentItem\n\t\tWFSafariWebPageContentItem\n\t\tWFStringContentItem\n\t\tWFURLContentItem\n\t\n\tWFWorkflowTypes\n\t\n\t\tNCWidget\n\t\tWatchKit\n\t\n\n\n' 155 | assert dump == exp_dump 156 | 157 | 158 | class TestHashAction(ActionTomlLoadsMixin): 159 | @pytest.mark.parametrize('hash_type', [ 160 | *HASH_CHOICES, 161 | ]) 162 | def test_dumps_with_choices(self, hash_type): 163 | action = HashAction(data={'hash_type': hash_type}) 164 | exp_dump = { 165 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.hash', 166 | 'WFWorkflowActionParameters': { 167 | 'WFHashType': hash_type, 168 | } 169 | } 170 | assert action.dump() == exp_dump 171 | 172 | @pytest.mark.parametrize('hash_type', [ 173 | *HASH_CHOICES, 174 | ]) 175 | def test_loads_toml(self, hash_type): 176 | toml = f''' 177 | [[action]] 178 | type = "hash" 179 | hash_type = "{hash_type}" 180 | ''' 181 | self._assert_toml_loads(toml, HashAction, {'hash_type': hash_type}) 182 | 183 | def test_choices(self): 184 | exp_choices = ( 185 | 'MD5', 186 | 'SHA1', 187 | 'SHA256', 188 | 'SHA512', 189 | ) 190 | assert HASH_CHOICES == exp_choices 191 | 192 | 193 | class TestGetMyShortcutsAction(SimpleBaseDumpsLoadsTest): 194 | action_class = GetMyShortcutsAction 195 | itype = 'is.workflow.actions.getmyworkflows' 196 | toml = '[[action]]\ntype = "get_my_shortcuts"' 197 | action_xml = ''' 198 | 199 | WFWorkflowActionIdentifier 200 | is.workflow.actions.getmyworkflows 201 | WFWorkflowActionParameters 202 | 203 | 204 | ''' 205 | 206 | 207 | class TestChooseFromListAction(SimpleBaseDumpsLoadsTest): 208 | action_class = ChooseFromListAction 209 | itype = 'is.workflow.actions.choosefromlist' 210 | dump_data = {'select_multiple': False, 'select_all_initially': False} 211 | dump_params = {'WFChooseFromListActionSelectMultiple': False, 'WFChooseFromListActionSelectAll': False} 212 | toml = ''' 213 | [[action]] 214 | type = "choose_from_list" 215 | prompt = "test" 216 | ''' 217 | exp_toml_params = {'prompt': 'test'} 218 | action_xml = ''' 219 | 220 | WFWorkflowActionIdentifier 221 | is.workflow.actions.choosefromlist 222 | WFWorkflowActionParameters 223 | 224 | WFChooseFromListActionPrompt 225 | test 226 | WFChooseFromListActionSelectAll 227 | 228 | WFChooseFromListActionSelectMultiple 229 | 230 | 231 | 232 | ''' 233 | exp_xml_params = {'select_all_initially': True, 'select_multiple': True, 'prompt': 'test'} 234 | 235 | 236 | class TestOpenAppAction(SimpleBaseDumpsLoadsTest): 237 | action_class = OpenAppAction 238 | itype = 'is.workflow.actions.openapp' 239 | 240 | dump_data = {'app': 'com.apple.camera'} 241 | dump_params = { 242 | 'WFAppIdentifier': 'com.apple.camera' 243 | } 244 | 245 | toml = ''' 246 | [[action]] 247 | type = "open_app" 248 | app = "com.apple.camera" 249 | ''' 250 | exp_toml_params = {'app': 'com.apple.camera'} 251 | 252 | action_xml = ''' 253 | 254 | WFWorkflowActionIdentifier 255 | is.workflow.actions.openapp 256 | WFWorkflowActionParameters 257 | 258 | WFAppIdentifier 259 | com.apple.camera 260 | 261 | 262 | ''' 263 | exp_xml_params = {'app': 'com.apple.camera'} 264 | 265 | 266 | class TestRunShortcut(SimpleBaseDumpsLoadsTest): 267 | action_class = RunShortcutAction 268 | itype = 'is.workflow.actions.runworkflow' 269 | 270 | dump_data = {'shortcut_name': 'test', 'show': False} 271 | dump_params = { 272 | 'WFShowWorkflow': False, 273 | 'WFWorkflowName': 'test', 274 | } 275 | 276 | toml = ''' 277 | [[action]] 278 | type = "run_shortcut" 279 | shortcut_name = "test_shortcut" 280 | ''' 281 | exp_toml_params = {'shortcut_name': 'test_shortcut'} 282 | 283 | action_xml = ''' 284 | 285 | WFWorkflowActionIdentifier 286 | is.workflow.actions.runworkflow 287 | WFWorkflowActionParameters 288 | 289 | WFWorkflowName 290 | mywf 291 | WFShowWorkflow 292 | 293 | 294 | 295 | ''' 296 | exp_xml_params = {'shortcut_name': 'mywf', 'show': True} 297 | -------------------------------------------------------------------------------- /tests/actions/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib 3 | 4 | from shortcuts import actions 5 | 6 | 7 | class TestActions: 8 | def test_all_actions_must_be_imported_in_the_init(self): 9 | """ 10 | checks that all subclasses of BaseAction in files `shortcuts/actions/*.py` 11 | are imported in the `shortcuts/actions/__init__.py` 12 | """ 13 | lib_actions = self._get_actions_from_module(actions) # actions from shortcuts/actions/__init__.py 14 | 15 | files = os.listdir(os.path.dirname(actions.__file__)) 16 | for module in files: 17 | if module == '__init__.py': 18 | continue 19 | module_name, ext = os.path.splitext(module) 20 | if ext != '.py': 21 | continue 22 | 23 | imported_module = importlib.import_module(f'shortcuts.actions.{module_name}') 24 | module_actions = self._get_actions_from_module(imported_module) 25 | 26 | msg = 'Seems like you have actions which are not imported in `shortcuts.actions.__init__.py`' 27 | assert module_actions - lib_actions == set(), msg 28 | 29 | def _get_actions_from_module(self, module): 30 | """Returns subclasses of the BaseAction from module""" 31 | module_actions = [] 32 | for name, cls in module.__dict__.items(): 33 | if isinstance(cls, type) and issubclass(cls, actions.BaseAction) and cls.keyword: 34 | module_actions.append(cls) 35 | return set(module_actions) 36 | -------------------------------------------------------------------------------- /tests/actions/text/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/text/__init__.py -------------------------------------------------------------------------------- /tests/actions/text/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from shortcuts.actions import SplitTextAction, ChangeCaseAction, DetectLanguageAction, ScanQRBarCodeAction, GetNameOfEmojiAction, GetTextFromInputAction, ShowDefinitionAction 4 | from shortcuts.actions.text import SPLIT_SEPARATOR_CHOICES, CASE_CHOICES 5 | 6 | from tests.conftest import ActionTomlLoadsMixin, SimpleBaseDumpsLoadsTest 7 | 8 | 9 | class TestSplitTextAction(ActionTomlLoadsMixin): 10 | def test_dumps(self): 11 | action = SplitTextAction() 12 | exp_dump = { 13 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.text.split', 14 | 'WFWorkflowActionParameters': { 15 | 'WFTextSeparator': 'New Lines', 16 | } 17 | } 18 | assert action.dump() == exp_dump 19 | 20 | def test_dumps_with_custom_separator(self): 21 | action = SplitTextAction(data={ 22 | 'custom_separator': ';', 23 | 'separator_type': 'Custom', 24 | }) 25 | exp_dump = { 26 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.text.split', 27 | 'WFWorkflowActionParameters': { 28 | 'WFTextSeparator': 'Custom', 29 | 'WFTextCustomSeparator': ';', 30 | } 31 | } 32 | assert action.dump() == exp_dump 33 | 34 | @pytest.mark.parametrize('separator', [ 35 | *SPLIT_SEPARATOR_CHOICES, 36 | ]) 37 | def test_dumps_with_choices(self, separator): 38 | action = SplitTextAction(data={'separator_type': separator}) 39 | exp_dump = { 40 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.text.split', 41 | 'WFWorkflowActionParameters': { 42 | 'WFTextSeparator': separator, 43 | } 44 | } 45 | assert action.dump() == exp_dump 46 | 47 | def test_loads_toml(self): 48 | toml = ''' 49 | [[action]] 50 | type = "split_text" 51 | ''' 52 | self._assert_toml_loads(toml, SplitTextAction, {}) 53 | 54 | def test_choices(self): 55 | exp_choices = ( 56 | 'New Lines', 57 | 'Spaces', 58 | 'Every Character', 59 | 'Custom', 60 | ) 61 | assert SPLIT_SEPARATOR_CHOICES == exp_choices 62 | 63 | 64 | class TestChangeCaseAction(ActionTomlLoadsMixin): 65 | @pytest.mark.parametrize('case_type', [ 66 | *CASE_CHOICES, 67 | ]) 68 | def test_dumps_with_choices(self, case_type): 69 | action = ChangeCaseAction(data={'case_type': case_type}) 70 | exp_dump = { 71 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.text.changecase', 72 | 'WFWorkflowActionParameters': { 73 | 'WFCaseType': case_type, 74 | } 75 | } 76 | assert action.dump() == exp_dump 77 | 78 | @pytest.mark.parametrize('case_type', [ 79 | *CASE_CHOICES, 80 | ]) 81 | def test_loads_toml(self, case_type): 82 | toml = f''' 83 | [[action]] 84 | type = "change_case" 85 | case_type = "{case_type}" 86 | ''' 87 | self._assert_toml_loads(toml, ChangeCaseAction, {'case_type': case_type}) 88 | 89 | def test_choices(self): 90 | exp_choices = ( 91 | 'UPPERCASE', 92 | 'lowercase', 93 | 'Capitalize Every Word', 94 | 'Capitalize with Title Case', 95 | 'Capitalize with sentence case.', 96 | 'cApItAlIzE wItH aLtErNaTiNg CaSe.', 97 | ) 98 | assert CASE_CHOICES == exp_choices 99 | 100 | 101 | class TestGetNameOfEmojiAction(SimpleBaseDumpsLoadsTest): 102 | action_class = GetNameOfEmojiAction 103 | itype = 'is.workflow.actions.getnameofemoji' 104 | toml = '[[action]]\ntype = "get_name_of_emoji"' 105 | action_xml = ''' 106 | 107 | WFWorkflowActionIdentifier 108 | is.workflow.actions.getnameofemoji 109 | WFWorkflowActionParameters 110 | 111 | 112 | ''' 113 | 114 | 115 | class TestScanQRBarCodeAction(SimpleBaseDumpsLoadsTest): 116 | action_class = ScanQRBarCodeAction 117 | itype = 'is.workflow.actions.scanbarcode' 118 | toml = '[[action]]\ntype = "scan_barcode"' 119 | action_xml = ''' 120 | 121 | WFWorkflowActionIdentifier 122 | is.workflow.actions.scanbarcode 123 | WFWorkflowActionParameters 124 | 125 | 126 | ''' 127 | 128 | 129 | class TestDetectLanguageAction(SimpleBaseDumpsLoadsTest): 130 | action_class = DetectLanguageAction 131 | itype = 'is.workflow.actions.detectlanguage' 132 | toml = '[[action]]\ntype = "detect_language"' 133 | action_xml = ''' 134 | 135 | WFWorkflowActionIdentifier 136 | is.workflow.actions.detectlanguage 137 | WFWorkflowActionParameters 138 | 139 | 140 | ''' 141 | 142 | 143 | class TestGetTextFromInputAction(SimpleBaseDumpsLoadsTest): 144 | action_class = GetTextFromInputAction 145 | itype = 'is.workflow.actions.detect.text' 146 | toml = '[[action]]\ntype = "get_text_from_input"' 147 | action_xml = ''' 148 | 149 | WFWorkflowActionIdentifier 150 | is.workflow.actions.detect.text 151 | WFWorkflowActionParameters 152 | 153 | 154 | ''' 155 | 156 | 157 | class TestShowDefinitionAction(SimpleBaseDumpsLoadsTest): 158 | action_class = ShowDefinitionAction 159 | itype = 'is.workflow.actions.showdefinition' 160 | toml = '[[action]]\ntype = "show_definition"' 161 | action_xml = ''' 162 | 163 | WFWorkflowActionIdentifier 164 | is.workflow.actions.showdefinition 165 | WFWorkflowActionParameters 166 | 167 | 168 | ''' 169 | -------------------------------------------------------------------------------- /tests/actions/variables/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/variables/__init__.py -------------------------------------------------------------------------------- /tests/actions/variables/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts import Shortcut, FMT_SHORTCUT 2 | from shortcuts.actions import SetVariableAction, GetVariableAction, AppendVariableAction 3 | 4 | from tests.conftest import ActionTomlLoadsMixin 5 | 6 | 7 | class TestSetVariable: 8 | def test_get_parameters(self): 9 | name = 'var' 10 | set_action = SetVariableAction(data={'name': name}) 11 | 12 | dump = set_action._get_parameters() 13 | 14 | exp_dump = { 15 | 'WFVariableName': name, 16 | } 17 | assert dump == exp_dump 18 | 19 | 20 | class TestGetVariableAction: 21 | toml_string = ''' 22 | [[action]] 23 | type = "get_variable" 24 | name = "var" 25 | ''' 26 | 27 | def test_loads_from_toml(self): 28 | sc = Shortcut.loads(self.toml_string) 29 | 30 | assert len(sc.actions) == 1 31 | 32 | action = sc.actions[0] 33 | assert isinstance(action, GetVariableAction) is True 34 | 35 | exp_data = { 36 | 'name': 'var', 37 | } 38 | assert action.data == exp_data 39 | 40 | def test_dumps_to_plist(self): 41 | sc = Shortcut.loads(self.toml_string) 42 | dump = sc.dumps(file_format=FMT_SHORTCUT) 43 | 44 | exp_dump = '\n\n\n\n\tWFWorkflowActions\n\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.getvariable\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tWFVariable\n\t\t\t\t\n\t\t\t\t\tValue\n\t\t\t\t\t\n\t\t\t\t\t\tType\n\t\t\t\t\t\tVariable\n\t\t\t\t\t\tVariableName\n\t\t\t\t\t\tvar\n\t\t\t\t\t\n\t\t\t\t\tWFSerializationType\n\t\t\t\t\tWFTextTokenAttachment\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\tWFWorkflowClientRelease\n\t2.0\n\tWFWorkflowClientVersion\n\t700\n\tWFWorkflowIcon\n\t\n\t\tWFWorkflowIconGlyphNumber\n\t\t59511\n\t\tWFWorkflowIconImageData\n\t\t\n\t\t\n\t\tWFWorkflowIconStartColor\n\t\t431817727\n\t\n\tWFWorkflowImportQuestions\n\t\n\tWFWorkflowInputContentItemClasses\n\t\n\t\tWFAppStoreAppContentItem\n\t\tWFArticleContentItem\n\t\tWFContactContentItem\n\t\tWFDateContentItem\n\t\tWFEmailAddressContentItem\n\t\tWFGenericFileContentItem\n\t\tWFImageContentItem\n\t\tWFiTunesProductContentItem\n\t\tWFLocationContentItem\n\t\tWFDCMapsLinkContentItem\n\t\tWFAVAssetContentItem\n\t\tWFPDFContentItem\n\t\tWFPhoneNumberContentItem\n\t\tWFRichTextContentItem\n\t\tWFSafariWebPageContentItem\n\t\tWFStringContentItem\n\t\tWFURLContentItem\n\t\n\tWFWorkflowTypes\n\t\n\t\tNCWidget\n\t\tWatchKit\n\t\n\n\n' 45 | assert dump == exp_dump 46 | 47 | def test_loads_from_plist(self): 48 | plist = '\n\n\n\n\tWFWorkflowActions\n\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.getvariable\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tWFVariable\n\t\t\t\t\n\t\t\t\t\tValue\n\t\t\t\t\t\n\t\t\t\t\t\tType\n\t\t\t\t\t\tVariable\n\t\t\t\t\t\tVariableName\n\t\t\t\t\t\tvar\n\t\t\t\t\t\n\t\t\t\t\tWFSerializationType\n\t\t\t\t\tWFTextTokenAttachment\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\tWFWorkflowClientRelease\n\t2.0\n\tWFWorkflowClientVersion\n\t700\n\tWFWorkflowIcon\n\t\n\t\tWFWorkflowIconGlyphNumber\n\t\t59511\n\t\tWFWorkflowIconImageData\n\t\t\n\t\t\n\t\tWFWorkflowIconStartColor\n\t\t431817727\n\t\n\tWFWorkflowImportQuestions\n\t\n\tWFWorkflowInputContentItemClasses\n\t\n\t\tWFAppStoreAppContentItem\n\t\tWFArticleContentItem\n\t\tWFContactContentItem\n\t\tWFDateContentItem\n\t\tWFEmailAddressContentItem\n\t\tWFGenericFileContentItem\n\t\tWFImageContentItem\n\t\tWFiTunesProductContentItem\n\t\tWFLocationContentItem\n\t\tWFDCMapsLinkContentItem\n\t\tWFAVAssetContentItem\n\t\tWFPDFContentItem\n\t\tWFPhoneNumberContentItem\n\t\tWFRichTextContentItem\n\t\tWFSafariWebPageContentItem\n\t\tWFStringContentItem\n\t\tWFURLContentItem\n\t\n\tWFWorkflowTypes\n\t\n\t\tNCWidget\n\t\tWatchKit\n\t\n\n\n' 49 | 50 | sc = Shortcut.loads(plist, file_format=FMT_SHORTCUT) 51 | 52 | assert len(sc.actions) == 1 53 | 54 | action = sc.actions[0] 55 | assert isinstance(action, GetVariableAction) is True 56 | 57 | exp_data = { 58 | 'name': 'var', 59 | } 60 | assert action.data == exp_data 61 | 62 | 63 | class TestAppendVariableAction(ActionTomlLoadsMixin): 64 | def test_dumps(self): 65 | name = 'var' 66 | action = AppendVariableAction(data={'name': name}) 67 | exp_dump = { 68 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.appendvariable', 69 | 'WFWorkflowActionParameters': { 70 | 'WFVariableName': name, 71 | } 72 | } 73 | assert action.dump() == exp_dump 74 | 75 | def test_loads_toml(self): 76 | name = 'var1' 77 | toml = f''' 78 | [[action]] 79 | type = "append_variable" 80 | name = "{name}" 81 | ''' 82 | self._assert_toml_loads(toml, AppendVariableAction, {'name': name}) 83 | -------------------------------------------------------------------------------- /tests/actions/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/actions/web/__init__.py -------------------------------------------------------------------------------- /tests/actions/web/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts import Shortcut, FMT_SHORTCUT 2 | from shortcuts.actions import URLAction, GetURLAction, URLDecodeAction, URLEncodeAction, ExpandURLAction, OpenURLAction 3 | 4 | from tests.conftest import ActionTomlLoadsMixin, SimpleBaseDumpsLoadsTest 5 | 6 | 7 | class TestURLAction: 8 | def test_get_parameters(self): 9 | url = 'https://aleks.sh' 10 | action = URLAction(data={'url': url}) 11 | 12 | dump = action._get_parameters() 13 | 14 | exp_dump = { 15 | 'WFURLActionURL': url, 16 | } 17 | assert dump == exp_dump 18 | 19 | 20 | class TestGetURLAction: 21 | toml_string = ''' 22 | [[action]] 23 | type = "get_url" 24 | method = "POST" 25 | advanced = true 26 | 27 | [[action.headers]] 28 | key = "header1" 29 | value = "value" 30 | 31 | [[action.headers]] 32 | key = "authorization" 33 | value = "{{authorization}}" 34 | 35 | [[action.json]] 36 | key = "k" 37 | value = "v" 38 | ''' 39 | 40 | def test_loads_from_toml(self): 41 | sc = Shortcut.loads(self.toml_string) 42 | 43 | assert len(sc.actions) == 1 44 | 45 | action = sc.actions[0] 46 | assert isinstance(action, GetURLAction) is True 47 | 48 | exp_data = { 49 | 'headers': [ 50 | {'key': 'header1', 'value': 'value'}, 51 | {'key': 'authorization', 'value': '{{authorization}}'}, 52 | ], 53 | 'json': [ 54 | {'key': 'k', 'value': 'v'}, 55 | ], 56 | 'advanced': True, 57 | 'method': 'POST', 58 | } 59 | assert action.data == exp_data 60 | 61 | def test_dumps_to_plist(self): 62 | sc = Shortcut.loads(self.toml_string) 63 | dump = sc.dumps(file_format=FMT_SHORTCUT) 64 | 65 | exp_dump = '\n\n\n\n\tWFWorkflowActions\n\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.downloadurl\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tAdvanced\n\t\t\t\t\n\t\t\t\tShowHeaders\n\t\t\t\t\n\t\t\t\tWFHTTPBodyType\n\t\t\t\tJson\n\t\t\t\tWFHTTPHeaders\n\t\t\t\t\n\t\t\t\t\tValue\n\t\t\t\t\t\n\t\t\t\t\t\tWFDictionaryFieldValueItems\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFItemType\n\t\t\t\t\t\t\t\t0\n\t\t\t\t\t\t\t\tWFKey\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\theader1\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFValue\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\tvalue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFItemType\n\t\t\t\t\t\t\t\t0\n\t\t\t\t\t\t\t\tWFKey\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\tauthorization\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFValue\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{0, 1}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tType\n\t\t\t\t\t\t\t\t\t\t\t\tVariable\n\t\t\t\t\t\t\t\t\t\t\t\tVariableName\n\t\t\t\t\t\t\t\t\t\t\t\tauthorization\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tWFSerializationType\n\t\t\t\t\tWFDictionaryFieldValue\n\t\t\t\t\n\t\t\t\tWFHTTPMethod\n\t\t\t\tPOST\n\t\t\t\tWFJSONValues\n\t\t\t\t\n\t\t\t\t\tValue\n\t\t\t\t\t\n\t\t\t\t\t\tWFDictionaryFieldValueItems\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFItemType\n\t\t\t\t\t\t\t\t0\n\t\t\t\t\t\t\t\tWFKey\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\tk\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tWFValue\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tValue\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tstring\n\t\t\t\t\t\t\t\t\t\tv\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tWFSerializationType\n\t\t\t\t\t\t\t\t\tWFTextTokenString\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tWFSerializationType\n\t\t\t\t\tWFDictionaryFieldValue\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\tWFWorkflowClientRelease\n\t2.0\n\tWFWorkflowClientVersion\n\t700\n\tWFWorkflowIcon\n\t\n\t\tWFWorkflowIconGlyphNumber\n\t\t59511\n\t\tWFWorkflowIconImageData\n\t\t\n\t\t\n\t\tWFWorkflowIconStartColor\n\t\t431817727\n\t\n\tWFWorkflowImportQuestions\n\t\n\tWFWorkflowInputContentItemClasses\n\t\n\t\tWFAppStoreAppContentItem\n\t\tWFArticleContentItem\n\t\tWFContactContentItem\n\t\tWFDateContentItem\n\t\tWFEmailAddressContentItem\n\t\tWFGenericFileContentItem\n\t\tWFImageContentItem\n\t\tWFiTunesProductContentItem\n\t\tWFLocationContentItem\n\t\tWFDCMapsLinkContentItem\n\t\tWFAVAssetContentItem\n\t\tWFPDFContentItem\n\t\tWFPhoneNumberContentItem\n\t\tWFRichTextContentItem\n\t\tWFSafariWebPageContentItem\n\t\tWFStringContentItem\n\t\tWFURLContentItem\n\t\n\tWFWorkflowTypes\n\t\n\t\tNCWidget\n\t\tWatchKit\n\t\n\n\n' 66 | 67 | assert dump == exp_dump 68 | 69 | 70 | class TestURLEncodeAction(ActionTomlLoadsMixin): 71 | def test_dumps(self): 72 | action = URLEncodeAction() 73 | exp_dump = { 74 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.urlencode', 75 | 'WFWorkflowActionParameters': { 76 | 'WFEncodeMode': 'Encode', 77 | } 78 | } 79 | assert action.dump() == exp_dump 80 | 81 | def test_loads_toml(self): 82 | toml = f''' 83 | [[action]] 84 | type = "urlencode" 85 | ''' 86 | self._assert_toml_loads(toml, URLEncodeAction, {}) 87 | 88 | 89 | class TestURLDecodeAction(ActionTomlLoadsMixin): 90 | def test_dumps(self): 91 | action = URLDecodeAction() 92 | exp_dump = { 93 | 'WFWorkflowActionIdentifier': 'is.workflow.actions.urlencode', 94 | 'WFWorkflowActionParameters': { 95 | 'WFEncodeMode': 'Decode', 96 | } 97 | } 98 | assert action.dump() == exp_dump 99 | 100 | def test_loads_toml(self): 101 | toml = f''' 102 | [[action]] 103 | type = "urldecode" 104 | ''' 105 | self._assert_toml_loads(toml, URLDecodeAction, {}) 106 | 107 | 108 | class TestURLEncodeAndDecodeActions: 109 | def test_loads_plist(self): 110 | plist = ' WFWorkflowActions WFWorkflowActionIdentifier is.workflow.actions.urlencode WFWorkflowActionParameters WFEncodeMode Decode WFWorkflowActionIdentifier is.workflow.actions.urlencode WFWorkflowActionParameters WFEncodeMode Encode WFWorkflowClientRelease 2.0 WFWorkflowClientVersion 700 WFWorkflowIcon WFWorkflowIconGlyphNumber 59511 WFWorkflowIconImageData WFWorkflowIconStartColor 4271458815 WFWorkflowImportQuestions WFWorkflowInputContentItemClasses WFAppStoreAppContentItem WFArticleContentItem WFContactContentItem WFDateContentItem WFEmailAddressContentItem WFGenericFileContentItem WFImageContentItem WFiTunesProductContentItem WFLocationContentItem WFDCMapsLinkContentItem WFAVAssetContentItem WFPDFContentItem WFPhoneNumberContentItem WFRichTextContentItem WFSafariWebPageContentItem WFStringContentItem WFURLContentItem WFWorkflowTypes NCWidget WatchKit ' 111 | 112 | sc = Shortcut.loads(plist, file_format=FMT_SHORTCUT) 113 | 114 | assert isinstance(sc.actions[0], URLDecodeAction) is True 115 | assert isinstance(sc.actions[1], URLEncodeAction) is True 116 | 117 | 118 | class TestExpandURLAction(SimpleBaseDumpsLoadsTest): 119 | action_class = ExpandURLAction 120 | itype = 'is.workflow.actions.url.expand' 121 | toml = '[[action]]\ntype = "expand_url"' 122 | action_xml = ''' 123 | 124 | WFWorkflowActionIdentifier 125 | is.workflow.actions.url.expand 126 | WFWorkflowActionParameters 127 | 128 | 129 | 130 | ''' 131 | 132 | 133 | class TestOpenURLAction(SimpleBaseDumpsLoadsTest): 134 | action_class = OpenURLAction 135 | itype = 'is.workflow.actions.openurl' 136 | toml = '[[action]]\ntype = "open_url"' 137 | action_xml = ''' 138 | 139 | WFWorkflowActionIdentifier 140 | is.workflow.actions.openurl 141 | WFWorkflowActionParameters 142 | 143 | 144 | 145 | ''' 146 | -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/cli/__init__.py -------------------------------------------------------------------------------- /tests/cli/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from shortcuts import FMT_SHORTCUT, FMT_TOML 4 | from shortcuts.cli import _get_format 5 | 6 | 7 | class Test_get_format: 8 | @pytest.mark.parametrize('filepath,exp_format', [ 9 | ('file.shortcut', FMT_SHORTCUT), 10 | ('file.plist', FMT_SHORTCUT), 11 | ('file.toml', FMT_TOML), 12 | ('https://icloud.com/shortcuts/some-id/', 'url'), 13 | ]) 14 | def test_for_url(self, filepath, exp_format): 15 | assert _get_format(filepath) == exp_format 16 | 17 | def test_raises(self): 18 | with pytest.raises(RuntimeError): 19 | _get_format('abc') 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from shortcuts import Shortcut, FMT_SHORTCUT 2 | 3 | from tests.templates import SHORTCUT_EMPTY_PLIST_TEMPLATE 4 | 5 | 6 | class ActionTomlLoadsMixin: 7 | def _assert_toml_loads(self, toml, exp_cls, exp_data): 8 | sc = Shortcut.loads(toml) 9 | 10 | assert len(sc.actions) == 1 11 | 12 | action = sc.actions[0] 13 | assert isinstance(sc.actions[0], exp_cls) is True 14 | 15 | assert action.data == exp_data 16 | 17 | 18 | class SimpleBaseDumpsLoadsTest(ActionTomlLoadsMixin): 19 | action_class = None 20 | itype = None 21 | 22 | dump_data = None 23 | dump_params = None 24 | 25 | toml = None 26 | exp_toml_params = None 27 | 28 | action_xml = None 29 | exp_xml_params = None 30 | 31 | def test_dumps(self): 32 | action = self.action_class(data=self.dump_data) 33 | dump_params = self.dump_params if self.dump_params else {} 34 | exp_dump = { 35 | 'WFWorkflowActionIdentifier': self.itype, 36 | 'WFWorkflowActionParameters': dump_params, 37 | } 38 | assert action.dump() == exp_dump 39 | 40 | def test_loads_toml(self): 41 | exp_params = self.exp_toml_params if self.exp_toml_params else {} 42 | self._assert_toml_loads(self.toml, self.action_class, exp_params) 43 | 44 | def test_loads_plist(self): 45 | plist = SHORTCUT_EMPTY_PLIST_TEMPLATE.format(actions=self.action_xml.strip()) 46 | plist = plist.replace(' ', '').replace('\n', '') # remove all indentation 47 | sc = Shortcut.loads(plist, file_format=FMT_SHORTCUT) 48 | 49 | assert len(sc.actions) == 1 50 | assert isinstance(sc.actions[0], self.action_class) is True 51 | 52 | exp_params = self.exp_xml_params if self.exp_xml_params else {} 53 | assert sc.actions[0].data == exp_params 54 | -------------------------------------------------------------------------------- /tests/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/examples/__init__.py -------------------------------------------------------------------------------- /tests/loader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/loader/__init__.py -------------------------------------------------------------------------------- /tests/loader/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from shortcuts.loader import WFTextTokenAttachmentField 4 | from shortcuts.exceptions import UnknownWFTextTokenAttachment 5 | 6 | 7 | class TestWFTextTokenAttachmentField: 8 | @pytest.mark.parametrize('data, exp_value', [ 9 | ({'Type': 'Ask'}, '{{ask_when_run}}'), 10 | ({'Type': 'Clipboard'}, '{{clipboard}}'), 11 | ({'Type': 'ExtensionInput'}, '{{shortcut_input}}'), 12 | ({'Type': 'Variable', 'VariableName': 'var'}, 'var'), # todo: #2 13 | ]) 14 | def test_field(self, data, exp_value): 15 | f = WFTextTokenAttachmentField({'Value': data}) 16 | assert f.deserialized_data == exp_value 17 | 18 | @pytest.mark.parametrize('data', [ 19 | {}, 20 | {'Value': {}}, 21 | {'Value': {'Type': 'unknown'}}, 22 | ]) 23 | def test_raises_exception(self, data): 24 | with pytest.raises(UnknownWFTextTokenAttachment): 25 | WFTextTokenAttachmentField(data).deserialized_data 26 | -------------------------------------------------------------------------------- /tests/shortcuts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/shortcuts/__init__.py -------------------------------------------------------------------------------- /tests/shortcuts/tests.py: -------------------------------------------------------------------------------- 1 | from shortcuts import Shortcut, FMT_SHORTCUT 2 | from shortcuts.actions import ( 3 | TextAction, 4 | SetVariableAction, 5 | IfAction, 6 | ElseAction, 7 | EndIfAction, 8 | VibrateAction, 9 | GetURLAction, 10 | NotificationAction, 11 | ) 12 | 13 | 14 | class TestShortcutDumps: 15 | def test_dumps_simple_shortcut(self): 16 | sc = Shortcut(name='test') 17 | 18 | sc.actions = [ 19 | SetVariableAction(data={'name': 'var2'}), 20 | TextAction(data={'text': 'simple text: {{var1}}'}), 21 | TextAction(data={'text': 'another text: {{var1}}'}), 22 | ] 23 | 24 | for action in sc.actions: 25 | action.id = 'id' 26 | 27 | exp_dump = '\n\n\n\n\tWFWorkflowActions\n\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.setvariable\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tWFVariableName\n\t\t\t\tvar2\n\t\t\t\n\t\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.gettext\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tWFTextActionText\n\t\t\t\t\n\t\t\t\t\tValue\n\t\t\t\t\t\n\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{13, 1}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tType\n\t\t\t\t\t\t\t\tVariable\n\t\t\t\t\t\t\t\tVariableName\n\t\t\t\t\t\t\t\tvar1\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tstring\n\t\t\t\t\t\tsimple text: \n\t\t\t\t\t\n\t\t\t\t\tWFSerializationType\n\t\t\t\t\tWFTextTokenString\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.gettext\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tWFTextActionText\n\t\t\t\t\n\t\t\t\t\tValue\n\t\t\t\t\t\n\t\t\t\t\t\tattachmentsByRange\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{14, 1}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tType\n\t\t\t\t\t\t\t\tVariable\n\t\t\t\t\t\t\t\tVariableName\n\t\t\t\t\t\t\t\tvar1\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tstring\n\t\t\t\t\t\tanother text: \n\t\t\t\t\t\n\t\t\t\t\tWFSerializationType\n\t\t\t\t\tWFTextTokenString\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\tWFWorkflowClientRelease\n\t2.0\n\tWFWorkflowClientVersion\n\t700\n\tWFWorkflowIcon\n\t\n\t\tWFWorkflowIconGlyphNumber\n\t\t59511\n\t\tWFWorkflowIconImageData\n\t\t\n\t\t\n\t\tWFWorkflowIconStartColor\n\t\t431817727\n\t\n\tWFWorkflowImportQuestions\n\t\n\tWFWorkflowInputContentItemClasses\n\t\n\t\tWFAppStoreAppContentItem\n\t\tWFArticleContentItem\n\t\tWFContactContentItem\n\t\tWFDateContentItem\n\t\tWFEmailAddressContentItem\n\t\tWFGenericFileContentItem\n\t\tWFImageContentItem\n\t\tWFiTunesProductContentItem\n\t\tWFLocationContentItem\n\t\tWFDCMapsLinkContentItem\n\t\tWFAVAssetContentItem\n\t\tWFPDFContentItem\n\t\tWFPhoneNumberContentItem\n\t\tWFRichTextContentItem\n\t\tWFSafariWebPageContentItem\n\t\tWFStringContentItem\n\t\tWFURLContentItem\n\t\n\tWFWorkflowTypes\n\t\n\t\tNCWidget\n\t\tWatchKit\n\t\n\n\n' 28 | assert sc.dumps(file_format=FMT_SHORTCUT) == exp_dump 29 | 30 | 31 | class TestShortcutLoads: 32 | def test_loads(self): 33 | toml_string = ''' 34 | [[action]] 35 | type = "text" 36 | text = "ping" 37 | 38 | [[action]] 39 | type = "set_variable" 40 | name = "variable" 41 | 42 | [[action]] 43 | type = "show_result" 44 | text = "My variable: {{variable}}" 45 | ''' 46 | sc = Shortcut.loads(toml_string) 47 | 48 | assert len(sc.actions) == 3 49 | 50 | assert sc.actions[0].keyword == 'text' 51 | assert sc.actions[0].data['text'] == 'ping' 52 | assert sc.actions[0].itype == 'is.workflow.actions.gettext' 53 | 54 | assert sc.actions[1].keyword == 'set_variable' 55 | assert sc.actions[1].data['name'] == 'variable' 56 | assert sc.actions[1].itype == 'is.workflow.actions.setvariable' 57 | 58 | assert sc.actions[2].keyword == 'show_result' 59 | assert sc.actions[2].data['text'] == 'My variable: {{variable}}' 60 | assert sc.actions[2].itype == 'is.workflow.actions.showresult' 61 | 62 | 63 | class TestShortcutLoadsAndDumps: 64 | def test_loads_and_dumps_with_not_all_params(self): 65 | question = 'What is your name?' 66 | toml_string = f''' 67 | [[action]] 68 | type = "ask" 69 | question = "{question}" 70 | ''' 71 | 72 | sc = Shortcut.loads(toml_string) 73 | 74 | assert len(sc.actions) == 1 75 | 76 | action = sc.actions[0] 77 | 78 | assert action.keyword == 'ask' 79 | assert action.data['question'] == question 80 | assert action.itype == 'is.workflow.actions.ask' 81 | 82 | assert action.data == {'question': question} 83 | 84 | dump = sc.dumps(file_format=FMT_SHORTCUT) 85 | 86 | exp_dump = '\n\n\n\n\tWFWorkflowActions\n\t\n\t\t\n\t\t\tWFWorkflowActionIdentifier\n\t\t\tis.workflow.actions.ask\n\t\t\tWFWorkflowActionParameters\n\t\t\t\n\t\t\t\tWFAskActionPrompt\n\t\t\t\tWhat is your name?\n\t\t\t\n\t\t\n\t\n\tWFWorkflowClientRelease\n\t2.0\n\tWFWorkflowClientVersion\n\t700\n\tWFWorkflowIcon\n\t\n\t\tWFWorkflowIconGlyphNumber\n\t\t59511\n\t\tWFWorkflowIconImageData\n\t\t\n\t\t\n\t\tWFWorkflowIconStartColor\n\t\t431817727\n\t\n\tWFWorkflowImportQuestions\n\t\n\tWFWorkflowInputContentItemClasses\n\t\n\t\tWFAppStoreAppContentItem\n\t\tWFArticleContentItem\n\t\tWFContactContentItem\n\t\tWFDateContentItem\n\t\tWFEmailAddressContentItem\n\t\tWFGenericFileContentItem\n\t\tWFImageContentItem\n\t\tWFiTunesProductContentItem\n\t\tWFLocationContentItem\n\t\tWFDCMapsLinkContentItem\n\t\tWFAVAssetContentItem\n\t\tWFPDFContentItem\n\t\tWFPhoneNumberContentItem\n\t\tWFRichTextContentItem\n\t\tWFSafariWebPageContentItem\n\t\tWFStringContentItem\n\t\tWFURLContentItem\n\t\n\tWFWorkflowTypes\n\t\n\t\tNCWidget\n\t\tWatchKit\n\t\n\n\n' 87 | 88 | assert dump == exp_dump 89 | 90 | def test_loads_plist(self): 91 | plist = ' WFWorkflowActions WFWorkflowActionIdentifier is.workflow.actions.gettext WFWorkflowActionParameters WFTextActionText Value attachmentsByRange string ping WFSerializationType WFTextTokenString WFWorkflowActionIdentifier is.workflow.actions.setvariable WFWorkflowActionParameters WFVariableName init WFWorkflowActionIdentifier is.workflow.actions.conditional WFWorkflowActionParameters GroupingIdentifier 73C63251-8CE2-4DC2-A078-57CBBC823657 WFCondition Equals WFConditionalActionString ping WFControlFlowMode 0 WFWorkflowActionIdentifier is.workflow.actions.gettext WFWorkflowActionParameters WFTextActionText Value attachmentsByRange string test WFSerializationType WFTextTokenString WFWorkflowActionIdentifier is.workflow.actions.setvariable WFWorkflowActionParameters WFVariableName test WFWorkflowActionIdentifier is.workflow.actions.conditional WFWorkflowActionParameters GroupingIdentifier 73C63251-8CE2-4DC2-A078-57CBBC823657 WFControlFlowMode 1 WFWorkflowActionIdentifier is.workflow.actions.vibrate WFWorkflowActionParameters WFWorkflowActionIdentifier is.workflow.actions.downloadurl WFWorkflowActionParameters Advanced ShowHeaders WFHTTPBodyType Json WFHTTPHeaders Value WFDictionaryFieldValueItems WFItemType 0 WFKey Value attachmentsByRange string Auth WFSerializationType WFTextTokenString WFValue Value attachmentsByRange string token WFSerializationType WFTextTokenString WFSerializationType WFDictionaryFieldValue WFHTTPMethod POST WFJSONValues Value WFDictionaryFieldValueItems WFItemType 0 WFKey Value attachmentsByRange string key1 WFSerializationType WFTextTokenString WFValue Value attachmentsByRange {0, 1} Type Variable VariableName init string  WFSerializationType WFTextTokenString WFItemType 0 WFKey Value attachmentsByRange string key2 WFSerializationType WFTextTokenString WFValue Value attachmentsByRange string 5 WFSerializationType WFTextTokenString WFSerializationType WFDictionaryFieldValue WFWorkflowActionIdentifier is.workflow.actions.conditional WFWorkflowActionParameters GroupingIdentifier 73C63251-8CE2-4DC2-A078-57CBBC823657 WFControlFlowMode 2 WFWorkflowActionIdentifier is.workflow.actions.notification WFWorkflowActionParameters WFNotificationActionBody Value attachmentsByRange {13, 1} Type Variable VariableName test string Hello World!  WFSerializationType WFTextTokenString WFNotificationActionSound WFNotificationActionTitle Value attachmentsByRange string hi WFSerializationType WFTextTokenString WFWorkflowClientRelease 2.0 WFWorkflowClientVersion 700 WFWorkflowIcon WFWorkflowIconGlyphNumber 59511 WFWorkflowIconImageData WFWorkflowIconStartColor 431817727 WFWorkflowImportQuestions WFWorkflowInputContentItemClasses WFAppStoreAppContentItem WFArticleContentItem WFContactContentItem WFDateContentItem WFEmailAddressContentItem WFGenericFileContentItem WFImageContentItem WFiTunesProductContentItem WFLocationContentItem WFDCMapsLinkContentItem WFAVAssetContentItem WFPDFContentItem WFPhoneNumberContentItem WFRichTextContentItem WFSafariWebPageContentItem WFStringContentItem WFURLContentItem WFWorkflowTypes NCWidget WatchKit ' 92 | 93 | sc = Shortcut.loads(plist, file_format=FMT_SHORTCUT) 94 | 95 | exp_actions = [ 96 | TextAction, 97 | SetVariableAction, 98 | IfAction, 99 | TextAction, 100 | SetVariableAction, 101 | ElseAction, 102 | VibrateAction, 103 | GetURLAction, 104 | EndIfAction, 105 | NotificationAction, 106 | ] 107 | 108 | assert [type(a) for a in sc.actions] == exp_actions 109 | 110 | 111 | class TestShortcut: 112 | def test_set_group_ids_for_empty_shortcut(self): 113 | sc = Shortcut() 114 | 115 | assert len(sc.actions) == 0 116 | sc._set_group_ids() 117 | assert len(sc.actions) == 0 118 | 119 | def test_set_group_ids(self): 120 | # test that _set_group_ids sets group_ids correctly 121 | sc = Shortcut() 122 | sc.actions = [ 123 | IfAction(data={'condition': 'equals', 'compare_with': 'test'}), 124 | 125 | IfAction(data={'condition': 'equals', 'compare_with': 'test'}), 126 | EndIfAction(data={}), 127 | 128 | ElseAction(data={}), 129 | # pass 130 | EndIfAction(data={}), 131 | ] 132 | 133 | # all actions are without group_id info 134 | assert any([a.data.get('group_id') for a in sc.actions]) is False 135 | 136 | sc._set_group_ids() 137 | 138 | # now all actions are with group_id info 139 | assert all([a.data.get('group_id') for a in sc.actions]) is True 140 | 141 | # first cycle check 142 | assert sc.actions[0].data['group_id'] == sc.actions[3].data['group_id'] == sc.actions[4].data['group_id'] 143 | 144 | # second cycle 145 | assert sc.actions[1].data['group_id'] == sc.actions[2].data['group_id'] 146 | 147 | # ids are different 148 | assert sc.actions[0].data['group_id'] != sc.actions[1].data['group_id'] 149 | -------------------------------------------------------------------------------- /tests/templates.py: -------------------------------------------------------------------------------- 1 | SHORTCUT_EMPTY_PLIST_TEMPLATE = ''' 2 | 3 | 4 | 5 | 6 | WFWorkflowActions 7 | 8 | {actions} 9 | 10 | WFWorkflowClientRelease 11 | 2.0 12 | WFWorkflowClientVersion 13 | 700 14 | WFWorkflowIcon 15 | 16 | WFWorkflowIconGlyphNumber 17 | 59511 18 | WFWorkflowIconImageData 19 | 20 | 21 | WFWorkflowIconStartColor 22 | 1440408063 23 | 24 | WFWorkflowImportQuestions 25 | 26 | WFWorkflowInputContentItemClasses 27 | 28 | WFAppStoreAppContentItem 29 | WFArticleContentItem 30 | WFContactContentItem 31 | WFDateContentItem 32 | WFEmailAddressContentItem 33 | WFGenericFileContentItem 34 | WFImageContentItem 35 | WFiTunesProductContentItem 36 | WFLocationContentItem 37 | WFDCMapsLinkContentItem 38 | WFAVAssetContentItem 39 | WFPDFContentItem 40 | WFPhoneNumberContentItem 41 | WFRichTextContentItem 42 | WFSafariWebPageContentItem 43 | WFStringContentItem 44 | WFURLContentItem 45 | 46 | WFWorkflowTypes 47 | 48 | NCWidget 49 | WatchKit 50 | 51 | 52 | 53 | ''' 54 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexander-akhmetov/python-shortcuts/0fcb7f8718f3c4805ab13769087b53a5634d4dd2/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import mock 4 | import pytest 5 | 6 | 7 | from shortcuts.utils import _make_request, is_shortcut_url, _get_shortcut_uuid, download_shortcut 8 | from shortcuts.exceptions import InvalidShortcutURLError 9 | 10 | 11 | class Test__make_request: 12 | def test_make_request_should_call_urllib(self): 13 | mocked_urlopen = mock.Mock() 14 | mocked_urlopen.return_value.status = 200 15 | url = 'https://google.com' 16 | 17 | with mock.patch('shortcuts.utils.urlopen', mocked_urlopen): 18 | response = _make_request(url) 19 | 20 | mocked_urlopen.assert_called_once_with(url) 21 | assert response == mocked_urlopen() 22 | 23 | def test_make_request_should_raise_exception_for_non_200_status(self): 24 | mocked_urllib = mock.Mock() 25 | mocked_urllib.return_value.status = 500 26 | 27 | with mock.patch('shortcuts.utils.urlopen', mocked_urllib): 28 | with pytest.raises(RuntimeError): 29 | _make_request('') 30 | 31 | 32 | class Test__is_shortcut_url: 33 | @pytest.mark.parametrize('url,exp_result', [ 34 | ('https://www.icloud.com/shortcuts/', True), 35 | ('https://icloud.com/shortcuts/', True), 36 | ('http://icloud.com/shortcuts/', True), 37 | ('https://icloud.com/', False), # not shortcuts 38 | ('https://google.com/shortcuts/', False), # not shortcuts 39 | ('', False), 40 | (None, False), 41 | ]) 42 | def test_is_shortcut_url(self, url, exp_result): 43 | assert is_shortcut_url(url) is exp_result 44 | 45 | 46 | class Test__get_shortcut_uuid: 47 | @pytest.mark.parametrize('url,exp_uuid', [ 48 | ('https://www.icloud.com/shortcuts/288f4b6c-11bd-4c2b-bb08-5bfa7504bca5', '288f4b6c-11bd-4c2b-bb08-5bfa7504bca5'), 49 | ('https://icloud.com/shortcuts/288f4b6c-11bd-4c2b-bb08-5bfa7504bca5', '288f4b6c-11bd-4c2b-bb08-5bfa7504bca5'), 50 | ]) 51 | def test_uuid(self, url, exp_uuid): 52 | assert _get_shortcut_uuid(url) == exp_uuid 53 | 54 | @pytest.mark.parametrize('url', [ 55 | 'https://yandex.ru', 56 | 'https://icloud.com', 57 | '', 58 | 'abc', 59 | None, 60 | 'https://www.icloud.com/shortcuts/abc', 61 | 'https://icloud.com/shortcuts/123' 62 | ]) 63 | def test_exception_with_invalid_url(self, url): 64 | with pytest.raises(InvalidShortcutURLError): 65 | _get_shortcut_uuid(url) 66 | 67 | 68 | class Test__download_shortcut: 69 | def test_should_return_shortcut_content(self): 70 | shortcut_id = '288f4b6c-11bd-4c2b-bb08-5bfa7504bca5' 71 | 72 | api_url = f'https://www.icloud.com/shortcuts/api/records/{shortcut_id}/' 73 | url = f'https://icloud.com/shortcuts/{shortcut_id}' 74 | download_url = 'some-url' 75 | exp_content = 'shortcut content' 76 | shortcut_info = { 77 | 'fields': { 78 | 'shortcut': { 79 | 'value': { 80 | 'downloadURL': download_url, 81 | }, 82 | }, 83 | }, 84 | } 85 | 86 | def _urlopen(url): 87 | mocked_response = mock.Mock() 88 | mocked_response.status = 200 89 | if url == api_url: 90 | mocked_response.read.return_value = json.dumps(shortcut_info) 91 | elif url == download_url: 92 | mocked_response.read.return_value = exp_content 93 | 94 | return mocked_response 95 | 96 | with mock.patch('shortcuts.utils.urlopen', _urlopen): 97 | response = download_shortcut(url) 98 | 99 | assert response == exp_content 100 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = black,flake8,mypy,py37,py36,py38 3 | skipsdist = True 4 | 5 | [gh-actions] 6 | python = 7 | 3.6: py36 8 | 3.7: py37 9 | 3.8: py38, mypy, flake8, black 10 | 11 | [testenv] 12 | whitelist_externals = pipenv 13 | install_command = pipenv update {opts} {packages} 14 | deps = --dev 15 | commands = pytest --cov=shortcuts {posargs} 16 | 17 | [testenv:flake8] 18 | commands = flake8 shortcuts 19 | 20 | [testenv:mypy] 21 | commands = mypy shortcuts 22 | --------------------------------------------------------------------------------