├── .flake8 ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── aquaui ├── __init__.py ├── alert.py ├── choice.py ├── color_picker.py ├── dialog.py ├── notification │ ├── fallback_notification.py │ └── native_notification.py ├── types │ ├── buttons.py │ └── result.py └── utils.py ├── assets ├── Terminal.icns ├── folder.png └── icon.svg ├── docs ├── 1-dialog.md ├── 2-choice.md ├── 3-notification.md ├── 4-color_picker.md ├── 5-alert.md ├── README.md └── other.md ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── test_alert.py ├── test_buttons.py ├── test_choice.py ├── test_color_picker.py ├── test_dialog.py └── test_fallback_notification.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E266, E501, W503, E722 3 | max-line-length = 120 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | tests: 6 | runs-on: macos-11.0 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-python@v1 10 | with: 11 | python-version: 3.7 12 | - uses: Gr1N/setup-poetry@v4 13 | with: 14 | poetry-version: 1.1.4 15 | 16 | - name: Install dependencies 17 | run: poetry install 18 | 19 | - name: Run tests with pytest 20 | run: poetry run pytest 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | dist 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | 142 | .vscode 143 | playground.py -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: stable 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | - repo: https://gitlab.com/pycqa/flake8 8 | rev: 3.7.9 9 | hooks: 10 | - id: flake8 -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for deciding to contribute! 4 | 5 | Unless the change you wish to make is minor (fixing a typo for example), please [open an issue](https://github.com/ninest/aquaui/issues/new) or [start a discussion](https://github.com/ninest/aquaui/discussions). 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ninest 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | aquaui 5 |

6 | 7 |

8 | Display native dialogs, alerts, notifications, color pickers, and more with Python 9 |

10 | 11 |

12 | GitHub Workflow Status 13 | 14 | 15 | Version 16 | 17 | 18 | PyPI - Downloads 19 | 20 | 21 | MIT 22 | 23 | 24 | Buy Me A Coffee 25 | 26 |

