├── .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 | [](https://travis-ci.org/alexander-akhmetov/python-shortcuts)
4 | [](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 |
--------------------------------------------------------------------------------