From ab6b31e5142a46f8ee740e2aeba775780d176b2c Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Sat, 22 Sep 2018 16:05:59 +0200 Subject: [PATCH] initial commit --- .gitignore | 5 + LICENSE | 21 +++ MANIFEST | 35 +++++ MANIFEST.in | 10 ++ Makefile | 11 ++ Pipfile | 14 ++ Pipfile.lock | 96 ++++++++++++ README.md | 69 ++++++++ docs/CHANGELOG.md | 10 ++ docs/actions.md | 183 ++++++++++++++++++++++ docs/new_action.md | 53 +++++++ docs/tutorial.md | 52 ++++++ examples/base64.toml | 33 ++++ examples/multiline.toml | 8 + examples/what_is_your_name.toml | 13 ++ setup.cfg | 5 + setup.py | 34 ++++ shortcuts/__init__.py | 5 + shortcuts/actions/__init__.py | 47 ++++++ shortcuts/actions/base.py | 105 +++++++++++++ shortcuts/actions/base64.py | 21 +++ shortcuts/actions/calculation.py | 9 ++ shortcuts/actions/date.py | 7 + shortcuts/actions/dictionary.py | 9 ++ shortcuts/actions/files.py | 29 ++++ shortcuts/actions/input.py | 11 ++ shortcuts/actions/out.py | 19 +++ shortcuts/actions/photo.py | 19 +++ shortcuts/actions/text.py | 17 ++ shortcuts/actions/variables.py | 9 ++ shortcuts/actions/web.py | 9 ++ shortcuts/cli.py | 49 ++++++ shortcuts/docs.py | 52 ++++++ shortcuts/dump.py | 42 +++++ shortcuts/loader.py | 127 +++++++++++++++ shortcuts/shortcuts.py | 97 ++++++++++++ shortcuts/tests/__init__.py | 0 shortcuts/tests/actions/__init__.py | 0 shortcuts/tests/actions/base/__init__.py | 0 shortcuts/tests/actions/base/tests.py | 88 +++++++++++ shortcuts/tests/actions/photo/__init__.py | 0 shortcuts/tests/actions/photo/tests.py | 24 +++ shortcuts/tests/shortcuts/__init__.py | 0 shortcuts/tests/shortcuts/tests.py | 78 +++++++++ tox.ini | 25 +++ 45 files changed, 1550 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 README.md create mode 100644 docs/CHANGELOG.md create mode 100644 docs/actions.md create mode 100644 docs/new_action.md create mode 100644 docs/tutorial.md create mode 100644 examples/base64.toml create mode 100644 examples/multiline.toml create mode 100644 examples/what_is_your_name.toml create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 shortcuts/__init__.py create mode 100644 shortcuts/actions/__init__.py create mode 100644 shortcuts/actions/base.py create mode 100644 shortcuts/actions/base64.py create mode 100644 shortcuts/actions/calculation.py create mode 100644 shortcuts/actions/date.py create mode 100644 shortcuts/actions/dictionary.py create mode 100644 shortcuts/actions/files.py create mode 100644 shortcuts/actions/input.py create mode 100644 shortcuts/actions/out.py create mode 100644 shortcuts/actions/photo.py create mode 100644 shortcuts/actions/text.py create mode 100644 shortcuts/actions/variables.py create mode 100644 shortcuts/actions/web.py create mode 100644 shortcuts/cli.py create mode 100644 shortcuts/docs.py create mode 100644 shortcuts/dump.py create mode 100644 shortcuts/loader.py create mode 100644 shortcuts/shortcuts.py create mode 100644 shortcuts/tests/__init__.py create mode 100644 shortcuts/tests/actions/__init__.py create mode 100644 shortcuts/tests/actions/base/__init__.py create mode 100644 shortcuts/tests/actions/base/tests.py create mode 100644 shortcuts/tests/actions/photo/__init__.py create mode 100644 shortcuts/tests/actions/photo/tests.py create mode 100644 shortcuts/tests/shortcuts/__init__.py create mode 100644 shortcuts/tests/shortcuts/tests.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67ae01b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.tox +*.pyc +.plist +build +dist diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cbde8b4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Alexander Akhmetov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..c80f7e5 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,35 @@ +# file GENERATED by distutils, do NOT edit +README.md +setup.cfg +setup.py +docs/CHANGELOG.md +docs/actions.md +docs/new_action.md +docs/tutorial.md +shortcuts/__init__.py +shortcuts/cli.py +shortcuts/docs.py +shortcuts/dump.py +shortcuts/loader.py +shortcuts/shortcuts.py +shortcuts/actions/__init__.py +shortcuts/actions/base.py +shortcuts/actions/base64.py +shortcuts/actions/calculation.py +shortcuts/actions/date.py +shortcuts/actions/dictionary.py +shortcuts/actions/files.py +shortcuts/actions/input.py +shortcuts/actions/out.py +shortcuts/actions/photo.py +shortcuts/actions/text.py +shortcuts/actions/variables.py +shortcuts/actions/web.py +shortcuts/tests/__init__.py +shortcuts/tests/actions/__init__.py +shortcuts/tests/actions/base/__init__.py +shortcuts/tests/actions/base/tests.py +shortcuts/tests/actions/photo/__init__.py +shortcuts/tests/actions/photo/tests.py +shortcuts/tests/shortcuts/__init__.py +shortcuts/tests/shortcuts/tests.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..edf84a7 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +# Include the README +include *.md + +# Include the license file +include LICENSE.txt + +recursive-include shortcuts *.py +recursive-include docs * + +global-exclude *.pyc .git .tox __pycache__ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..19b1697 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +generate_docs: + python src/docs.py docs/actions.md + +tests: + tox + + +release-pypi: + test -n "$(VERSION)" + python setup.py sdist + twine upload dist/python-shortcuts-$(VERSION).tar.gz diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..a841cd3 --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[dev-packages] +pytest = "*" +mock = "*" + +[packages] +toml = "*" + +[requires] +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..24fbf83 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,96 @@ +{ + "_meta": { + "hash": { + "sha256": "992d267370c43853118ea5f02aa9a3125987b8d7e3b74623c720b39df022578e" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.6" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "toml": { + "hashes": [ + "sha256:380178cde50a6a79f9d2cf6f42a62a5174febe5eea4126fe4038785f1d888d42", + "sha256:a7901919d3e4f92ffba7ff40a9d697e35bbbc8a8049fe8da742f34c83606d957" + ], + "index": "pypi", + "version": "==0.9.6" + } + }, + "develop": { + "atomicwrites": { + "hashes": [ + "sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", + "sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" + ], + "version": "==1.2.1" + }, + "attrs": { + "hashes": [ + "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", + "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" + ], + "version": "==18.2.0" + }, + "mock": { + "hashes": [ + "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", + "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" + ], + "index": "pypi", + "version": "==2.0.0" + }, + "more-itertools": { + "hashes": [ + "sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092", + "sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e", + "sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d" + ], + "version": "==4.3.0" + }, + "pbr": { + "hashes": [ + "sha256:1b8be50d938c9bb75d0eaf7eda111eec1bf6dc88a62a6412e33bf077457e0f45", + "sha256:b486975c0cafb6beeb50ca0e17ba047647f229087bd74e37f4a7e2cac17d2caa" + ], + "version": "==4.2.0" + }, + "pluggy": { + "hashes": [ + "sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1", + "sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1" + ], + "version": "==0.7.1" + }, + "py": { + "hashes": [ + "sha256:06a30435d058473046be836d3fc4f27167fd84c45b99704f2fb5509ef61f9af1", + "sha256:50402e9d1c9005d759426988a492e0edaadb7f4e68bcddfea586bc7432d009c6" + ], + "version": "==1.6.0" + }, + "pytest": { + "hashes": [ + "sha256:453cbbbe5ce6db38717d282b758b917de84802af4288910c12442984bde7b823", + "sha256:a8a07f84e680482eb51e244370aaf2caa6301ef265f37c2bdefb3dd3b663f99d" + ], + "index": "pypi", + "version": "==3.8.0" + }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b486b7 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# python-shortcuts + +🍏 + 🐍 = ❤️ + +**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. +It uses [toml](https://github.com/toml-lang/toml) to represent shortcuts. + +The library is in a very early development state (PR welcome!), so it does not support all actions from Shortcuts app. + +* [Tutorial](docs/tutorial.md) +* [How to add a new action](docs/new_action.md) +* [Supported actions](docs/actions.md) +* [Examples](examples/) +* [Changelog](docs/CHANGELOG.md) +* [Documentation](docs/) + +## Why + +I wanted to convert my shortcut to a file in human-readable format. :) + +## How to use + +### Requirements + +This library requires `plutil` tool, which should be installed on MacOS by default. +On Linux, you should be able to use `plistutil` instead. + +### Usage + +### shortcut → toml + +If you need to convert existing shortcut to a toml file, at first you need to export it. +Go into Shortcuts app, open the shortcut and share it. Choose "Share as file" and use this file with this library. + +Convert `toml` file with shortcut description to a real shortcut file. +After you need to open the file with iOS Shortcuts app. + +```bash +python src/cli.py examples/test.toml my_first_shortcut.shortcut +``` + +### toml → shortcut + +Also, you can convert shortcut file to a `toml`: + +```bash +python src/cli.py examples/test.shortcut test.toml +``` + +More examples of `toml` files you can find [here](examples/). +And [read the tutorial](docs/tutorial.md)! :) + +## Development + +### Tests + +Run tests: + +```bash +tox +``` + +### TODO + +* ☐ Conditionals +* ☐ Describe all actions +* ☐ Support magic variables +* ☐ Support all current actions from Shortcuts app +* ☐ Support common action format (with default `WFTextActionText...` field names), so it will not be necessary to create mappings diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..3789c0c --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] + +- It's alive! diff --git a/docs/actions.md b/docs/actions.md new file mode 100644 index 0000000..28d67da --- /dev/null +++ b/docs/actions.md @@ -0,0 +1,183 @@ + +# Supported Actions +## ShowAlertAction + +Show alert + +**keyword**: `alert` +**shortcuts identifier**: `is.workflow.actions.alert` + +params: + +* show_cancel_button +* text +* title + +## AskAction + +Ask for input + +**keyword**: `ask` +**shortcuts identifier**: `is.workflow.actions.ask` + +params: + +* default_answer +* input_type +* question + +## Base64DecodeAction + +Base64 decode + +**keyword**: `base64_decode` +**shortcuts identifier**: `is.workflow.actions.base64decode` + +## Base64EncodeAction + +Base64 encode + +**keyword**: `base64_encode` +**shortcuts identifier**: `is.workflow.actions.base64encode` + +## CommentAction + +Comment: just a comment + +**keyword**: `comment` +**shortcuts identifier**: `is.workflow.actions.comment` + +params: + +* text + +## CountAction + +Count + +**keyword**: `count` +**shortcuts identifier**: `is.workflow.actions.count` + +params: + +* count + +## CreateFolderAction + +Create folder + +**keyword**: `create_folder` +**shortcuts identifier**: `is.workflow.actions.file.createfolder` + +params: + +* path + +## DateAction + +Date + +**keyword**: `date` +**shortcuts identifier**: `is.workflow.actions.date` + +## GetLastPhotoAction + +Get latest photos + +**keyword**: `get_last_photo` +**shortcuts identifier**: `is.workflow.actions.getlastphoto` + +## GetDictionaryValueAction + +Get dictionary value + +**keyword**: `get_value_for_key` +**shortcuts identifier**: `is.workflow.actions.getvalueforkey` + +params: + +* key + +## ReadFileAction + +Get file + +**keyword**: `read_file` +**shortcuts identifier**: `is.workflow.actions.documentpicker.open` + +params: + +* not_found_error +* path +* show_picker + +## SaveFileAction + +Save file + +**keyword**: `save_file` +**shortcuts identifier**: `is.workflow.actions.documentpicker.save` + +params: + +* overwrite +* path +* show_picker + +## SelectPhotoAction + +Select photos + +**keyword**: `select_photo` +**shortcuts identifier**: `is.workflow.actions.selectphoto` + +## SetVariableAction + +Set variable: saves input to a variable with a name=`name` + +**keyword**: `set_variable` +**shortcuts identifier**: `is.workflow.actions.setvariable` + +params: + +* name + +## ShowResultAction + +Show result: shows a result + +**keyword**: `show_result` +**shortcuts identifier**: `is.workflow.actions.showresult` + +params: + +* text + +## CameraAction + +Take photo + +**keyword**: `take_photo` +**shortcuts identifier**: `is.workflow.actions.takephoto` + +## TextAction + +Text: returns text as an output + +**keyword**: `text` +**shortcuts identifier**: `is.workflow.actions.gettext` + +params: + +* text + +## URLAction + +URL: returns url as an output + +**keyword**: `url` +**shortcuts identifier**: `is.workflow.actions.url` + +params: + +* url diff --git a/docs/new_action.md b/docs/new_action.md new file mode 100644 index 0000000..9c4aa74 --- /dev/null +++ b/docs/new_action.md @@ -0,0 +1,53 @@ +# Actions + +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: + +```bash +$ python src/cli.py myshortcut.shortcut myshortcut.toml + +RuntimeError: + + Unknown shortcut action: is.workflow.actions.gettext + + Please, check documentation to add new shortcut action, or create an issue: + Docs: https://github.com/alexander-akhmetov/python-shortcuts/tree/master/docs/new_action.md + + https://github.com/alexander-akhmetov/python-shortcuts/ + + Action dictionary: + + { + 'WFWorkflowActionIdentifier': 'is.workflow.actions.gettext', + 'WFWorkflowActionParameters': {'WFTextActionText': 'some text'}, + } +``` + +So, how to fix this? +You can create a new action class in any module in the `src/actions/` directory: + +```python +from actions.base import BaseAction, Field + + +class TextAction(BaseAction): + type = 'is.workflow.actions.gettext' + keyword = 'comment' + + text = Field('WFTextActionText', help='Text to show in the comment') +``` + +Every parameter from `WFWorkflowActionParameters` must be presented as a `Field` attribute of the action class. +If this parameter is not required, you can pass `required=False` to the `Field`. + +That's all, now this action is supported by the library, and you can convert your shortcut: + +```bash +$ python src/cli.py myshortcut.shortcut myshortcut.toml +$ cat myshortcut.toml + +[[action]] +type = "comment" +text = "some text" +``` + +And now, you need to do only one last thing. Please, send a pull request :) diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..38da7cd --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,52 @@ +# Tutorial + +This project uses [toml](https://github.com/toml-lang/toml) to describe shortcuts. + +A shortcut is a sequence of actions. Every action can receive some input and can produce output. + +Let's write a simple shortcut, which asks the name of our user and then prints it back in an alert. + +At first, we need to ask our user: + +```toml +[[action]] +type = "ask" +question = "What is your name?" +``` + +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. + +Let's save it to a variable. + +```toml +[[action]] +type = "set_variable" +name = "name" +``` + +And now let's print a message for the user: + +```toml +[[action]] +type = "show_result" +text = "Hello, {{name}}!" +``` + +The most important thing here is `{{name}}`. We took our variable `name` and put it to the text string. +And the user will see something like `Hello, Alexander!`. + +## Full toml file + +```toml +[[action]] +type = "ask" +question = "What is your name?" + +[[action]] +type = "set_variable" +name = "name" + +[[action]] +type = "show_result" +text = "Hello, {{name}}!" +``` diff --git a/examples/base64.toml b/examples/base64.toml new file mode 100644 index 0000000..238eea2 --- /dev/null +++ b/examples/base64.toml @@ -0,0 +1,33 @@ +name = "Base64 Example" + +[[action]] +type = "text" +text = "ping" + +[[action]] +type = "set_variable" +name = "variable" + +[[action]] +type = "base64_encode" + +[[action]] +type = "set_variable" +name = "variable_encoded" + +[[action]] +type = "base64_decode" + +[[action]] +type = "set_variable" +name = "variable_decoded" + +[[action]] +type = "show_result" +text = """ +Hello, world! + +original_variable: {{variable}} +variable_encoded: {{variable_encoded}} +variable_decoded: {{variable_decoded}} +""" diff --git a/examples/multiline.toml b/examples/multiline.toml new file mode 100644 index 0000000..d378249 --- /dev/null +++ b/examples/multiline.toml @@ -0,0 +1,8 @@ +[[action]] +type = "comment" +text = """ +Hi! +This is an example shortcut, created with python-shortcuts. + +See more here: https://github.com/alexander-akhmetov/python-shortcuts +""" diff --git a/examples/what_is_your_name.toml b/examples/what_is_your_name.toml new file mode 100644 index 0000000..c4f227a --- /dev/null +++ b/examples/what_is_your_name.toml @@ -0,0 +1,13 @@ +name = "What is your name?" + +[[action]] +type = "ask" +question = "What is your name?" + +[[action]] +type = "set_variable" +name = "name" + +[[action]] +type = "show_result" +text = "Hello, {{name}}!" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e8b8954 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[pytest] +python_files=test*.py + +[flake8] +max-line-length = 119 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..059a0f5 --- /dev/null +++ b/setup.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +import os +import re +from distutils.core import setup + + +def get_version(package): + """ + Returns version of a package (`__version__` in `init.py`). + """ + init_py = open(os.path.join(package, '__init__.py')).read() + return re.match("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) + + +version = get_version('shortcuts') + + +setup( + name='python-shortcuts', + version=version, + description='Python library to create and parse Siri Shortcuts', + author='Alexander Akhmetov', + author_email='me@aleks.sh', + url='https://github.com/alexander-akhmetov/python-shortcuts', + packages=[ + 'shortcuts', + 'shortcuts.actions', + ], + entry_points={ + 'console_scripts': [ + 'shortcuts = shortcuts.cli:main', + ], + }, +) diff --git a/shortcuts/__init__.py b/shortcuts/__init__.py new file mode 100644 index 0000000..41bf57f --- /dev/null +++ b/shortcuts/__init__.py @@ -0,0 +1,5 @@ +__version__ = '0.1.0' + +from .shortcuts import Shortcut + +VERSION = __version__ diff --git a/shortcuts/actions/__init__.py b/shortcuts/actions/__init__.py new file mode 100644 index 0000000..1d73225 --- /dev/null +++ b/shortcuts/actions/__init__.py @@ -0,0 +1,47 @@ +from shortcuts.actions import ( + base, + base64, + calculation, + date, + dictionary, + files, + input, + photo, + text, + out, + variables, + web, +) + + +KEYWORD_TO_ACTION_MAP = {} +TYPE_TO_ACTION_MAP = {} + + +def _create_map(): + modules = ( + base, + base64, + calculation, + date, + dictionary, + files, + input, + photo, + text, + out, + variables, + web, + ) + for module in modules: + _parse_module(module) + + +def _parse_module(module): + for name, cls in module.__dict__.items(): + if isinstance(cls, type) and issubclass(cls, base.BaseAction) and cls.keyword: + KEYWORD_TO_ACTION_MAP[cls.keyword] = cls + TYPE_TO_ACTION_MAP[cls.type] = cls + + +_create_map() diff --git a/shortcuts/actions/base.py b/shortcuts/actions/base.py new file mode 100644 index 0000000..936e573 --- /dev/null +++ b/shortcuts/actions/base.py @@ -0,0 +1,105 @@ +import re +from typing import Dict, Tuple, List, Union, Any + + +class BaseAction: + type = None # identificator from shortcut source + keyword = None # this keyword is used in the toml file + default_fields = None # dictionary with default parameters fields + + def __init__(self, data: Union[Dict, None] = None) -> None: + self.data = data if data is not None else {} + + def dumps(self) -> Dict: + data = { + 'WFWorkflowActionIdentifier': self.type, + 'WFWorkflowActionParameters': {}, + } + + data['WFWorkflowActionParameters'].update( + self._get_parameters(), + ) + + return data + + def _get_parameters(self) -> Dict: + params = {} + + if self.default_fields: + params.update(self.default_fields) + + for field in self.fields: + try: + data_value = self.data[field._attr] + except KeyError: + if field.required: + raise ValueError(f'{self}, Field is required: {field._attr}:{field.name}') + else: + continue + + params[field.name] = field.process_value(data_value) + + if isinstance(field, VariablesField): + params['WFSerializationType'] = 'WFTextTokenString' + + return params + + @property + def fields(self) -> List[Any]: + if not hasattr(self, '_fields'): + self._fields = [] + for attr in dir(self): + field = getattr(self, attr) + if isinstance(field, (Field, VariablesField)): + field._attr = attr + self._fields.append(field) + + return self._fields + + +class Field: + def __init__(self, name, required=True, capitalize=False, help=''): + self.name = name + self.required = required + self.capitalize = capitalize + self.help = help + + def process_value(self, value): + if self.capitalize: + value = value.capitalize() + + return value + + +class VariablesField(Field): + _regexp = re.compile(r'({{[A-Za-z0-9_-]+}})') + + def process_value(self, value): + attachments_by_range, string = self._get_variables_from_text(value) + + if not attachments_by_range: + # if we don't have variables in the string, + # just return the value + return value + + return { + 'Value': { + 'attachmentsByRange': attachments_by_range, + 'string': string, + }, + 'WFSerializationType': 'WFTextTokenString', + } + + def _get_variables_from_text(self, value: str) -> List[Tuple[str, str]]: + attachments_by_range = {} + offset = 0 + for m in self._regexp.finditer(value): + attachments_by_range[f'{{{m.start() - offset}, {1}}}'] = { + 'Type': 'Variable', + 'VariableName': m.group().strip('{}'), + } + offset += len(m.group()) - 1 + + # replacing all variables with char 65523 (OBJECT REPLACEMENT CHARACTER) + string = self._regexp.sub('', value) + return attachments_by_range, string diff --git a/shortcuts/actions/base64.py b/shortcuts/actions/base64.py new file mode 100644 index 0000000..ef5bc42 --- /dev/null +++ b/shortcuts/actions/base64.py @@ -0,0 +1,21 @@ +from shortcuts.actions.base import BaseAction + + +class Base64EncodeAction(BaseAction): + '''Base64 encode''' + type = 'is.workflow.actions.base64encode' + keyword = 'base64_encode' + + default_fields = { + 'WFEncodeMode': 'Encode', + } + + +class Base64DecodeAction(BaseAction): + '''Base64 decode''' + type = 'is.workflow.actions.base64decode' + keyword = 'base64_decode' + + default_fields = { + 'WFEncodeMode': 'Decode', + } diff --git a/shortcuts/actions/calculation.py b/shortcuts/actions/calculation.py new file mode 100644 index 0000000..a60e7cc --- /dev/null +++ b/shortcuts/actions/calculation.py @@ -0,0 +1,9 @@ +from shortcuts.actions.base import BaseAction, Field + + +class CountAction(BaseAction): + '''Count''' + type = 'is.workflow.actions.count' + keyword = 'count' + + count = Field('WFCountType', capitalize=True) diff --git a/shortcuts/actions/date.py b/shortcuts/actions/date.py new file mode 100644 index 0000000..3de6aef --- /dev/null +++ b/shortcuts/actions/date.py @@ -0,0 +1,7 @@ +from shortcuts.actions.base import BaseAction + + +class DateAction(BaseAction): + '''Date''' + type = 'is.workflow.actions.date' + keyword = 'date' diff --git a/shortcuts/actions/dictionary.py b/shortcuts/actions/dictionary.py new file mode 100644 index 0000000..9766823 --- /dev/null +++ b/shortcuts/actions/dictionary.py @@ -0,0 +1,9 @@ +from shortcuts.actions.base import BaseAction, Field + + +class GetDictionaryValueAction(BaseAction): + '''Get dictionary value''' + type = 'is.workflow.actions.getvalueforkey' + keyword = 'get_value_for_key' + + key = Field('WFDictionaryKey') diff --git a/shortcuts/actions/files.py b/shortcuts/actions/files.py new file mode 100644 index 0000000..cf321cf --- /dev/null +++ b/shortcuts/actions/files.py @@ -0,0 +1,29 @@ +from shortcuts.actions.base import BaseAction, Field + + +class ReadFileAction(BaseAction): + '''Get file''' + type = 'is.workflow.actions.documentpicker.open' + keyword = 'read_file' + + path = Field('WFFileDestinationPath') + not_found_error = Field('WFFileErrorIfNotFound') + show_picker = Field('WFAskWhereToSave') + + +class SaveFileAction(BaseAction): + '''Save file''' + type = 'is.workflow.actions.documentpicker.save' + keyword = 'save_file' + + path = Field('WFFileDestinationPath') + overwrite = Field('WFSaveFileOverwrite') + show_picker = Field('WFAskWhereToSave') + + +class CreateFolderAction(BaseAction): + '''Create folder''' + type = 'is.workflow.actions.file.createfolder' + keyword = 'create_folder' + + path = Field('WFFilePath') diff --git a/shortcuts/actions/input.py b/shortcuts/actions/input.py new file mode 100644 index 0000000..e52ab56 --- /dev/null +++ b/shortcuts/actions/input.py @@ -0,0 +1,11 @@ +from shortcuts.actions.base import BaseAction, Field + + +class AskAction(BaseAction): + '''Ask for input''' + type = 'is.workflow.actions.ask' + keyword = 'ask' + + question = Field('WFAskActionPrompt') + input_type = Field('WFInputType', required=False) + default_answer = Field('WFAskActionDefaultAnswer', required=False) diff --git a/shortcuts/actions/out.py b/shortcuts/actions/out.py new file mode 100644 index 0000000..e1d99e7 --- /dev/null +++ b/shortcuts/actions/out.py @@ -0,0 +1,19 @@ +from shortcuts.actions.base import BaseAction, Field, VariablesField + + +class ShowResultAction(BaseAction): + '''Show result: shows a result''' + type = 'is.workflow.actions.showresult' + keyword = 'show_result' + + text = VariablesField('Text') + + +class ShowAlertAction(BaseAction): + '''Show alert''' + type = 'is.workflow.actions.alert' + keyword = 'alert' + + show_cancel_button = Field('WFAlertActionCancelButtonShown') + text = Field('WFAlertActionMessage') + title = Field('WFAlertActionTitle') diff --git a/shortcuts/actions/photo.py b/shortcuts/actions/photo.py new file mode 100644 index 0000000..83ef248 --- /dev/null +++ b/shortcuts/actions/photo.py @@ -0,0 +1,19 @@ +from shortcuts.actions.base import BaseAction + + +class CameraAction(BaseAction): + '''Take photo''' + type = 'is.workflow.actions.takephoto' + keyword = 'take_photo' + + +class GetLastPhotoAction(BaseAction): + '''Get latest photos''' + type = 'is.workflow.actions.getlastphoto' + keyword = 'get_last_photo' + + +class SelectPhotoAction(BaseAction): + '''Select photos''' + type = 'is.workflow.actions.selectphoto' + keyword = 'select_photo' diff --git a/shortcuts/actions/text.py b/shortcuts/actions/text.py new file mode 100644 index 0000000..444b60b --- /dev/null +++ b/shortcuts/actions/text.py @@ -0,0 +1,17 @@ +from shortcuts.actions.base import BaseAction, VariablesField, Field + + +class CommentAction(BaseAction): + '''Comment: just a comment''' + type = 'is.workflow.actions.comment' + keyword = 'comment' + + text = Field('WFCommentActionText', help='Text to show in the comment') + + +class TextAction(BaseAction): + '''Text: returns text as an output''' + type = 'is.workflow.actions.gettext' + keyword = 'text' + + text = VariablesField('WFTextActionText', help='Output of this action') diff --git a/shortcuts/actions/variables.py b/shortcuts/actions/variables.py new file mode 100644 index 0000000..9acb18b --- /dev/null +++ b/shortcuts/actions/variables.py @@ -0,0 +1,9 @@ +from shortcuts.actions.base import BaseAction, Field + + +class SetVariableAction(BaseAction): + '''Set variable: saves input to a variable with a name=`name`''' + type = 'is.workflow.actions.setvariable' + keyword = 'set_variable' + + name = Field('WFVariableName') diff --git a/shortcuts/actions/web.py b/shortcuts/actions/web.py new file mode 100644 index 0000000..e9879be --- /dev/null +++ b/shortcuts/actions/web.py @@ -0,0 +1,9 @@ +from shortcuts.actions.base import BaseAction, Field + + +class URLAction(BaseAction): + '''URL: returns url as an output''' + type = 'is.workflow.actions.url' + keyword = 'url' + + url = Field('WFURLActionURL') diff --git a/shortcuts/cli.py b/shortcuts/cli.py new file mode 100644 index 0000000..82dc2e0 --- /dev/null +++ b/shortcuts/cli.py @@ -0,0 +1,49 @@ +import argparse +import os.path +from subprocess import call + +from shortcuts import Shortcut + +parser = argparse.ArgumentParser(description='Shortcuts: Siri shortcuts creator') +parser.add_argument('file', help='shortcut source file') +parser.add_argument('output', help='shortcut output file') + + +def convert_shortcut(input_filepath, out_filepath): + input_format = _get_format(input_filepath) + out_format = _get_format(out_filepath) + + if input_format == 'plist': + convert_plist_to_xml(input_filepath) + + with open(input_filepath, 'rb') as f: + sc = Shortcut.load(f, file_format=input_format) + with open(out_filepath, 'w') as f: + sc.dump(f, file_format=out_format) + + if out_format == 'plist': + convert_plist_to_binary(out_filepath) + + +def _get_format(filepath): + _, ext = os.path.splitext(filepath) + ext = ext.strip('.') + if ext in ('shortcut', 'plist'): + return 'plist' + elif ext == 'toml': + return 'toml' + + raise RuntimeError(f'Unsupported file format: {filepath}: "{ext}"') + + +def convert_plist_to_binary(filepath): + call(['plutil', '-convert', 'binary1', filepath]) + + +def convert_plist_to_xml(filepath): + call(['plutil', '-convert', 'xml1', filepath]) + + +if __name__ == '__main__': + args = parser.parse_args() + convert_shortcut(args.file, args.output) diff --git a/shortcuts/docs.py b/shortcuts/docs.py new file mode 100644 index 0000000..041dc4a --- /dev/null +++ b/shortcuts/docs.py @@ -0,0 +1,52 @@ +import argparse + +from shortcuts.actions import KEYWORD_TO_ACTION_MAP + + +DOC_TEMPLATE = ''' +# Supported Actions +{actions} +''' + + +ACTION_TEMPLATE = ''' +## {name} + +{doc} + +**keyword**: `{keyword}` +**shortcuts identifier**: `{identifier}` + +{params} +''' + + +def _build_docs(): + actions_docs = [] + actions = sorted(KEYWORD_TO_ACTION_MAP.items()) + for _, action in actions: + params = '\n'.join([f'* {f._attr}' for f in action().fields]).strip() + if params: + params = f'params:\n\n{params}' + actions_docs.append( + ACTION_TEMPLATE.format( + name=action.__name__, + doc=action.__doc__ or '', + keyword=action.keyword, + identifier=action.type, + params=params, + ).strip() + ) + + return DOC_TEMPLATE.format(actions='\n\n'.join(actions_docs)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Actions documentation generator') + parser.add_argument('output', help='output file') + args = parser.parse_args() + + doc = _build_docs() + + with open(args.output, 'w') as f: + f.write(doc) diff --git a/shortcuts/dump.py b/shortcuts/dump.py new file mode 100644 index 0000000..377c8b5 --- /dev/null +++ b/shortcuts/dump.py @@ -0,0 +1,42 @@ +from typing import Any, Dict +import plistlib + +import toml + + +class BaseDumper: + def __init__(self, shortcut: 'shortcuts.Shortcut') -> None: + self.shortcut = shortcut + + def dump(self, file_obj) -> str: + return file_obj.write(self.dumps()) + + +class PListDumper(BaseDumper): + def dumps(self) -> str: + data = { + 'WFWorkflowActions': self.shortcut._get_actions(), + 'WFWorkflowImportQuestions': self.shortcut._get_import_questions(), + 'WFWorkflowClientRelease': self.shortcut.client_release, + 'WFWorkflowClientVersion': self.shortcut.client_version, + 'WFWorkflowTypes': ['NCWidget', 'WatchKit'], # todo: change me + 'WFWorkflowIcon': self.shortcut._get_icon(), + 'WFWorkflowInputContentItemClasses': self.shortcut._get_input_content_item_classes(), + } + + return plistlib.dumps(data).decode('utf-8') + + +class TomlDumper(BaseDumper): + def dumps(self) -> str: + data = { + 'action': [self._process_action(a) for a in self.shortcut.actions], + } + return toml.dumps(data) + + def _process_action(self, action) -> Dict[str, Any]: + data = { + f._attr: action.data[f._attr] for f in action.fields + } + data['type'] = action.keyword + return data diff --git a/shortcuts/loader.py b/shortcuts/loader.py new file mode 100644 index 0000000..5024ca7 --- /dev/null +++ b/shortcuts/loader.py @@ -0,0 +1,127 @@ +import copy +import plistlib +import collections +from typing import Dict + +import toml + +from shortcuts.actions import KEYWORD_TO_ACTION_MAP, TYPE_TO_ACTION_MAP + + +class BaseLoader: + @classmethod + def load(cls, file_obj) -> str: + content = file_obj.read() + if isinstance(content, (bytes, bytearray)): + content = content.decode('utf-8') + return cls.loads(content) + + @classmethod + def loads(cls, string) -> str: + raise NotImplementedError() + + +class TomlLoader(BaseLoader): + @classmethod + def loads(cls, string) -> str: + from shortcuts import Shortcut + + shortcut_dict = toml.loads(string) + shortcut = Shortcut(name=shortcut_dict.get('name', 'python-shortcuts')) + + for action in shortcut_dict['action']: + action_params = copy.deepcopy(action) + del action_params['type'] + shortcut.actions.append( + KEYWORD_TO_ACTION_MAP[action['type']](data=action_params) + ) + + return shortcut + + +class PListLoader(BaseLoader): + @classmethod + def loads(cls, string) -> str: + from shortcuts import Shortcut + + if isinstance(string, str): + string = string.encode('utf-8') + + shortcut_dict = plistlib.loads(string) + shortcut = Shortcut( + name=shortcut_dict.get('name', 'python-shortcuts'), + client_release=shortcut_dict['WFWorkflowClientRelease'], + client_version=shortcut_dict['WFWorkflowClientVersion'], + ) + + import pdb; pdb.set_trace() + for action in shortcut_dict['WFWorkflowActions']: + shortcut.actions.append(cls._action_from_dict(action)) + + return shortcut + + @classmethod + def _action_from_dict(self, action_dict: Dict): + type = action_dict['WFWorkflowActionIdentifier'] + action_class = TYPE_TO_ACTION_MAP.get(type) + if not action_class: + msg = f''' + Unknown shortcut action: {type} + + Please, check documentation to add new shortcut action, or create an issue: + Docs: https://github.com/alexander-akhmetov/python-shortcuts/tree/master/docs/new_action.md + + https://github.com/alexander-akhmetov/python-shortcuts/ + + Action dictionary: + + {action_dict} + ''' + raise RuntimeError(msg) + + shortcut_name_to_field_name = { + f.name: f._attr for f in action_class().fields + } + params = { + shortcut_name_to_field_name[p]: self._load_parameter_value(v) + for p, v in action_dict['WFWorkflowActionParameters'].items() + if p in shortcut_name_to_field_name + } + + return action_class(data=params) + + @classmethod + def _load_parameter_value(cls, value): + # todo: move to fields + if not isinstance(value, dict): + return value + + if value.get('WFSerializationType') == 'WFTextTokenString': + # if thhis field is a string with variables, + # we need to convert it to our representation + value = value['Value'] + value_string = value['string'] + + positions = {} + + for variable_range, variable_data in value['attachmentsByRange'].items(): + if variable_data['Type'] != 'Variable': + # it doesn't support magic variables yet + raise RuntimeError(f'Unsupported variable type: {variable_data["Type"]}') + + # let's find positions of all variables in the string + position = cls._get_position(variable_range) + positions[position] = '{{%s}}' % variable_data['VariableName'] + + # and then replace them with '{{variable_name}}' + offset = 0 + for pos, variable in collections.OrderedDict(sorted(positions.items())).items(): + value_string = value_string[:pos + offset] + variable + value_string[pos + offset:] + offset += len(variable) + + return value_string + + @classmethod + def _get_position(cls, range_str) -> int: + ranges = list(map(lambda x: int(x.strip()), range_str.strip('{} ').split(','))) + return ranges[0] diff --git a/shortcuts/shortcuts.py b/shortcuts/shortcuts.py new file mode 100644 index 0000000..8b33b02 --- /dev/null +++ b/shortcuts/shortcuts.py @@ -0,0 +1,97 @@ +import plistlib +import logging +from typing import List, Dict, Union + +from loader import TomlLoader, PListLoader +from dump import PListDumper, TomlDumper + + +logger = logging.getLogger(__name__) + + +class Shortcut: + def __init__(self, + name: str, + client_release: str = '2.0', + client_version: str = '700', + minimal_client_version: int = 411, + actions: List = None) -> None: + self.name = name + self.client_release = client_release + self.client_version = client_version + self.minimal_client_version = minimal_client_version + self.actions = actions if actions else [] + + @classmethod + def load(cls, file_object, file_format: str = 'toml') -> 'Shortcut': + return cls._get_loader_class(file_format)().load(file_object) + + @classmethod + def loads(cls, string, file_format: str = 'toml') -> 'Shortcut': + return cls._get_loader_class(file_format)().loads(string) + + @classmethod + def _get_loader_class(self, file_format: str) -> Union[PListDumper, TomlDumper]: + supported_formats = { + 'plist': PListLoader, + 'toml': TomlLoader, + } + if file_format in supported_formats: + logger.debug(f'Loading shortcut from file format: {supported_formats}') + return supported_formats[file_format] + + raise RuntimeError(f'Unknown file_format: {file_format}') + + def dump(self, file_object, file_format: str = 'plist') -> None: + return self._get_dumper_class(file_format)(shortcut=self).dump(file_object) + + def dumps(self, file_format: str = 'plist') -> None: + return self._get_dumper_class(file_format)(shortcut=self).dumps() + + def _get_dumper_class(self, file_format: str) -> Union[PListDumper, TomlDumper]: + supported_formats = { + 'plist': PListDumper, + 'toml': TomlDumper, + } + if file_format in supported_formats: + logger.debug(f'Dumping shortcut to file format: {supported_formats}') + return supported_formats[file_format] + + raise RuntimeError(f'Unknown file_format: {file_format}') + + def _get_actions(self) -> List: + return [a.dumps() for a in self.actions] + + def _get_import_questions(self) -> List: + # todo: change me + return [] + + def _get_icon(self) -> Dict: + # todo: change me + return { + 'WFWorkflowIconGlyphNumber': 59511, + 'WFWorkflowIconImageData': plistlib.Data(b''), + 'WFWorkflowIconStartColor': 431817727, + } + + def _get_input_content_item_classes(self) -> List[str]: + # todo: change me + return [ + 'WFAppStoreAppContentItem', + 'WFArticleContentItem', + 'WFContactContentItem', + 'WFDateContentItem', + 'WFEmailAddressContentItem', + 'WFGenericFileContentItem', + 'WFImageContentItem', + 'WFiTunesProductContentItem', + 'WFLocationContentItem', + 'WFDCMapsLinkContentItem', + 'WFAVAssetContentItem', + 'WFPDFContentItem', + 'WFPhoneNumberContentItem', + 'WFRichTextContentItem', + 'WFSafariWebPageContentItem', + 'WFStringContentItem', + 'WFURLContentItem', + ] diff --git a/shortcuts/tests/__init__.py b/shortcuts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shortcuts/tests/actions/__init__.py b/shortcuts/tests/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shortcuts/tests/actions/base/__init__.py b/shortcuts/tests/actions/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shortcuts/tests/actions/base/tests.py b/shortcuts/tests/actions/base/tests.py new file mode 100644 index 0000000..3602636 --- /dev/null +++ b/shortcuts/tests/actions/base/tests.py @@ -0,0 +1,88 @@ +from actions.base import BaseAction +from actions.variables import SetVariableAction +from actions.out import ( + ShowResultAction, + ShowAlertAction, +) +from actions.web import URLAction + + +class TestBaseAction: + def test_get_parameters(self): + base_action = BaseAction() + base_action.type = '123' + dump = base_action.dumps() + + exp_dump = { + 'WFWorkflowActionIdentifier': base_action.type, + 'WFWorkflowActionParameters': {}, + } + assert dump == exp_dump + + +class TestSetVariable: + def test_get_parameters(self): + name = 'var' + set_action = SetVariableAction(data={'name': name}) + + dump = set_action._get_parameters() + + exp_dump = { + 'WFVariableName': name, + } + assert dump == exp_dump + + +class TestShowResultAction: + def test_get_parameters(self): + text = '{{v1}}##{{v2}}' + action = ShowResultAction(data={'text': text}) + + dump = action._get_parameters() + + exp_dump = { + 'Text': { + 'Value': { + 'attachmentsByRange': { + '{0, 1}': {'Type': 'Variable', 'VariableName': 'v1'}, + '{3, 1}': {'Type': 'Variable', 'VariableName': 'v2'}, + }, + 'string': '##', + }, + 'WFSerializationType': 'WFTextTokenString', + }, + 'WFSerializationType': 'WFTextTokenString', + } + assert dump == exp_dump + + +class TestShowAlertAction: + def test_get_parameters(self): + title = 'some title' + text = 'some text' + show_cancel_button = True + action = ShowAlertAction( + dict(title=title, text=text, show_cancel_button=show_cancel_button) + ) + + dump = action._get_parameters() + + exp_dump = { + 'WFAlertActionCancelButtonShown': show_cancel_button, + 'WFAlertActionMessage': text, + 'WFAlertActionTitle': title, + } + assert dump == exp_dump + + +class TestURLAction: + def test_get_parameters(self): + url = 'https://aleks.sh' + action = URLAction(data={'url': url}) + + dump = action._get_parameters() + + exp_dump = { + 'WFURLActionURL': url, + } + assert dump == exp_dump diff --git a/shortcuts/tests/actions/photo/__init__.py b/shortcuts/tests/actions/photo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shortcuts/tests/actions/photo/tests.py b/shortcuts/tests/actions/photo/tests.py new file mode 100644 index 0000000..1c23c4c --- /dev/null +++ b/shortcuts/tests/actions/photo/tests.py @@ -0,0 +1,24 @@ +from actions.photo import ( + CameraAction, + GetLastPhotoAction, + SelectPhotoAction, +) + + +class BasePhotoTest: + def test_get_parameters(self): + action = self.action_class() + dump = action._get_parameters() + assert dump == {} + + +class TestCameraAction(BasePhotoTest): + action_class = CameraAction + + +class TestGetLastPhotoAction(BasePhotoTest): + action_class = GetLastPhotoAction + + +class TestSelectPhotoAction(BasePhotoTest): + action_class = SelectPhotoAction diff --git a/shortcuts/tests/shortcuts/__init__.py b/shortcuts/tests/shortcuts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shortcuts/tests/shortcuts/tests.py b/shortcuts/tests/shortcuts/tests.py new file mode 100644 index 0000000..02b28bf --- /dev/null +++ b/shortcuts/tests/shortcuts/tests.py @@ -0,0 +1,78 @@ +from shortcuts import Shortcut +from actions.text import TextAction + + +class TestShortcutDumps: + def test_dumps_simple_shortcut(self): + sc = Shortcut(name='test') + + sc.actions = [ + TextAction(data={'text': 'simple text'}), + TextAction(data={'text': 'another text'}), + ] + + for action in sc.actions: + action.id = 'id' + + exp_dump = '\n\n\n\n\tWFWorkflowActions\n\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\tWFSerializationType\n\t\t\t\tWFTextTokenString\n\t\t\t\tWFTextActionText\n\t\t\t\tsimple text\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\tWFSerializationType\n\t\t\t\tWFTextTokenString\n\t\t\t\tWFTextActionText\n\t\t\t\tanother text\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' + assert sc.dumps() == exp_dump + + +class TestShortcutLoads: + def test_loads(self): + toml_string = ''' + [[action]] + type = "text" + text = "ping" + + [[action]] + type = "set_variable" + name = "variable" + + [[action]] + type = "show_result" + text = "My variable: {{variable}}" + ''' + sc = Shortcut.loads(toml_string) + + assert len(sc.actions) == 3 + + assert sc.actions[0].keyword == 'text' + assert sc.actions[0].data['text'] == 'ping' + assert sc.actions[0].type == 'is.workflow.actions.gettext' + + assert sc.actions[1].keyword == 'set_variable' + assert sc.actions[1].data['name'] == 'variable' + assert sc.actions[1].type == 'is.workflow.actions.setvariable' + + assert sc.actions[2].keyword == 'show_result' + assert sc.actions[2].data['text'] == 'My variable: {{variable}}' + assert sc.actions[2].type == 'is.workflow.actions.showresult' + + +class TestShortcutLoadsAndDumps: + def test_loads_and_dumps_with_not_all_params(self): + question = 'What is your name?' + toml_string = f''' + [[action]] + type = "ask" + question = "{question}" + ''' + + sc = Shortcut.loads(toml_string) + + assert len(sc.actions) == 1 + + action = sc.actions[0] + + assert action.keyword == 'ask' + assert action.data['question'] == question + assert action.type == 'is.workflow.actions.ask' + + assert action.data == {'question': question} + + dump = sc.dumps() + + 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' + + assert dump == exp_dump diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..fb87e54 --- /dev/null +++ b/tox.ini @@ -0,0 +1,25 @@ +[tox] +envlist = lint,py37 +skipsdist = True + +[testenv] +whitelist_externals = pipenv +install_command = pipenv update {opts} {packages} +deps = --dev +commands = + pytest + +[lint] +deps = + flake8 + mypy + +[testenv:lint] +commands = flake8 src +deps = {[lint]deps} +envdir = {toxworkdir}/lint + +[testenv:mypy] +commands = mypy src +deps = {[lint]deps} +envdir = {toxworkdir}/lint