27 | 28 | **💥 This library is still a work in progress.** 29 | 30 | ## Useful links 31 | 32 | - [Documentation](https://github.com/ninest/aquaui/tree/master/docs) 33 | - [Examples](https://github.com/ninest/aquaui#examples) 34 | - [Discussions](https://github.com/ninest/aquaui/discussions) 35 | 36 | ## Features 37 | 38 | - [x] Display dialogs 39 | - [x] Dialog prompts 40 | - [x] Icon support 41 | - [x] Alerts 42 | - [x] Choice dialogs 43 | - [ ] Notifications 44 | - [x] Customize title, subtitle, and informational text 45 | - [x] Customize icon 46 | - [x] Schedulable 47 | - [ ] Callbacks (button pressed, reply text) – [relevant stackoverflow answer](https://stackoverflow.com/a/62248246/8677167) 48 | - [x] Fallback (AppleScript) notifications 49 | - [ ] Color picker 50 | - [ ] File/folder picker 51 | 52 | ## Documentation 53 | 54 | [**Find the documentation in the `docs/` folder**](https://github.com/ninest/aquaui/tree/master/docs) 55 | 56 | ## Examples 57 | 58 | See the `examples/` directory. Feel free to make a pull request to add more examples. 59 | 60 | **Show a dialog with the buttons "Go" (default) and "No" (to cancel) with the caution icon:** 61 | 62 | ```py 63 | from aquaui import Dialog, Buttons, Icon 64 | 65 | buttons = Buttons(["Go", "No"], default_button="Go", cancel_button="No") 66 | result = Dialog("Hello!").with_buttons(buttons).with_icon(Icon.CAUTION).show() 67 | ``` 68 | 69 | **Execute functions based on the button clicked:** 70 | 71 | ```py 72 | from aquaui import Dialog, Buttons 73 | 74 | button_one = "One" 75 | button_two = "Two" 76 | buttons = Buttons([button_one, button_two]) 77 | 78 | result = Dialog("Press a button").with_buttons(buttons).show() 79 | 80 | if result.button_returned == button_one: 81 | print("Button One was pressed") 82 | elif result.button_returned == button_two: 83 | print("Button Two was pressed") 84 | ``` 85 | 86 | **Display a choice dialog with the options "Netflix" and "Prime Video"** 87 | 88 | ```py 89 | from aquaui import Choice 90 | 91 | provider = Choice("Choose the streaming platform").with_choices(["Netflix", "Prime Video"]).show() 92 | print(provider) 93 | ``` 94 | 95 | If this example interests you, check out my other library [Flixpy](https://github.com/ninest/flixpy). 96 | 97 | **Display a notification:** 98 | 99 | Warning: please read the [documentation](./docs/3-notification.md) before using notifications. There are additional dependencies to install. 100 | 101 | ```py 102 | from aquaui.notification.native_notification import Notification 103 | 104 | notification = ( 105 | Notification("Hello!") 106 | .with_subtitle("This is the subtitle!") 107 | .with_informative_text("Isn't this informative?") 108 | .with_identity_image("assets/folder.png") # the image on the right of the notification 109 | .send() 110 | ) 111 | ``` 112 | 113 | **Schedule a notification:** 114 | 115 | ```py 116 | from aquaui.notification.native_notification import Notification 117 | 118 | notification = Notification("Your pizza is here!").with_delay(15).send() 119 | # 15 seconds delay 120 | ``` 121 | 122 | ## Build setup 123 | 124 | Clone or fork the repository, then run 125 | 126 | ```bash 127 | poetry shell 128 | 129 | poetry install 130 | pre-commit install 131 | ``` 132 | 133 | Make changes, then run tests with 134 | 135 | ```bash 136 | pytest tests 137 | ``` 138 | 139 | Ensure that all tests pass. 140 | 141 |
142 | 143 | Recommended editor settings 144 | 145 | 146 | ```json 147 | { 148 | "python.formatting.provider": "black", 149 | "editor.formatOnSave": true, 150 | "[python]": { 151 | "editor.insertSpaces": true, 152 | "editor.detectIndentation": false, 153 | "editor.tabSize": 4 154 | }, 155 | "python.linting.enabled": true, 156 | "python.linting.flake8Enabled": true, 157 | "python.linting.pylintEnabled": false, 158 | "python.pythonPath": "/Users/yourusername/.../aquaui-UIHDsdfS-py3.7" 159 | } 160 | ``` 161 | 162 |
163 | 164 | ## License 165 | 166 | MIT 167 | -------------------------------------------------------------------------------- /aquaui/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1-2" 2 | 3 | from aquaui.types.buttons import Buttons 4 | from aquaui.dialog import Dialog, Icon 5 | from aquaui.choice import Choice 6 | from aquaui.alert import Alert, AlertType 7 | from aquaui.color_picker import ColorPicker 8 | 9 | __all__ = [ 10 | "Buttons", 11 | "Dialog", 12 | "Icon", 13 | "Choice", 14 | "Alert", 15 | "AlertType", 16 | "ColorPicker", 17 | ] 18 | -------------------------------------------------------------------------------- /aquaui/alert.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | from typing import Union 3 | from aquaui.utils import quotify, run_applescript 4 | from .types.buttons import Buttons 5 | from .types.result import Result 6 | 7 | 8 | @unique 9 | class AlertType(Enum): 10 | INFORMATIONAL = "informational" 11 | CRITICAL = "critical" 12 | 13 | """ 14 | WARNING = "warning" 15 | Warning seems to be the same as informational on Big Sur 16 | """ 17 | 18 | 19 | class Alert: 20 | """ 21 | Returns an object of type Result, which as a button_returned property. 22 | """ 23 | 24 | def __init__(self, title: str) -> None: 25 | self.applescript = f"display alert {quotify(title)} " 26 | 27 | def with_buttons(self, buttons: Union[Buttons, None] = None): 28 | """ 29 | If a default button is not specified, the last button is the list will become the default. 30 | 31 | If buttons is None, default buttons are displayed 32 | """ 33 | 34 | if buttons is not None: 35 | self.applescript += f"{buttons.applescript_fragment}" 36 | 37 | return self 38 | 39 | def of_type(self, alert_type: AlertType = AlertType.INFORMATIONAL): 40 | """Different alert types use different icons""" 41 | 42 | self.applescript += f"as {alert_type.value} " 43 | return self 44 | 45 | def show(self) -> Result: 46 | try: 47 | return Result(run_applescript(self.applescript)) 48 | except: 49 | return Result.escaped() 50 | -------------------------------------------------------------------------------- /aquaui/choice.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from .utils import quotify, run_applescript 3 | 4 | 5 | class Choice: 6 | def __init__(self, title: str) -> None: 7 | self.title = title 8 | self.applescript = "choose from list " 9 | 10 | def with_choices(self, choices: List[str] = []): 11 | self.choices = choices 12 | string_choices = "{%s}" % ", ".join(map(quotify, self.choices)) 13 | self.applescript += f"{string_choices} " 14 | 15 | # Set title before showing: first choices need to be defined 16 | if self.title is not None: 17 | self.applescript += f"with prompt {quotify(self.title)} " 18 | 19 | return self 20 | 21 | def default_choice(self, default_choice: Union[str, None] = None): 22 | if default_choice is not None: 23 | if default_choice not in self.choices: 24 | raise Exception("The default_choice must be in choices") 25 | self.applescript += f"default items {{ {quotify(default_choice)} }} " 26 | 27 | return self 28 | 29 | def show(self) -> str: 30 | result = run_applescript(self.applescript).strip() 31 | if result == "false": 32 | result = "" 33 | return result 34 | -------------------------------------------------------------------------------- /aquaui/color_picker.py: -------------------------------------------------------------------------------- 1 | from aquaui.utils import run_applescript 2 | from typing import List, Union 3 | 4 | 5 | class ColorPicker: 6 | def __init__(self) -> None: 7 | self.applescript = "choose color " 8 | 9 | def with_default_color(self, color: List[int]): 10 | if color is not None: 11 | color_list = ", ".join(str(val) for val in color) 12 | self.applescript += f"default color {{ {color_list} }} " 13 | 14 | return self 15 | 16 | def show(self) -> Union[List[int], None]: 17 | try: 18 | result = run_applescript(self.applescript) 19 | color_array = list(map(int, result.replace("\n", "").split(", "))) 20 | return color_array 21 | except: 22 | # If cancelled using escape key 23 | return None 24 | -------------------------------------------------------------------------------- /aquaui/dialog.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from enum import Enum, unique 3 | from .types.buttons import Buttons 4 | from .types.result import Result 5 | from .utils import quotify, run_applescript 6 | 7 | 8 | @unique 9 | class Icon(Enum): 10 | NOTE = "note" 11 | CAUTION = "caution" 12 | STOP = "stop" 13 | 14 | 15 | class Dialog: 16 | def __init__(self, text: str) -> None: 17 | """Start generation of applescript for dialog""" 18 | 19 | self.applescript = f"display dialog {quotify(text)} " 20 | 21 | def with_title(self, title: str): 22 | self.applescript += f"with title {quotify(title)} " 23 | return self 24 | 25 | def with_buttons(self, buttons: Union[Buttons, None] = None): 26 | """If buttons is None, default buttons are displayed""" 27 | 28 | if buttons is not None: 29 | self.applescript += f"{buttons.applescript_fragment} " 30 | 31 | return self 32 | 33 | def with_icon(self, icon: Union[str, Icon] = None): 34 | """Use custom icons or built-in icons""" 35 | 36 | applescript_fragment: str 37 | if icon is not None: 38 | if isinstance(icon, str): 39 | # TODO: also support absolute file paths 40 | icon_path = icon 41 | applescript_fragment = f"with icon POSIX file {quotify(icon_path)}" 42 | 43 | # applescript built in icon 44 | elif isinstance(icon, Icon): 45 | applescript_fragment = f"with icon {icon.value}" 46 | 47 | else: 48 | raise Exception("Incorrect datatype for property icon") 49 | 50 | self.applescript += f"{applescript_fragment} " 51 | 52 | return self 53 | 54 | def with_input(self, default_response: str = ""): 55 | """Specify default answer""" 56 | 57 | self.applescript += f"default answer {quotify(default_response)} " 58 | return self 59 | 60 | def show(self): 61 | try: 62 | return Result(run_applescript(self.applescript)) 63 | except: 64 | # On some cases, the dislog can be dismissed with the escape key, 65 | # and an error is thrown 66 | return Result.escaped() 67 | -------------------------------------------------------------------------------- /aquaui/notification/fallback_notification.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from ..utils import quotify, run_applescript 3 | 4 | 5 | class ApplescriptNotification: 6 | def __init__(self, text: Union[str, None] = None) -> None: 7 | 8 | self.applescript = "display notification " 9 | if text is not None: 10 | self.applescript += f"{quotify(text)} " 11 | 12 | def with_title(self, title): 13 | if title is not None: 14 | self.applescript += f"with title {quotify(title)} " 15 | 16 | return self 17 | 18 | def with_subtitle(self, subtitle: Union[str, None]): 19 | if subtitle is not None: 20 | self.applescript += f"subtitle {quotify(subtitle)} " 21 | 22 | return self 23 | 24 | def send(self): 25 | return run_applescript(self.applescript, no_return=True) 26 | -------------------------------------------------------------------------------- /aquaui/notification/native_notification.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import os 3 | 4 | try: 5 | import objc 6 | import Foundation 7 | 8 | # This import is required for NSImage 9 | import AppKit # noqa: 5401 10 | except ImportError: 11 | raise Exception( 12 | """To use native notifications, you need to install the following dependencies: 13 | - pyobjc-core 14 | - pyobjc-framework-NotificationCenter 15 | - pyobjc-framework-UserNotifications 16 | - pyobjc-framework-UserNotificationsUI 17 | 18 | If you are unable to do so, import and use ApplescriptNotification instead: 19 | 20 | from aquaui.notification.fallback_notification import ApplescriptNotification 21 | """ 22 | ) 23 | 24 | from .fallback_notification import ApplescriptNotification 25 | 26 | NSUserNotification = objc.lookUpClass("NSUserNotification") # type: ignore 27 | NSUserNotificationCenter = objc.lookUpClass("NSUserNotificationCenter") # type: ignore 28 | NSUrl = objc.lookUpClass("NSURL") # type: ignore 29 | NSImage = objc.lookUpClass("NSImage") # type: ignore 30 | 31 | 32 | class Notification: 33 | """Show a notification with a title, subtitle, info text, image, delay, and sound""" 34 | 35 | def __init__(self, text: Union[str, None] = None) -> None: 36 | """ 37 | info text is the third (last) line 38 | """ 39 | 40 | self.notification = NSUserNotification.alloc().init() 41 | 42 | if text is not None: 43 | self.notification.setInformativeText_(text) 44 | self.text = text 45 | 46 | def with_subtitle(self, subtitle: str): 47 | """ 48 | subtitle is in the second line 49 | """ 50 | 51 | self.notification.setSubtitle_(subtitle) 52 | self.subtitle = subtitle 53 | return self 54 | 55 | def with_title(self, title: str): 56 | """ 57 | title is the large text at the top of the notification, not required 58 | """ 59 | 60 | self.notification.setTitle_(title) 61 | self.title = title 62 | return self 63 | 64 | def _create_image(self, image_path: str): 65 | """Create an image for identity of content image""" 66 | 67 | path = f"file:{os.getcwd()}/{image_path}" 68 | url = NSUrl.alloc().initWithString_(path) 69 | image = NSImage.alloc().initWithContentsOfURL_(url) 70 | 71 | return image 72 | 73 | def with_identity_image(self, identity_image_path: Union[str, None] = None): 74 | """Image on the right side of the notification""" 75 | 76 | if identity_image_path is not None: 77 | image = self._create_image(identity_image_path) 78 | self.notification.set_identityImage_(image) 79 | 80 | return self 81 | 82 | def _with_content_image(self, content_image_path: Union[str, None] = None): 83 | """Image on the left side of the notification, but does not seem to be working on Big Sur""" 84 | 85 | if content_image_path is not None: 86 | image = self._create_image(content_image_path) 87 | self.notification.setContentImage_(image) 88 | 89 | return self 90 | 91 | def with_delay(self, delay: int = 0): 92 | """The delay in second between .send() and the notification being shown""" 93 | 94 | self.notification.setDeliveryDate_( 95 | Foundation.NSDate.dateWithTimeInterval_sinceDate_(delay, Foundation.NSDate.date()) # type: ignore 96 | ) 97 | return self 98 | 99 | def send(self) -> Union[None, str]: 100 | try: 101 | NSUserNotificationCenter.defaultUserNotificationCenter().scheduleNotification_(self.notification) 102 | except: 103 | return ApplescriptNotification(self.text).with_subtitle(self.subtitle).with_title(self.title).send() 104 | -------------------------------------------------------------------------------- /aquaui/types/buttons.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from ..utils import quotify 3 | 4 | 5 | class Buttons: 6 | """Provide the buttons, default button, and cancel button for dialogs and alerts""" 7 | 8 | def __init__( 9 | self, 10 | buttons: List[str] = [], 11 | default_button: Union[str, None] = None, 12 | cancel_button: Union[str, None] = None, 13 | ) -> None: 14 | # Maximum 3 buttons 15 | if len(buttons) > 3: 16 | raise Exception("There can be a maximum of 3 buttons only") 17 | 18 | if (default_button is not None) and (default_button not in buttons): 19 | raise Exception("Default Button not in buttons list") 20 | 21 | if (cancel_button is not None) and (cancel_button not in buttons): 22 | raise Exception("Cancel Button not in buttons list") 23 | 24 | self.buttons = buttons 25 | self.default_button = default_button 26 | self.cancel_button = cancel_button 27 | 28 | @property 29 | def string(self) -> str: 30 | """Get a string of all buttons""" 31 | return ", ".join(map(quotify, self.buttons)) 32 | 33 | @property 34 | def applescript_fragment(self) -> str: 35 | """Generate the applescript fragment for the button""" 36 | 37 | script_fragment = f"buttons {{ {self.string} }} " 38 | 39 | if self.default_button is not None: 40 | script_fragment += f"default button {quotify(self.default_button)} " 41 | if self.cancel_button is not None: 42 | script_fragment += f"cancel button {quotify(self.cancel_button)} " 43 | 44 | return script_fragment 45 | -------------------------------------------------------------------------------- /aquaui/types/result.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | 4 | class Result: 5 | def __init__(self, string_result: Union[str, None], cancelled=False) -> None: 6 | self.button_returned: str = "" 7 | self.text_returned: str = "" 8 | self.cancelled = cancelled 9 | 10 | # Set attributes based on output 11 | if string_result is not None: 12 | for data in string_result.split(","): 13 | data = data.strip() 14 | 15 | key, value = data.split(":") 16 | key = key.replace(" ", "_") 17 | 18 | # setattr(self, key, value) 19 | if key == "button_returned": 20 | self.button_returned = value 21 | elif key == "text_returned": 22 | self.text_returned = value 23 | 24 | @staticmethod 25 | def escaped(): 26 | """Used when dialog is cancelled with escape key""" 27 | 28 | return Result(None, cancelled=True) 29 | -------------------------------------------------------------------------------- /aquaui/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from typing import List, Union 3 | 4 | 5 | def quotify(string): 6 | """Return the string with quotes 7 | 8 | >>> quotify("some string") 9 | '"some string"' 10 | """ 11 | 12 | return f'"{string}"' 13 | 14 | 15 | def run_command(command: Union[str, List]): 16 | """Run a command on the terminal and return the output""" 17 | 18 | result = subprocess.check_output(command) 19 | return result.decode("utf-8") 20 | 21 | 22 | def run_applescript(script: str, no_return: bool = False): 23 | """Run an applescript 24 | 25 | Set no_return to True to run an AppleScript that doesn't show a response, such as notifications 26 | """ 27 | 28 | # Escape quotes 29 | # script = script.replace('"', '\\"') 30 | 31 | command = ["osascript", "-e"] 32 | 33 | # Notifications don't get an answer in applescrip 34 | if no_return: 35 | command.append(script) 36 | else: 37 | command.append(f"set answer to {script}\nreturn answer") 38 | 39 | return run_command(command) 40 | -------------------------------------------------------------------------------- /assets/Terminal.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninest/aquaui/0ed14822e4d6a76ac7d38aec78a044c71364349f/assets/Terminal.icns -------------------------------------------------------------------------------- /assets/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninest/aquaui/0ed14822e4d6a76ac7d38aec78a044c71364349f/assets/folder.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/1-dialog.md: -------------------------------------------------------------------------------- 1 | # Dialog 2 | 3 | ```py 4 | from aquaui import Dialog 5 | 6 | the_dialog = Dialog("This is the dialog title") 7 | the_dialog.show() 8 | ``` 9 | 10 | ## Parameters 11 | 12 | - `text`: the text shown in the dialog 13 | 14 | ## Functions 15 | 16 | These are chainable functions to use on a `Dialog` object. See the [examples](https://github.com/ninest/aquaui#examples) too. 17 | 18 | ### `.show()` 19 | 20 | Display the dialog and return a result (see `.with_buttons` and `.with_input`). 21 | 22 | ```py 23 | from aquaui import Dialog 24 | 25 | the_dialog = Dialog("This is the dialog title") 26 | the_dialog.show() 27 | ``` 28 | 29 | Note that the above code is the same as 30 | 31 | ```py 32 | from aquaui import Dialog 33 | 34 | the_dialog = Dialog("This is the dialog title").show() 35 | ``` 36 | 37 | ### `.with_title(title: str)` 38 | 39 | The title of the dialog window, shown at the top. 40 | 41 | ```py 42 | from aquaui import Dialog 43 | 44 | 45 | Dialog("Please open your folder").with_title("Important notification").show() 46 | ``` 47 | 48 | ### `.with_buttons(buttons: Buttons)` 49 | 50 | A list of buttons along with the default and cancel button can be specified. 51 | 52 | ```py 53 | from aquaui import Dialog, Buttons 54 | 55 | buttons = Buttons(["Enter", "Exit"]) 56 | ``` 57 | 58 | The default button is highlighted: 59 | 60 | ```py 61 | buttons = Buttons(["Enter", "Exit"], default_button="Enter") 62 | ``` 63 | 64 | Setting `default_button` and `cancel_button` is optional. Note that if a button is specified, its string needs to be in the list. 65 | 66 | This code will **throw an error**: 67 | 68 | ```py 69 | buttons = Buttons(["Enter", "Exit"], default_button="Go") 70 | # => Exception: Default Button not in buttons list 71 | 72 | buttons_2 = Buttons(["Enter", "Exit"], default_button="Enter", cancel_button="No") 73 | # => Exception: Cancel Button not in buttons list 74 | ``` 75 | 76 | Once the buttons have been defined, pass them into the dialog with the `.with_buttons` chainable function: 77 | 78 | ```py 79 | from aquaui import Dialog 80 | 81 | buttons = Buttons(["Enter", "Exit"]) 82 | the_dialog = Dialog("This is the dialog title").with_buttons(buttons) 83 | ``` 84 | 85 | Finally, display the dialog with `.show()`: 86 | 87 | ```py 88 | from aquaui import Dialog 89 | 90 | buttons = Buttons(["Enter", "Exit"]) 91 | the_dialog = Dialog("This is the dialog title").with_buttons(buttons).show() 92 | ``` 93 | 94 | Note that the above code is the same as 95 | 96 | ```py 97 | from aquaui import Dialog 98 | 99 | buttons = Buttons(["Enter", "Exit"]) 100 | the_dialog = Dialog("This is the dialog title") 101 | the_dialog.with_buttons(buttons) 102 | the_dialog.show() 103 | ``` 104 | 105 | The `.show()` function will return a `Result` object which contains the `button_returned` (and `text_returned` if it's a dialog with an input – see `.with_input`) 106 | 107 | ```py 108 | from aquaui import Dialog 109 | 110 | buttons = Buttons(["Enter", "Exit"]) 111 | the_dialog = Dialog("This is the dialog title") 112 | the_dialog.with_buttons(buttons) 113 | result = the_dialog.show() 114 | 115 | print(result.button_returned) # => a string of the button pressed 116 | # Either "Enter" or "Exit" 117 | ``` 118 | 119 | ### `.with_icon(icon: str ath of Icon)`: 120 | 121 | Specify a relative file path for an icon, or use a built-in icon (with the `Icon` enum). 122 | 123 | ```py 124 | ... 125 | the_dialog.with_icon("assets/folder.png") 126 | ... 127 | ``` 128 | 129 | Or use a built-in icon: 130 | 131 | ```py 132 | from aquaui import ..., Icon 133 | ... 134 | the_dialog.with_icon(Icon.CAUTION) 135 | # availabile icons: Icon.NOTE, icon.CAUTION, Icon.STOP 136 | ... 137 | ``` 138 | 139 | Note that absolute paths for icons are not yet supported. 140 | 141 | ### `.with_input(default_answer: str or None)` 142 | 143 | Specified that the dialog should have a text box: 144 | 145 | ```py 146 | from aquaui import Dialog 147 | 148 | buttons = Buttons(["Enter", "Exit"]) 149 | the_dialog = Dialog("This is the dialog title") 150 | the_dialog.with_buttons(buttons) 151 | the_dialog.with_input() 152 | 153 | result = the_dialog.show() 154 | 155 | result.button_returned # => string of button pressed 156 | result.text_returned # => text entered in input 157 | ``` 158 | 159 | A default value can be provided: 160 | 161 | ```py 162 | ... 163 | 164 | the_dialog.with_input("default text in textbox") 165 | 166 | ... 167 | ``` 168 | -------------------------------------------------------------------------------- /docs/2-choice.md: -------------------------------------------------------------------------------- 1 | # Choice 2 | 3 | ```py 4 | from aquaui import Choice 5 | 6 | the_choice = Choice("Choose below").with_choices(["One", "Two", "Three"]).show() 7 | 8 | print(the_choice) # => Choice selected ("One", "Two", or "Three"), or false if none selected 9 | ``` 10 | 11 | Display a dialog with a list of choices to choose from. 12 | 13 | ## Parameters 14 | 15 | - `title`: The title of the choice selection dialog 16 | 17 | ## Functions 18 | 19 | These are chainable functions to use on a `Choice` object. See the [examples](https://github.com/ninest/aquaui#examples) too. 20 | 21 | ### `.with_choices(choices: List[str])` 22 | 23 | A list of choices for the dialog. 24 | 25 | ```py 26 | from aquaui import Choice 27 | 28 | the_choice = Choice("Choose below") 29 | the_choice.with_choices(["One", "Two", "Three"]) 30 | the_choice.show() 31 | ``` 32 | 33 | ### `.default_choice(choice: str)` 34 | 35 | The default choice. 36 | 37 | ```py 38 | from aquaui import Choice 39 | 40 | the_choice = Choice("Choose below") 41 | the_choice.with_choices(["One", "Two", "Three"]) 42 | the_choice.default_choice("One") 43 | the_choice.show() 44 | ``` 45 | 46 | The default choice has to be in the list passed in to `.with_choices`, otherwise an error is thrown. 47 | 48 | ### `.show()` 49 | 50 | Shows the choice dialog and returns the text selected, or `""` (empty string) if nothing was selected. 51 | 52 | ```py 53 | from aquaui import Choice 54 | 55 | the_choice = Choice("Choose below").with_choices(["One", "Two", "Three"]).default_choice("One").show() 56 | 57 | if the_choice: 58 | print(f"{the_choice} was selected") 59 | else: 60 | print("Nothing was selected") 61 | ``` 62 | 63 |
64 | 65 | How to check if a string is empty? 66 | 67 | 68 | ```py 69 | my_string = "" 70 | if my_string: 71 | # string not empty 72 | else: 73 | # string empty :( 74 | ``` 75 | 76 |
77 | -------------------------------------------------------------------------------- /docs/3-notification.md: -------------------------------------------------------------------------------- 1 | # Notification 2 | 3 | Notifications are imported in a different manner: 4 | 5 | ```py 6 | from aquaui.notification.native_notification import Notification 7 | ``` 8 | 9 | To use notifications, the following dependencies are required: 10 | 11 | - `pyobjc-core` 12 | - `pyobjc-framework-NotificationCenter` 13 | - `pyobjc-framework-UserNotifications` 14 | - `pyobjc-framework-UserNotificationsUI` 15 | 16 | If you are unable to or cannot install them, use `ApplescriptNotification` instead: 17 | 18 | ```py 19 | from aquaui import ApplescriptNotification 20 | ``` 21 | 22 | A native notification with a customizable title, subtitle, and informational text. In some cases, and icon and delay can also be set.\* 23 | 24 | ## Parameters 25 | 26 | - `text`: The notification's body text 27 | 28 | ## Functions 29 | 30 | Similarly, these are also all chainable functions 31 | 32 | ### `.with_title(title: str)` 33 | 34 | The notification's title, the first line. 35 | 36 | Note that the notification's title does **not** change the notification's app name. The notification's app name defaults to "Python", and the only way to change it is by packaging your app with `py2app` and setting the icon. 37 | 38 | ### `.with_subtitle(subtitle: str)` 39 | 40 | Set the subtitle of the notification (second line). 41 | 42 | ### `.with_informative_text(info_text: str)` \* 43 | 44 | Set the informational text of the notification (third line). 45 | 46 | ### `.with_identity_image(image_path: str)` \* 47 | 48 | Set the image on the right side of the notification. This does **not** change the "app image", which defaults to the Python Rocket. This Rocket image can only change if you package your app with `py2app` and set a custom icon. 49 | 50 | ### `.with_delay(delay: int)` \* 51 | 52 | Set the delay in seconds between when `.send()` is called and the notification being shown. 53 | 54 | ### `.send()` 55 | 56 | Send the notification. 57 | 58 | \* Please see this [stackoverflow answer](https://stackoverflow.com/a/62248246/8677167). If your mac is unable to send notification from Python for an reason, aquaui will fallback to AppleScript notifications, where only the **title** and **subtitle** can be specified. The icon will be that of the AppleScript editor app. 59 | 60 | ```py 61 | from aquaui import Notification 62 | 63 | notification = ( 64 | Notification("Hello!") 65 | .with_subtitle("This is the subtitle!") 66 | .with_informative_text("Isn't this informative?") 67 | .with_identity_image("assets/folder.png") # the image on the right of the notification 68 | .send() 69 | ) 70 | ``` 71 | 72 | ```py 73 | from aquaui import Notification 74 | 75 | notification = Notification("Your pizza is here!").with_delay(15).send() 76 | # 15 seconds delay 77 | ``` 78 | 79 | #### Fallback 80 | 81 | In some cases, the native notification cannot be displayed, so the fallback notification will be sent instead. This fallback notification can only have the title, subtitle, and informational text. It **cannot** have an icon or delay. 82 | 83 | - TBD 84 | -------------------------------------------------------------------------------- /docs/4-color_picker.md: -------------------------------------------------------------------------------- 1 | # Color picker 2 | 3 | WIP! 4 | 5 | ```py 6 | from aquaui import ColorPicker 7 | 8 | color = ColorPicker().show() 9 | ``` 10 | 11 | ## Functions 12 | 13 | ### `.with_default_color(color: List[int])` 14 | 15 | Note: the color is NOT in the RGB 255, 255, 255 format. 16 | 17 | ```py 18 | from aquaui import ColorPicker 19 | 20 | c = ColorPicker().with_default_color([0, 0, 0]).show() 21 | print(c) 22 | ``` 23 | 24 | ### `.show()` 25 | 26 | Show the color picker. If the color picker is cancelled, `None` is returned. If a color is picked, the color as a list of integers is returned 27 | -------------------------------------------------------------------------------- /docs/5-alert.md: -------------------------------------------------------------------------------- 1 | # Alert 2 | 3 | Display an alert. Similar to a dialog, but larger, and no text field. 4 | 5 | ## Parameters 6 | 7 | - `title`: The title of the alert 8 | 9 | ## Functions 10 | 11 | ### `.with_buttons(buttons: Buttons)` 12 | 13 | ```py 14 | from aquaui import Alert, Buttons 15 | 16 | al = Alert("Hello").with_buttons(Buttons(["One", "Two", "Three"], default_button="One")).show() 17 | ``` 18 | 19 | If not `default_button` is specified, the last button will become the default. See the [documentation for Dialogs](https://github.com/ninest/aquaui/blob/master/docs/1-dialog.md#with_buttonsbuttons-buttons) too, as Alerts are very similar. 20 | 21 | ### `of_type(AlertType)` 22 | 23 | The possible alert types are `AlertType.INFORMATIONAL` and `AlertType.CRITICAL`. They change the icon shown on the alert. It is _not possible_ to display a custom icon here. 24 | 25 | ```py 26 | # AlertType has to be imported before using: 27 | from aquaui import Alert, Buttons, AlertType 28 | 29 | al = Alert("Hello").with_buttons(Buttons(["One", "Two",]) 30 | al.of_type(AlertType.INFORMATIONAL) 31 | al.show() 32 | ``` 33 | 34 | ### `.show()` 35 | 36 | Show the dialog, and return a `Result`. The result as a property of `button_returned`. 37 | 38 | ```py 39 | # AlertType has to be imported before using: 40 | from aquaui import Alert, Buttons, AlertType 41 | 42 | al = Alert("Hello").with_buttons(Buttons(["One", "Two",]).show() 43 | 44 | al.button_returned # => either "One" or "Two" 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## Contents 4 | 5 | 1. [Dialog](./1-dialog.md) 6 | 2. [Choice](./2-choice.md) 7 | 3. [Notification](./3-notification.md) 8 | 4. [Color picker](./4-color_picker.md) 9 | 5. [Alert](./5-alert.md) 10 | 6. [Examples](https://github.com/ninest/aquaui#Examples) 11 | 12 | ## Installation 13 | 14 | ```bash 15 | pip3 install aquaui 16 | ``` 17 | 18 | ## Basic usage 19 | 20 | Show a dialog with two buttons **Go** and **No**: 21 | 22 | ```py 23 | from aquaui import Dialog, Buttons, Icon 24 | 25 | buttons = Buttons(["Go", "No"], default_button="Go", cancel_button="No") 26 | result = Dialog("Hello!").with_buttons(buttons).with_icon(Icon.CAUTION).show() 27 | 28 | print(result) 29 | ``` 30 | 31 | ## Examples 32 | 33 | View examples on the [README](https://github.com/ninest/aquaui#Examples) or in the `examples/` directory. 34 | 35 | ## FAQ 36 | 37 | 1. Why can't I specify a callback for notifications? 38 | 39 | Read this [answer on stackoverflow](https://stackoverflow.com/a/62248246/8677167). 40 | 41 | 2. Why chained functions and classes and not a single function? 42 | 43 | I had initially used regular function, and the code looked [like this](https://github.com/ninest/aquaui/blob/f4b35f05b0b5689f22e35bfe1d0af30599db7adc/as.py#L5): 44 | 45 | ```py 46 | def dialog_prompt(text, default_answer="", buttons=["Cancel", "Continue"], default_button=None, cancel_button=None, icon=None, password=False): 47 | ... 48 | ``` 49 | 50 | As you can see, this is a little confusing. But more than that, it was difficult to test. If I wanted to get the output AppleScript, I'd have to add a flag to the function like 51 | 52 | ```py 53 | def dialog_prompt(......, return_applescript=False): 54 | ... 55 | if return_applescript: 56 | return as 57 | ... 58 | ``` 59 | 60 | This, to an extent is also fine, but there's an issue with _type safety_. I wanted to add types to all functions to make it easier to code faster. So for the above function, adding types will look something like this: 61 | 62 | ```py 63 | def dialog_prompt(..., return_applescript=False) -> Union[Result, None]: 64 | ... 65 | 66 | response = dialog_prompt(...) 67 | answer = response.text_returned.strip() 68 | ``` 69 | 70 | This looks fine, right? Althought it runs fine, your code editor will fight with you. You know that the function returns a `Result` object, but your editor does not. It thinkgs it is possibly `None`, so you have to do something like this **all the time**: 71 | 72 | ```py 73 | ... 74 | 75 | response = dialog_prompt(...) 76 | answer = response.text_returned.strip() 77 | 78 | if response is not None: 79 | answer = response.text_returned.strip() 80 | ``` 81 | 82 | With chained functions, it's easy to do something like 83 | 84 | ```py 85 | response = Dialog(..).with_input().show() 86 | 87 | # for testing purposes 88 | output_applescript = Dialog(..).with_input().applescript 89 | ``` 90 | 91 | 3. What does the name mean? 92 | 93 | This library was made for macos dialogs, and [Aqua]() is the GUI. 94 | -------------------------------------------------------------------------------- /docs/other.md: -------------------------------------------------------------------------------- 1 | # Other important things 2 | 3 | ## Escaping quotes 4 | 5 | Single quotes work fine. 6 | 7 | ```py 8 | from aquaui import Alert 9 | 10 | Alert("This is a 'quote'").show() 11 | ``` 12 | 13 | For double-quotes, however, you have to escape them by inserting not one, but **two** backslashes. You also have to use single-quotes for the string: 14 | 15 | ```py 16 | Alert('This is a \\"quote\\"').show() 17 | ``` 18 | 19 | This may be changed and made simpler in the future. 20 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.4.0" 12 | description = "Atomic file writes." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "20.3.0" 20 | description = "Classes Without Boilerplate" 21 | category = "dev" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 24 | 25 | [package.extras] 26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 27 | docs = ["furo", "sphinx", "zope.interface"] 28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 30 | 31 | [[package]] 32 | name = "black" 33 | version = "20.8b1" 34 | description = "The uncompromising code formatter." 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=3.6" 38 | 39 | [package.dependencies] 40 | appdirs = "*" 41 | click = ">=7.1.2" 42 | mypy-extensions = ">=0.4.3" 43 | pathspec = ">=0.6,<1" 44 | regex = ">=2020.1.8" 45 | toml = ">=0.10.1" 46 | typed-ast = ">=1.4.0" 47 | typing-extensions = ">=3.7.4" 48 | 49 | [package.extras] 50 | colorama = ["colorama (>=0.4.3)"] 51 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 52 | 53 | [[package]] 54 | name = "cfgv" 55 | version = "3.2.0" 56 | description = "Validate configuration and produce human readable error messages." 57 | category = "dev" 58 | optional = false 59 | python-versions = ">=3.6.1" 60 | 61 | [[package]] 62 | name = "click" 63 | version = "7.1.2" 64 | description = "Composable command line interface toolkit" 65 | category = "dev" 66 | optional = false 67 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 68 | 69 | [[package]] 70 | name = "colorama" 71 | version = "0.4.4" 72 | description = "Cross-platform colored terminal text." 73 | category = "dev" 74 | optional = false 75 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 76 | 77 | [[package]] 78 | name = "distlib" 79 | version = "0.3.1" 80 | description = "Distribution utilities" 81 | category = "dev" 82 | optional = false 83 | python-versions = "*" 84 | 85 | [[package]] 86 | name = "filelock" 87 | version = "3.0.12" 88 | description = "A platform independent file lock." 89 | category = "dev" 90 | optional = false 91 | python-versions = "*" 92 | 93 | [[package]] 94 | name = "flake8" 95 | version = "3.8.4" 96 | description = "the modular source code checker: pep8 pyflakes and co" 97 | category = "dev" 98 | optional = false 99 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 100 | 101 | [package.dependencies] 102 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 103 | mccabe = ">=0.6.0,<0.7.0" 104 | pycodestyle = ">=2.6.0a1,<2.7.0" 105 | pyflakes = ">=2.2.0,<2.3.0" 106 | 107 | [[package]] 108 | name = "identify" 109 | version = "1.5.10" 110 | description = "File identification library for Python" 111 | category = "dev" 112 | optional = false 113 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 114 | 115 | [package.extras] 116 | license = ["editdistance"] 117 | 118 | [[package]] 119 | name = "importlib-metadata" 120 | version = "3.3.0" 121 | description = "Read metadata from Python packages" 122 | category = "dev" 123 | optional = false 124 | python-versions = ">=3.6" 125 | 126 | [package.dependencies] 127 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 128 | zipp = ">=0.5" 129 | 130 | [package.extras] 131 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 132 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 133 | 134 | [[package]] 135 | name = "mccabe" 136 | version = "0.6.1" 137 | description = "McCabe checker, plugin for flake8" 138 | category = "dev" 139 | optional = false 140 | python-versions = "*" 141 | 142 | [[package]] 143 | name = "more-itertools" 144 | version = "8.6.0" 145 | description = "More routines for operating on iterables, beyond itertools" 146 | category = "dev" 147 | optional = false 148 | python-versions = ">=3.5" 149 | 150 | [[package]] 151 | name = "mypy-extensions" 152 | version = "0.4.3" 153 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 154 | category = "dev" 155 | optional = false 156 | python-versions = "*" 157 | 158 | [[package]] 159 | name = "nodeenv" 160 | version = "1.5.0" 161 | description = "Node.js virtual environment builder" 162 | category = "dev" 163 | optional = false 164 | python-versions = "*" 165 | 166 | [[package]] 167 | name = "packaging" 168 | version = "20.8" 169 | description = "Core utilities for Python packages" 170 | category = "dev" 171 | optional = false 172 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 173 | 174 | [package.dependencies] 175 | pyparsing = ">=2.0.2" 176 | 177 | [[package]] 178 | name = "pathspec" 179 | version = "0.8.1" 180 | description = "Utility library for gitignore style pattern matching of file paths." 181 | category = "dev" 182 | optional = false 183 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 184 | 185 | [[package]] 186 | name = "pluggy" 187 | version = "0.13.1" 188 | description = "plugin and hook calling mechanisms for python" 189 | category = "dev" 190 | optional = false 191 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 192 | 193 | [package.dependencies] 194 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 195 | 196 | [package.extras] 197 | dev = ["pre-commit", "tox"] 198 | 199 | [[package]] 200 | name = "pre-commit" 201 | version = "2.9.3" 202 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 203 | category = "dev" 204 | optional = false 205 | python-versions = ">=3.6.1" 206 | 207 | [package.dependencies] 208 | cfgv = ">=2.0.0" 209 | identify = ">=1.0.0" 210 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 211 | nodeenv = ">=0.11.1" 212 | pyyaml = ">=5.1" 213 | toml = "*" 214 | virtualenv = ">=20.0.8" 215 | 216 | [[package]] 217 | name = "py" 218 | version = "1.10.0" 219 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 220 | category = "dev" 221 | optional = false 222 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 223 | 224 | [[package]] 225 | name = "pycodestyle" 226 | version = "2.6.0" 227 | description = "Python style guide checker" 228 | category = "dev" 229 | optional = false 230 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 231 | 232 | [[package]] 233 | name = "pyflakes" 234 | version = "2.2.0" 235 | description = "passive checker of Python programs" 236 | category = "dev" 237 | optional = false 238 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 239 | 240 | [[package]] 241 | name = "pyparsing" 242 | version = "2.4.7" 243 | description = "Python parsing module" 244 | category = "dev" 245 | optional = false 246 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 247 | 248 | [[package]] 249 | name = "pytest" 250 | version = "5.4.3" 251 | description = "pytest: simple powerful testing with Python" 252 | category = "dev" 253 | optional = false 254 | python-versions = ">=3.5" 255 | 256 | [package.dependencies] 257 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 258 | attrs = ">=17.4.0" 259 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 260 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 261 | more-itertools = ">=4.0.0" 262 | packaging = "*" 263 | pluggy = ">=0.12,<1.0" 264 | py = ">=1.5.0" 265 | wcwidth = "*" 266 | 267 | [package.extras] 268 | checkqa-mypy = ["mypy (==v0.761)"] 269 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 270 | 271 | [[package]] 272 | name = "pyyaml" 273 | version = "5.4" 274 | description = "YAML parser and emitter for Python" 275 | category = "dev" 276 | optional = false 277 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 278 | 279 | [[package]] 280 | name = "regex" 281 | version = "2020.11.13" 282 | description = "Alternative regular expression module, to replace re." 283 | category = "dev" 284 | optional = false 285 | python-versions = "*" 286 | 287 | [[package]] 288 | name = "six" 289 | version = "1.15.0" 290 | description = "Python 2 and 3 compatibility utilities" 291 | category = "dev" 292 | optional = false 293 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 294 | 295 | [[package]] 296 | name = "toml" 297 | version = "0.10.2" 298 | description = "Python Library for Tom's Obvious, Minimal Language" 299 | category = "dev" 300 | optional = false 301 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 302 | 303 | [[package]] 304 | name = "typed-ast" 305 | version = "1.4.1" 306 | description = "a fork of Python 2 and 3 ast modules with type comment support" 307 | category = "dev" 308 | optional = false 309 | python-versions = "*" 310 | 311 | [[package]] 312 | name = "typing-extensions" 313 | version = "3.7.4.3" 314 | description = "Backported and Experimental Type Hints for Python 3.5+" 315 | category = "dev" 316 | optional = false 317 | python-versions = "*" 318 | 319 | [[package]] 320 | name = "virtualenv" 321 | version = "20.2.2" 322 | description = "Virtual Python Environment builder" 323 | category = "dev" 324 | optional = false 325 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 326 | 327 | [package.dependencies] 328 | appdirs = ">=1.4.3,<2" 329 | distlib = ">=0.3.1,<1" 330 | filelock = ">=3.0.0,<4" 331 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 332 | six = ">=1.9.0,<2" 333 | 334 | [package.extras] 335 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] 336 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] 337 | 338 | [[package]] 339 | name = "wcwidth" 340 | version = "0.2.5" 341 | description = "Measures the displayed width of unicode strings in a terminal" 342 | category = "dev" 343 | optional = false 344 | python-versions = "*" 345 | 346 | [[package]] 347 | name = "zipp" 348 | version = "3.4.0" 349 | description = "Backport of pathlib-compatible object wrapper for zip files" 350 | category = "dev" 351 | optional = false 352 | python-versions = ">=3.6" 353 | 354 | [package.extras] 355 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 356 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 357 | 358 | [metadata] 359 | lock-version = "1.1" 360 | python-versions = "^3.7" 361 | content-hash = "35b05741c417ad73347fccb1c14cea07bc593e5c3186dc2530a64bdd2c1e1a67" 362 | 363 | [metadata.files] 364 | appdirs = [ 365 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 366 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 367 | ] 368 | atomicwrites = [ 369 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 370 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 371 | ] 372 | attrs = [ 373 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, 374 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, 375 | ] 376 | black = [ 377 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 378 | ] 379 | cfgv = [ 380 | {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, 381 | {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, 382 | ] 383 | click = [ 384 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 385 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 386 | ] 387 | colorama = [ 388 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 389 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 390 | ] 391 | distlib = [ 392 | {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, 393 | {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, 394 | ] 395 | filelock = [ 396 | {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, 397 | {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, 398 | ] 399 | flake8 = [ 400 | {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, 401 | {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, 402 | ] 403 | identify = [ 404 | {file = "identify-1.5.10-py2.py3-none-any.whl", hash = "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e"}, 405 | {file = "identify-1.5.10.tar.gz", hash = "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5"}, 406 | ] 407 | importlib-metadata = [ 408 | {file = "importlib_metadata-3.3.0-py3-none-any.whl", hash = "sha256:bf792d480abbd5eda85794e4afb09dd538393f7d6e6ffef6e9f03d2014cf9450"}, 409 | {file = "importlib_metadata-3.3.0.tar.gz", hash = "sha256:5c5a2720817414a6c41f0a49993908068243ae02c1635a228126519b509c8aed"}, 410 | ] 411 | mccabe = [ 412 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 413 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 414 | ] 415 | more-itertools = [ 416 | {file = "more-itertools-8.6.0.tar.gz", hash = "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf"}, 417 | {file = "more_itertools-8.6.0-py3-none-any.whl", hash = "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330"}, 418 | ] 419 | mypy-extensions = [ 420 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 421 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 422 | ] 423 | nodeenv = [ 424 | {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, 425 | {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, 426 | ] 427 | packaging = [ 428 | {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, 429 | {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, 430 | ] 431 | pathspec = [ 432 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 433 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 434 | ] 435 | pluggy = [ 436 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 437 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 438 | ] 439 | pre-commit = [ 440 | {file = "pre_commit-2.9.3-py2.py3-none-any.whl", hash = "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0"}, 441 | {file = "pre_commit-2.9.3.tar.gz", hash = "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"}, 442 | ] 443 | py = [ 444 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 445 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 446 | ] 447 | pycodestyle = [ 448 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, 449 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, 450 | ] 451 | pyflakes = [ 452 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, 453 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, 454 | ] 455 | pyparsing = [ 456 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 457 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 458 | ] 459 | pytest = [ 460 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 461 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 462 | ] 463 | pyyaml = [ 464 | {file = "PyYAML-5.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:f7a21e3d99aa3095ef0553e7ceba36fb693998fbb1226f1392ce33681047465f"}, 465 | {file = "PyYAML-5.4-cp27-cp27m-win32.whl", hash = "sha256:52bf0930903818e600ae6c2901f748bc4869c0c406056f679ab9614e5d21a166"}, 466 | {file = "PyYAML-5.4-cp27-cp27m-win_amd64.whl", hash = "sha256:a36a48a51e5471513a5aea920cdad84cbd56d70a5057cca3499a637496ea379c"}, 467 | {file = "PyYAML-5.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5e7ac4e0e79a53451dc2814f6876c2fa6f71452de1498bbe29c0b54b69a986f4"}, 468 | {file = "PyYAML-5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc552b6434b90d9dbed6a4f13339625dc466fd82597119897e9489c953acbc22"}, 469 | {file = "PyYAML-5.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0dc9f2eb2e3c97640928dec63fd8dc1dd91e6b6ed236bd5ac00332b99b5c2ff9"}, 470 | {file = "PyYAML-5.4-cp36-cp36m-win32.whl", hash = "sha256:5a3f345acff76cad4aa9cb171ee76c590f37394186325d53d1aa25318b0d4a09"}, 471 | {file = "PyYAML-5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:f3790156c606299ff499ec44db422f66f05a7363b39eb9d5b064f17bd7d7c47b"}, 472 | {file = "PyYAML-5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:124fd7c7bc1e95b1eafc60825f2daf67c73ce7b33f1194731240d24b0d1bf628"}, 473 | {file = "PyYAML-5.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8b818b6c5a920cbe4203b5a6b14256f0e5244338244560da89b7b0f1313ea4b6"}, 474 | {file = "PyYAML-5.4-cp37-cp37m-win32.whl", hash = "sha256:737bd70e454a284d456aa1fa71a0b429dd527bcbf52c5c33f7c8eee81ac16b89"}, 475 | {file = "PyYAML-5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:7242790ab6c20316b8e7bb545be48d7ed36e26bbe279fd56f2c4a12510e60b4b"}, 476 | {file = "PyYAML-5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cc547d3ead3754712223abb7b403f0a184e4c3eae18c9bb7fd15adef1597cc4b"}, 477 | {file = "PyYAML-5.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8635d53223b1f561b081ff4adecb828fd484b8efffe542edcfdff471997f7c39"}, 478 | {file = "PyYAML-5.4-cp38-cp38-win32.whl", hash = "sha256:26fcb33776857f4072601502d93e1a619f166c9c00befb52826e7b774efaa9db"}, 479 | {file = "PyYAML-5.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2243dd033fd02c01212ad5c601dafb44fbb293065f430b0d3dbf03f3254d615"}, 480 | {file = "PyYAML-5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:31ba07c54ef4a897758563e3a0fcc60077698df10180abe4b8165d9895c00ebf"}, 481 | {file = "PyYAML-5.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:02c78d77281d8f8d07a255e57abdbf43b02257f59f50cc6b636937d68efa5dd0"}, 482 | {file = "PyYAML-5.4-cp39-cp39-win32.whl", hash = "sha256:fdc6b2cb4b19e431994f25a9160695cc59a4e861710cc6fc97161c5e845fc579"}, 483 | {file = "PyYAML-5.4-cp39-cp39-win_amd64.whl", hash = "sha256:8bf38641b4713d77da19e91f8b5296b832e4db87338d6aeffe422d42f1ca896d"}, 484 | {file = "PyYAML-5.4.tar.gz", hash = "sha256:3c49e39ac034fd64fd576d63bb4db53cda89b362768a67f07749d55f128ac18a"}, 485 | ] 486 | regex = [ 487 | {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, 488 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, 489 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, 490 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, 491 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, 492 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, 493 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, 494 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, 495 | {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, 496 | {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, 497 | {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, 498 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, 499 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, 500 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, 501 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, 502 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, 503 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, 504 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, 505 | {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, 506 | {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, 507 | {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, 508 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, 509 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, 510 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, 511 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, 512 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, 513 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, 514 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, 515 | {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, 516 | {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, 517 | {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, 518 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, 519 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, 520 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, 521 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, 522 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, 523 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, 524 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, 525 | {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, 526 | {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, 527 | {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, 528 | ] 529 | six = [ 530 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 531 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 532 | ] 533 | toml = [ 534 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 535 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 536 | ] 537 | typed-ast = [ 538 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 539 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 540 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 541 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 542 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 543 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 544 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 545 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, 546 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 547 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 548 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 549 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 550 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 551 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, 552 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 553 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 554 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 555 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 556 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 557 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, 558 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 559 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 560 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 561 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, 562 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, 563 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, 564 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, 565 | {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, 566 | {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, 567 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 568 | ] 569 | typing-extensions = [ 570 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 571 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 572 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 573 | ] 574 | virtualenv = [ 575 | {file = "virtualenv-20.2.2-py2.py3-none-any.whl", hash = "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c"}, 576 | {file = "virtualenv-20.2.2.tar.gz", hash = "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"}, 577 | ] 578 | wcwidth = [ 579 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 580 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 581 | ] 582 | zipp = [ 583 | {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, 584 | {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, 585 | ] 586 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aquaui" 3 | version = "0.0.1-2" 4 | description = "Native Mac OS UI elements with python" 5 | authors = ["ninest"] 6 | readme = "README.md" 7 | license = "MIT" 8 | repository = "https://github.com/ninest/aquaui/" 9 | documentation = "https://github.com/ninest/aquaui/" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.7" 13 | 14 | [tool.poetry.dev-dependencies] 15 | pytest = "^5.2" 16 | black = "^20.8b1" 17 | flake8 = "^3.8.4" 18 | pre-commit = "^2.9.3" 19 | 20 | [build-system] 21 | requires = ["poetry-core>=1.0.0"] 22 | build-backend = "poetry.core.masonry.api" 23 | 24 | 25 | [tool.black] 26 | line-length = 120 27 | target-version = ['py37'] 28 | include = '\.pyi?$' 29 | exclude = ''' 30 | /( 31 | \.git 32 | | \.hg 33 | | \.mypy_cache 34 | | \.pytest_cache 35 | | \.tox 36 | | \.venv 37 | | _build 38 | | buck-out 39 | | build 40 | | dist 41 | | docs 42 | )/ 43 | ''' -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninest/aquaui/0ed14822e4d6a76ac7d38aec78a044c71364349f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_alert.py: -------------------------------------------------------------------------------- 1 | from aquaui import Alert, AlertType, Buttons 2 | 3 | 4 | def test_alert(): 5 | assert ( 6 | Alert("Hello").with_buttons(Buttons(["One", "Two", "Three"])).of_type(AlertType.CRITICAL).applescript 7 | == 'display alert "Hello" buttons { "One", "Two", "Three" } as critical ' 8 | ) 9 | -------------------------------------------------------------------------------- /tests/test_buttons.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aquaui.types.buttons import Buttons 3 | 4 | 5 | @pytest.fixture 6 | def buttons(): 7 | enter_button = "Enter" 8 | cancel_button = "Cancel" 9 | more_button = "More" 10 | 11 | return Buttons( 12 | [cancel_button, more_button, enter_button], 13 | default_button=enter_button, 14 | cancel_button=cancel_button, 15 | ) 16 | 17 | 18 | def test_buttons_string(buttons: Buttons): 19 | assert buttons.string == '"Cancel", "More", "Enter"' 20 | 21 | 22 | def test_max_buttons(): 23 | """ 24 | Should through an error as there are more than three buttons 25 | """ 26 | 27 | with pytest.raises(Exception): 28 | Buttons(["One", "Two", "Three", "Four"]) 29 | 30 | 31 | def test_buttons_error(): 32 | """ 33 | Should through an error as the default/cancel button are not in the 34 | buttons list 35 | """ 36 | 37 | with pytest.raises(Exception): 38 | Buttons(["One"], default_button="OOOOO") 39 | 40 | with pytest.raises(Exception): 41 | Buttons(["One"], cancel_button="OOOOO") 42 | -------------------------------------------------------------------------------- /tests/test_choice.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aquaui import Choice 3 | 4 | 5 | def test_choice(): 6 | assert ( 7 | Choice("Choice Title").with_choices(["One", "Two"]).applescript.strip() 8 | == 'choose from list {"One", "Two"} with prompt "Choice Title"' 9 | ) 10 | 11 | 12 | def test_choice_default(): 13 | assert ( 14 | Choice("Title").with_choices(["A", "B"]).default_choice("B").applescript.strip() 15 | == 'choose from list {"A", "B"} with prompt "Title" default items { "B" }' 16 | ) 17 | 18 | 19 | def test_choice_error(): 20 | with pytest.raises(Exception): 21 | # Error because default choice not in choices 22 | Choice("Title").with_choices(["A", "B"]).default_choice("C") 23 | -------------------------------------------------------------------------------- /tests/test_color_picker.py: -------------------------------------------------------------------------------- 1 | from aquaui import ColorPicker 2 | 3 | 4 | def test_color_picker(): 5 | assert ColorPicker().applescript.strip() == "choose color" 6 | 7 | 8 | def test_color_picker_default_color(): 9 | assert ColorPicker().with_default_color([1, 2, 3]).applescript.strip() == "choose color default color { 1, 2, 3 }" 10 | -------------------------------------------------------------------------------- /tests/test_dialog.py: -------------------------------------------------------------------------------- 1 | from aquaui import Dialog, Buttons, Icon 2 | 3 | """ 4 | Test out by checking the output applescript 5 | """ 6 | 7 | 8 | def test_title(): 9 | assert Dialog("This is a dialog").applescript.strip() == 'display dialog "This is a dialog"' 10 | 11 | 12 | def test_buttons(): 13 | buttons = Buttons(["Go", "Nah"], default_button="Go", cancel_button="Nah") 14 | assert ( 15 | Dialog("Title").with_buttons(buttons).applescript.strip() 16 | == 'display dialog "Title" buttons { "Go", "Nah" } default button "Go" cancel button "Nah"' 17 | ) 18 | 19 | 20 | def test_icon(): 21 | assert Dialog("Title").with_icon(Icon.NOTE).applescript.strip() == 'display dialog "Title" with icon note' 22 | 23 | 24 | def test_input(): 25 | assert Dialog("Title").with_input().applescript.strip() == 'display dialog "Title" default answer ""' 26 | assert ( 27 | Dialog("Title").with_input("Default Answer").applescript.strip() 28 | == 'display dialog "Title" default answer "Default Answer"' 29 | ) 30 | -------------------------------------------------------------------------------- /tests/test_fallback_notification.py: -------------------------------------------------------------------------------- 1 | from aquaui.notification.fallback_notification import ApplescriptNotification as ASN 2 | 3 | 4 | def test_fallback_notification(): 5 | assert ( 6 | ASN("Text").with_title("Title").with_subtitle("Subtitle").applescript.strip() 7 | == 'display notification "Text" with title "Title" subtitle "Subtitle"' 8 | ) 9 | --------------------------------------------------------------------------------