├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── deploy.yml │ └── main.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── advanced_usage.rst ├── conf.py ├── custom_aumid.rst ├── dev │ ├── metadata.rst │ └── toast_document.rst ├── getting_started.rst ├── images │ └── logo.png ├── index.rst ├── interactable.rst ├── make.bat ├── migration.rst ├── requirements.txt ├── solving.rst └── user │ ├── audio.rst │ ├── exceptions.rst │ ├── toast.rst │ ├── toasters.rst │ └── wrappers.rst ├── pyproject.toml ├── requirements-dev.txt ├── scripts ├── __init__.py ├── publish_gh_release_notes.py └── register_hkey_aumid.py ├── setup.py ├── src └── windows_toasts │ ├── __init__.py │ ├── _version.py │ ├── events.py │ ├── exceptions.py │ ├── py.typed │ ├── toast.py │ ├── toast_audio.py │ ├── toast_document.py │ ├── toasters.py │ └── wrappers.py └── tests ├── __init__.py ├── conftest.py ├── test_aumid.py ├── test_import.py └── test_toasts.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | scripts/publish_gh_release_notes.py 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | except ImportError: 10 | if TYPE_CHECKING: 11 | if __name__ == .__main__.: -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # The following rules are incompatible with or enforced by black: 2 | # E203 whitespace before ':' -- scripts only 3 | # E301 expected 1 blank line -- stubs only 4 | # E302 expected 2 blank lines -- stubs only 5 | # E305 expected 2 blank lines -- stubs only 6 | # E501 line too long 7 | 8 | # Some rules are considered irrelevant to stub files: 9 | # E701 multiple statements on one line (colon) -- disallows "..." on the same line 10 | # F401 imported but unused -- does not recognize re-exports 11 | # https://github.com/PyCQA/pyflakes/issues/474 12 | # F822 undefined name in __all__ -- flake8 does not recognize 'foo: Any' 13 | # https://github.com/PyCQA/pyflakes/issues/533 14 | 15 | [flake8] 16 | max-line-length = 120 17 | per-file-ignores = 18 | *.py: E501 19 | src/windows_toasts/__init__.py: E402 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | commit-message: 17 | prefix: "ci" 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '0 23 * * 0' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: windows-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'python' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v3 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v3 46 | 47 | # ℹ️ Command-line programs to run using the OS shell. 48 | # 📚 https://git.io/JvXDl 49 | 50 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 51 | # and modify them (or add more) to build your code if your project 52 | # uses a compiled language 53 | 54 | #- run: | 55 | # make bootstrap 56 | # make release 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v3 60 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | # These tags are protected 7 | - "v[0-9]+.[0-9]+.[0-9]+" 8 | - "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 9 | 10 | # Set permissions at the job level. 11 | permissions: {} 12 | 13 | jobs: 14 | deploy: 15 | if: github.repository == 'DatGuy1/Windows-Toasts' 16 | 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 30 19 | permissions: 20 | contents: write 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | persist-credentials: false 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.11" 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install --upgrade build github3.py pypandoc 37 | - name: Build package 38 | run: | 39 | python -m build 40 | 41 | - name: Publish GitHub release notes 42 | env: 43 | GH_RELEASE_NOTES_TOKEN: ${{ github.token }} 44 | run: | 45 | sudo apt-get install pandoc 46 | python scripts/publish_gh_release_notes.py -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | env: 11 | PYTEST_ADDOPTS: "--color=yes" 12 | 13 | permissions: {} 14 | 15 | jobs: 16 | build: 17 | runs-on: ${{ matrix.os }} 18 | timeout-minutes: 10 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | python-version: ["3.9", "3.10", "3.11", "3.12"] 23 | os: [windows-latest] 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Update pip, wheel, setuptools, build 32 | run: | 33 | python -m pip install -U pip wheel setuptools build twine 34 | - name: Install testing dependencies 35 | run: | 36 | python -m pip install -r requirements-dev.txt 37 | - name: Install self 38 | run: | 39 | python -m pip install . 40 | - name: Generate coverage report 41 | run: | 42 | pytest --cov=src/windows_toasts --cov=scripts/ --cov-report=xml tests/ 43 | - name: Upload coverage to Codecov 44 | uses: codecov/codecov-action@v5 45 | with: 46 | fail_ci_if_error: true 47 | verbose: true 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | 50 | lint: 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | - name: Set up Python 56 | uses: actions/setup-python@v5 57 | with: 58 | python-version: "3.11" 59 | - name: Install dependencies 60 | run: | 61 | python -m pip install --upgrade pip 62 | pip install black isort flake8 63 | - name: Format per black code style 64 | uses: psf/black@stable 65 | - name: Order imports 66 | uses: isort/isort-action@master 67 | - name: Enforce flake8 68 | run: flake8 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | .coverage 4 | .coverage.* 5 | .dmypy.json 6 | coverage.xml 7 | 8 | build/ 9 | dist/ 10 | *.egg-info/ 11 | 12 | _build 13 | 14 | .idea 15 | 16 | main.py -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.11" 11 | 12 | # Build documentation in the docs/ directory with Sphinx 13 | sphinx: 14 | configuration: docs/conf.py 15 | 16 | formats: 17 | - pdf 18 | 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 1.3.1 (2025-05-06) 2 | ================== 3 | - Support pywinrt>=3 (#184) 4 | 5 | 1.3.0 (2024-09-01) 6 | ================== 7 | - Implemented custom audio file support (#154) 8 | 9 | 1.2.0 (2024-06-30) 10 | ================== 11 | - Implement removing toasts and toast groups (#145) 12 | - See `'removing toasts' `_ for an example. 13 | - Relax winrt package versioning requirements to support 2.x (#153) 14 | 15 | 1.1.1 (2024-05-19) 16 | ================== 17 | - Allow setting attribution text (#140) 18 | - Added support for winrt v2.0.1 (#138) 19 | 20 | 1.1.0 (2024-02-13) 21 | ================== 22 | - Importing the module now throws an exception if the Windows version is unsupported (#122) 23 | - Replaced toasts_winrt with winrt-Namespace packages (#113) 24 | - Dropped Python 3.8 support (#113) 25 | 26 | 1.0.2 (2023-12-31) 27 | =================== 28 | - Unquote image paths when the path contains characters that were escaped (#111) 29 | - Convert image paths to absolute before converting to URI (#112) 30 | 31 | 1.0.1 (2023-09-11) 32 | ================== 33 | - Fixed AttributeError when calling WindowsToaster.clear_toasts() (#96) 34 | - unschedule_toast() now raise a ToastNotFoundError exception if the toast could not be found instead of warning (#97) 35 | 36 | 1.0.0 (2023-08-14) 37 | ================== 38 | Major 39 | ----- 40 | - Replaced winsdk requirement with toasts-winrt (#78) 41 | - Removed toast templates in favour of ToastGeneric (#75) 42 | - Simplified configuration of toasts (#82) 43 | 44 | Minor 45 | ----- 46 | - InvalidImageException is thrown when trying to add online images or images that do not exist 47 | - Body is now the first argument for toasts 48 | - Images no longer default to being circleCrop-ed 49 | - Added support for inline images (#77) 50 | - Added support for launching applications using their protocols (#80) 51 | - Implemented snoozing and dismissing toasts (#83) 52 | 53 | See the `migration guide `_. 54 | 55 | 0.4.1 (2023-04-20) 56 | ================== 57 | - Recreated default Windows behaviour for progress bar. This allows it to be changed in the future while remaining faithful to the original implementation. 58 | - Fixed AttributeError on WindowsToaster.clear_toasts() 59 | - Bumped winsdk to 1.0.0b9 60 | 61 | 0.4.0 (2023-03-18) 62 | ================== 63 | - Added Windows 11 and Python 3.12 classifiers 64 | - Merge typing back inline 65 | - Dropped Python 3.7 support 66 | - Changed scripts to use entry_points 67 | - Created documentation on https://windows-toasts.readthedocs.io 68 | - register_hkey_aumid no longer requires user to be an administrator 69 | - Removed create_shell_link.py script 70 | - Added many new features for toasts, including: 71 | - Initialising toasts with your data rather than setting it afterwards 72 | - Dynamically modifying toast content after its display 73 | - Multiple images in different slots 74 | - Scheduled toasts 75 | - Progress bars 76 | - Selection boxes input 77 | - Improved button configuration 78 | - Grouped toasts 79 | - Suppressing the popup 80 | - Toast expiration time 81 | - Toast scenarios 82 | 83 | See the documentation for how to use them! 84 | 85 | This release is mostly backwards compatible. The next *major* release will be version 1.0.0, most likely be backwards incompatible, and will support on_activated callbacks after the toast has been relegated to the action center. 86 | 87 | 0.3.3 (2022-03-18) 88 | ================== 89 | - Fixed bug where user input would not work if there were no buttons 90 | - register_hkey_aumid will now raise an error if supplied image is not a .ico file 91 | - Added tests for user input and attribution text 92 | 93 | 0.3.2 (2022-03-18) 94 | ================== 95 | - Removed leftovers from older versions 96 | - Added proper code style and enforcement 97 | - Added tests to vastly increase coverage 98 | 99 | 0.3.1 (2022-03-11) 100 | ================== 101 | - Attribution text now only displays if using interactable toaster without a custom AUMID 102 | - Fixed bug when binding on_activated without an input field 103 | 104 | 0.3.0 (2022-03-11) 105 | ================== 106 | - Renamed AddDuration to SetDuration 107 | - Implemented text inputs fields. Use with SetInputField(placeholderText) 108 | - Switched to using a first party ToastActivatedEventArgs class instead of WinRT's 109 | - Added simple test to make sure toasts don't throw errors 110 | 111 | 0.2.0 (2022-02-26) 112 | ================== 113 | 114 | Major Revamp 115 | ------------ 116 | - Create InteractiveWindowsToaster, used for custom actions 117 | - Move typing to .pyi stubs 118 | - Add scripts to generate custom AUMIDs for toasts 119 | - Add tests for those scripts 120 | 121 | 122 | 0.1.3 (2022-02-19) 123 | ================== 124 | - Initial public release 125 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-include *.typed 2 | 3 | exclude scripts/publish_gh_release_notes.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Windows-Toasts 2 | 3 | --- 4 | [![PyPI version](https://img.shields.io/pypi/v/windows-toasts)](https://pypi.org/project/windows-toasts/) [![readthedocs.io](https://readthedocs.org/projects/windows-toasts/badge/?version=latest)](https://windows-toasts.readthedocs.io/en/latest/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/windows-toasts)](https://pypi.org/project/windows-toasts/) [![Downloads](https://static.pepy.tech/badge/windows-toasts/month)](https://pepy.tech/project/windows-toasts) [![codecov](https://codecov.io/gh/DatGuy1/Windows-Toasts/branch/master/graph/badge.svg?token=ZD8OF2SF61)](https://codecov.io/gh/DatGuy1/Windows-Toasts) [![Code Style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | 6 | **Windows-Toasts** is a Python library used to send [toast notifications](https://docs.microsoft.com/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts) on Windows machines. Check out the [documentation](https://windows-toasts.readthedocs.io/en/latest/). 7 | 8 | ## Installation 9 | Windows-Toasts supports Windows 10 and 11. While toast notifications do work on Windows 8.1 and below, Microsoft added features in Windows 10 that were never backported. 10 | 11 | Windows-Toasts is available through PyPI: 12 | ```console 13 | $ python -m pip install windows-toasts 14 | ``` 15 | 16 | ## Usage 17 | 18 | Simple usage: 19 | 20 | ```python 21 | >>> from windows_toasts import Toast, WindowsToaster 22 | >>> toaster = WindowsToaster('Python') 23 | >>> newToast = Toast() 24 | >>> newToast.text_fields = ['Hello, world!'] 25 | >>> newToast.on_activated = lambda _: print('Toast clicked!') 26 | >>> toaster.show_toast(newToast) 27 | ``` 28 | 29 | Full documentation is available at [readthedocs.io](https://windows-toasts.readthedocs.io/en/latest/) 30 | 31 | ## But I already saw this package three times on PyPI! 32 | 33 | I created this library since the other Windows toast notification libraries were all but abandoned, lacked features, and were using pywin32 bindings. 34 | 35 | Using WinRT may come with its own limitations. However, the only issue I've encountered compared to using pywin32 bindings is not being able to select the duration in seconds, but rather as short/long. 36 | 37 | ## Credits 38 | 39 | The code is adapted from [mohabouje's wonderful C++ WinToasts library](https://github.com/mohabouje/WinToast) 40 | 41 | Big thanks to dlech for his [recently created winrt fork](https://github.com/pywinrt/pywinrt) -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/advanced_usage.rst: -------------------------------------------------------------------------------- 1 | Advanced usage 2 | ============== 3 | 4 | What else can Windows-Toasts be used for? Since you're here, you probably already have your own idea, but here's a few examples: 5 | 6 | Display an image 7 | ---------------- 8 | 9 | Let's try out displaying an image 10 | 11 | .. code-block:: python 12 | 13 | from windows_toasts import Toast, ToastDisplayImage, WindowsToaster 14 | 15 | toaster = WindowsToaster('Windows-Toasts') 16 | 17 | newToast = Toast() 18 | newToast.text_fields = ['<--- look, the Windows logo!'] 19 | # str or PathLike 20 | newToast.AddImage(ToastDisplayImage.fromPath('C:/Windows/System32/@WLOGO_96x96.png')) 21 | 22 | toaster.show_toast(newToast) 23 | 24 | .. note:: 25 | | When not using InteractableWindowsToaster you can display up to two images, and one of them must be marked as 'hero'. 26 | | A `hero image `_ is an image that is prominently displayed at the top of the notification. 27 | 28 | Open a website on click 29 | ----------------------- 30 | 31 | We use :attr:`windows_toasts.toast.Toast.launch_action` to open a website when the notification is pressed. 32 | 33 | .. code-block:: python 34 | 35 | from windows_toasts import Toast, WindowsToaster 36 | 37 | toaster = WindowsToaster('Rick Astley') 38 | 39 | newToast = Toast() 40 | newToast.text_fields = ['Hello there! You just won a thousand dollars! Click me to claim it!'] 41 | # Inline lambda function. This could also be an actual function 42 | newToast.launch_action = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' 43 | 44 | # Send it 45 | toaster.show_toast(newToast) 46 | 47 | 48 | Play different audio 49 | -------------------- 50 | 51 | Out-of-the-box 52 | ^^^^^^^^^^^^^^ 53 | There is a list of available, out-of-the-box audio sources at :class:`windows_toasts.toast_audio.AudioSource`. Let's play the Windows IM sound looping until the notification is dismissed/expires. 54 | 55 | .. code-block:: python 56 | 57 | from windows_toasts import AudioSource, Toast, ToastAudio, WindowsToaster 58 | 59 | toaster = WindowsToaster('Windows-Toasts') 60 | 61 | newToast = Toast() 62 | newToast.text_fields = ['Ding ding! Ding ding! Ding ding!'] 63 | newToast.audio = ToastAudio(AudioSource.IM, looping=True) 64 | 65 | toaster.show_toast(newToast) 66 | 67 | Custom files 68 | ^^^^^^^^^^^^ 69 | You can also play local audio files, with the following extensions: 70 | 71 | * .aac 72 | * .flac 73 | * .m4a 74 | * .mp3 75 | * .wav 76 | * .wma 77 | 78 | .. code-block:: python 79 | 80 | from windows_toasts import Toast, ToastAudio, WindowsToaster 81 | from pathlib import Path 82 | 83 | toaster = WindowsToaster('Blooper') 84 | 85 | newToast = Toast() 86 | newToast.text_fields = ['Incoming bloop from', 'Steve'] 87 | newToast.audio = ToastAudio(Path('incoming_bloop.wav')) 88 | 89 | toaster.show_toast(newToast) 90 | 91 | .. warning:: 92 | A warning will arise if local file does not exist, or if it uses an unsupported extension. 93 | This will make Windows play the default sound. There is no magic check for the file type – just its suffix. 94 | 95 | Additionally, some audio files will not work for seemingly no discernible reason. If this happens, the toast will be silent. 96 | 97 | Progress bars 98 | ------------- 99 | 100 | .. code-block:: python 101 | 102 | from windows_toasts import InteractableWindowsToaster, Toast, ToastProgressBar 103 | 104 | toaster = InteractableWindowsToaster('Windows-Toasts') 105 | 106 | # progress=None means the bar will be indeterminate 107 | progressBar = ToastProgressBar( 108 | 'Preparing...', 'Python 4 release', progress=None, progress_override='? millenniums remaining' 109 | ) 110 | 111 | newToast = Toast(progress_bar=progressBar) 112 | 113 | toaster.show_toast(newToast) 114 | 115 | Dynamically modifying toast content 116 | ----------------------------------- 117 | 118 | You can dynamically modify a toast's progress bar or text field 119 | 120 | .. code-block:: python 121 | 122 | import time 123 | from windows_toasts import InteractableWindowsToaster, Toast, ToastProgressBar 124 | 125 | toaster = InteractableWindowsToaster('Python') 126 | 127 | newToast = Toast(['Starting.']) 128 | progressBar = ToastProgressBar('Waiting...', progress=0) 129 | newToast.progress_bar = progressBar 130 | 131 | toaster.show_toast(newToast) 132 | 133 | for i in range(1, 11): 134 | time.sleep(1) 135 | progressBar.progress += 0.1 136 | newToast.text_fields = [f'Stage {i}'] 137 | 138 | toaster.update_toast(newToast) 139 | 140 | newToast.text_fields = ['Goodbye!'] 141 | 142 | toaster.update_toast(newToast) 143 | 144 | From Microsoft.com: 145 | 146 | Since Windows 10, you could always replace a notification by sending a new toast with the same Tag and Group. So what's the difference between replacing the toast and updating the toast's data? 147 | 148 | .. list-table:: Update or replace a notification 149 | :header-rows: 1 150 | 151 | * - 152 | - Replacing 153 | - Updating 154 | * - **Position in Action Center** 155 | - Moves the notification to the top of Action Center. 156 | - Leaves the notification in place within Action Center. 157 | * - **Modifying content** 158 | - Can completely change all content/layout of the toast 159 | - Can only change progress bar and top-level text 160 | * - **Reappearing as popup** 161 | - Can reappear as a toast popup if you leave :attr:`~windows_toasts.toast.Toast.suppress_popup` set to false (or set to true to silently send it to Action Center) 162 | - Won't reappear as a popup; the toast's data is silently updated within Action Center 163 | * - **User dismissed** 164 | - Regardless of whether user dismissed your previous notification, your replacement toast will always be sent 165 | - If the user dismissed your toast, the toast update will fail 166 | 167 | Scheduled toasts 168 | ---------------- 169 | 170 | You can also schedule a toast to display at a specified time 171 | 172 | .. code-block:: python 173 | 174 | from datetime import datetime, timedelta 175 | from windows_toasts import WindowsToaster, Toast 176 | 177 | toaster = WindowsToaster('Python') 178 | 179 | displayTime = datetime.now() + timedelta(seconds=10) 180 | newToast = Toast([f'This will pop up at {displayTime}']) 181 | 182 | toaster.schedule_toast(newToast, displayTime) 183 | 184 | .. _system-actions: 185 | 186 | Snoozing and dismissing 187 | ----------------------- 188 | 189 | It is possible to snooze toasts and have them pop up later, as well as dismiss the toast entirely 190 | 191 | .. code-block:: python 192 | 193 | from windows_toasts import InteractableWindowsToaster, Toast, ToastSystemButton, ToastSystemButtonAction, ToastInputSelectionBox, ToastSelection 194 | 195 | newToast = Toast(['Reminder', 'It\'s time to stretch!']) 196 | 197 | selections = (ToastSelection('1', '1 minute'), ToastSelection('2', '2 minutes'), ToastSelection('5', '5 minutes')) 198 | selectionBox = ToastInputSelectionBox( 199 | 'snoozeBox', caption='Snooze duration', selections=selections, default_selection=selections[0] 200 | ) 201 | newToast.AddInput(selectionBox) 202 | 203 | snoozeButton = ToastSystemButton(ToastSystemButtonAction.Snooze, 'Remind Me Later', relatedInput=selectionBox) 204 | dismissBox = ToastSystemButton(ToastSystemButtonAction.Dismiss) 205 | newToast.AddAction(snoozeButton) 206 | newToast.AddAction(dismissBox) 207 | 208 | InteractableWindowsToaster('Python').show_toast(newToast) 209 | 210 | If you do not provide a caption, Windows will automatically use the appropriate localized strings. 211 | If the :attr:`~windows_toasts.wrappers.ToastSystemButton.relatedInput` is None, the notification will snooze only once for a system-defined time interval. Otherwise, specifying a :class:`~windows_toasts.wrappers.ToastInputSelectionBox` allows the user to select a predefined snooze interval. 212 | 213 | .. note:: 214 | Ensure the :attr:`~windows_toasts.wrappers.ToastSelection.selection_id` is a positive integer, which represents the interval in minutes. 215 | 216 | Removing toasts 217 | --------------- 218 | 219 | You can remove toasts, which will (if on-screen first hide them) and then immediately dismiss them from the action center. 220 | 221 | In the following example, the toast is automatically removed when it is dismissed to the action center: 222 | 223 | .. code-block:: python 224 | 225 | from windows_toasts import WindowsToaster, Toast 226 | 227 | toaster = WindowsToaster("Python") 228 | 229 | newToast = Toast(["Disappearing act"]) 230 | newToast.on_dismissed = lambda _: toaster.remove_toast(newToast) 231 | 232 | toaster.show_toast(newToast) 233 | 234 | .. warning:: 235 | You can only remove toasts that were popped by a toaster with the same AUMID. Additionally, no exception will be thrown if the toast does not exist 236 | 237 | Placing images 238 | ------------------ 239 | 240 | Images can be placed in three different positions, as included in :attr:`windows_toasts.wrappers.ToastImagePosition`: Inline, hero, and AppLogo. Creating the :class:`~windows_toasts.wrappers.ToastDisplayImage` with: 241 | 242 | * Inline – the image will be displayed inline, after any text elements, filling the full width of the visual area 243 | * Hero – the image will be displayed prominently at the top of the notification 244 | * AppLogo – the image will be displayed in a square on the left side of the visual area 245 | 246 | ...and much more 247 | ---------------- 248 | 249 | See :class:`windows_toasts.toast.Toast` or the tests for more modifications you can make to toast notifications. -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | import os 7 | import sys 8 | from typing import Dict 9 | 10 | sys.path.insert(0, os.path.abspath("../src")) 11 | 12 | # -- Project information ----------------------------------------------------- 13 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 14 | 15 | # I'd love to simply import the project (so I could also use sphinx.ext.doctest), but RTD builds on Ubuntu 16 | # Get version.py without importing project 17 | windows_toasts: Dict[str, str] = {} 18 | with open("../src/windows_toasts/_version.py", "r") as f: 19 | exec(f.read(), None, windows_toasts) 20 | 21 | project = windows_toasts["__title__"] 22 | copyright = "2025, DatGuy" 23 | author = windows_toasts["__author__"] 24 | 25 | 26 | # The version info for the project you're documenting, acts as replacement for 27 | # |version| and |release|, also used in various other places throughout the 28 | # built documents. 29 | # 30 | # The short X.Y version. 31 | version = windows_toasts["__version__"] 32 | # The full version, including alpha/beta/rc tags. 33 | release = windows_toasts["__version__"] 34 | 35 | # -- General configuration --------------------------------------------------- 36 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 37 | 38 | extensions = [ 39 | "sphinx.ext.autodoc", 40 | "sphinx.ext.autosectionlabel", 41 | "sphinx.ext.autosummary", 42 | "sphinx.ext.viewcode", 43 | "sphinx_toolbox.more_autodoc.typevars", 44 | "enum_tools.autoenum", 45 | ] 46 | 47 | templates_path = ["_templates"] 48 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 49 | 50 | autosectionlabel_prefix_document = True 51 | 52 | autodoc_mock_imports = ["winrt"] 53 | autodoc_default_options = {"members": True, "member-order": "bysource", "undoc-members": True} 54 | 55 | # -- Options for HTML output ------------------------------------------------- 56 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 57 | 58 | html_theme = "furo" 59 | html_logo = "images/logo.png" 60 | html_static_path = ["_static"] 61 | -------------------------------------------------------------------------------- /docs/custom_aumid.rst: -------------------------------------------------------------------------------- 1 | Custom AUMIDs 2 | ===================== 3 | 4 | Custom AUMIDs can be used to display user-defined titles and icons. 5 | When initialising :class:`~windows_toasts.toasters.InteractableWindowsToaster`, pass the custom AUMID as notifierAUMID. 6 | 7 | Installing a custom AUMID 8 | ------------------------- 9 | Installing a custom AUMID allows you to use your own title, icon, and listen to activation after the notification was relegated to the action center. 10 | 11 | The library comes with a script to implement it, :code:`register_hkey_aumid.py`. 12 | The arguments can be understood using the --help argument. If you have the Python Scripts directory in your path, you should be able to execute them by opening the command console and simply executing :code:`register_hkey_aumid`. 13 | 14 | Using an installed AUMID 15 | ------------------------ 16 | Microsoft.com has a page on `finding the Application User Model ID of an installed app `_. 17 | Below are the ways I recommend 18 | 19 | Using Powershell 20 | ~~~~~~~~~~~~~~~~ 21 | You can use Powershell to view existing AUMIDs. 22 | 23 | .. code-block:: powershell 24 | 25 | Get-StartApps 26 | 27 | Will return a table of all applications installed for the current user, with the right row containing AUMIDs for each corresponding name. 28 | 29 | Using the registry 30 | ~~~~~~~~~~~~~~~~~~ 31 | 32 | #. Open registry editor 33 | #. In the top address bar, paste :code:`HKEY_CURRENT_USER\\Software\\Classes\\ActivatableClasses\\Package` 34 | #. Many Microsoft product AUMIDs should be listed, among other third-party programs -------------------------------------------------------------------------------- /docs/dev/metadata.rst: -------------------------------------------------------------------------------- 1 | Metadata 2 | ======== 3 | 4 | .. autoattribute:: windows_toasts._version.__title__ 5 | .. autoattribute:: windows_toasts._version.__description__ 6 | .. autoattribute:: windows_toasts._version.__url__ 7 | .. autoattribute:: windows_toasts._version.__version__ 8 | .. autoattribute:: windows_toasts._version.__author__ 9 | .. autoattribute:: windows_toasts._version.__license__ 10 | -------------------------------------------------------------------------------- /docs/dev/toast_document.rst: -------------------------------------------------------------------------------- 1 | :py:mod:`windows_toasts.toast_document` 2 | ======================================= 3 | 4 | Classes 5 | ------- 6 | 7 | .. autosummary:: 8 | windows_toasts.toast_document.IXmlType 9 | windows_toasts.toast_document.ToastDocument 10 | 11 | Data 12 | ---- 13 | 14 | .. autodata:: windows_toasts.toast_document.IXmlType 15 | 16 | API 17 | --- 18 | 19 | .. automodule:: windows_toasts.toast_document 20 | :exclude-members: IXmlType 21 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Installing Windows-Toasts 5 | ------------------------- 6 | 7 | The latest version of Windows-Toasts requires Python 3.9 or later, and supports Windows 10 and 11. 8 | 9 | It can be installed using pip: 10 | 11 | .. code-block:: shell 12 | 13 | $ python -m pip install windows-toasts 14 | 15 | Install from source 16 | ~~~~~~~~~~~~~~~~~~~ 17 | 18 | The stable release version will most likely include the library's latest developments, but you can also install it directly from GitHub, where the code is 19 | `hosted `_. 20 | 21 | First, clone the repository: 22 | 23 | .. code-block:: shell 24 | 25 | $ git clone https://github.com/DatGuy1/Windows-Toasts.git 26 | 27 | You can then embed it in your own Python package, or install it into your site-packages: 28 | 29 | .. code-block:: shell 30 | 31 | $ cd Windows-Toasts 32 | $ python -m pip install . 33 | 34 | Quickstart 35 | ------------ 36 | 37 | To display a toast notification: 38 | 39 | .. code-block:: python 40 | 41 | # We import WindowsToaster and a toast format we want 42 | from windows_toasts import WindowsToaster, Toast 43 | # Prepare the toaster for bread (or your notification) 44 | toaster = WindowsToaster('Python') 45 | # Initialise the toast 46 | newToast = Toast() 47 | # Set the body of the notification 48 | newToast.text_fields = ['Hello, World!'] 49 | # And display it! 50 | toaster.show_toast(newToast) 51 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatGuy1/Windows-Toasts/8d3be759c13bd5d7b11a86a8fc6ef10350ffc8bc/docs/images/logo.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Windows-Toasts documentation master file, created by 2 | sphinx-quickstart on Fri Mar 10 09:44:43 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Windows-Toasts 7 | ========================================== 8 | 9 | Release v\ |version|. 10 | 11 | .. image:: https://static.pepy.tech/badge/windows-toasts/month 12 | :target: https://pepy.tech/project/windows-toasts 13 | :alt: Downloads Per Month Badge 14 | 15 | .. image:: https://img.shields.io/pypi/l/windows-toasts.svg 16 | :target: https://pypi.org/project/windows-toasts/ 17 | :alt: License Badge 18 | 19 | .. image:: https://img.shields.io/pypi/wheel/windows-toasts.svg 20 | :target: https://pypi.org/project/windows-toasts/ 21 | :alt: Wheel Support Badge 22 | 23 | .. image:: https://img.shields.io/pypi/pyversions/windows-toasts.svg 24 | :target: https://pypi.org/project/windows-toasts/ 25 | :alt: Python Version Support Badge 26 | 27 | .. image:: https://codecov.io/gh/DatGuy1/Windows-Toasts/branch/master/graph/badge.svg?token=ZD8OF2SF61) 28 | :target: https://codecov.io/gh/DatGuy1/Windows-Toasts 29 | :alt: Test Coverage Badge 30 | 31 | Windows-Toasts is a Python library used to send `toast notifications `_ on Windows machines. 32 | 33 | Why Windows-Toasts? 34 | ------------------- 35 | 36 | As opposed to other toast notification libraries, Windows-Toasts uses `Windows SDK `_ bindings to create and deliver notifications. 37 | This means no less-than-pretty Powershell hackyness and the like, and is in turn scalable, maintainable, and easy to use. 38 | 39 | The other packages I've seen also don't use tests or have no active maintainers, while Windows-Toasts has decent test coverage, is fully typed and documented, and has additional features. 40 | Any issues or feature requests you put on `GitHub `_ shouldn't stand there for too long without receiving a response. 41 | 42 | Contents 43 | -------- 44 | 45 | .. toctree:: 46 | :maxdepth: 3 47 | 48 | getting_started 49 | interactable 50 | advanced_usage 51 | custom_aumid 52 | 53 | .. toctree:: 54 | :maxdepth: 2 55 | :caption: User reference 56 | 57 | user/toasters 58 | user/toast 59 | user/audio 60 | user/wrappers 61 | user/exceptions 62 | 63 | .. toctree:: 64 | :maxdepth: 1 65 | :caption: Developer reference 66 | 67 | dev/toast_document 68 | dev/metadata 69 | 70 | .. toctree:: 71 | :maxdepth: 1 72 | :caption: Migration 73 | 74 | migration 75 | 76 | .. toctree:: 77 | :maxdepth: 1 78 | :caption: Other 79 | 80 | solving 81 | 82 | Indices and tables 83 | ================== 84 | 85 | * :ref:`genindex` 86 | * :ref:`modindex` 87 | * :ref:`search` 88 | -------------------------------------------------------------------------------- /docs/interactable.rst: -------------------------------------------------------------------------------- 1 | Interactable toasts 2 | =================== 3 | 4 | Interactable toasts are toast notifications that let the user interact with them, be it through different buttons or input fields. 5 | 6 | Usage 7 | ----- 8 | We import :class:`~windows_toasts.toasters.InteractableWindowsToaster` instead of :class:`~windows_toasts.windows_toasts.WindowsToaster`, but the rest is mostly the same. Here's a basic example: 9 | 10 | .. code-block:: python 11 | 12 | from windows_toasts import InteractableWindowsToaster, Toast, ToastActivatedEventArgs, ToastButton 13 | 14 | interactableToaster = InteractableWindowsToaster('Questionnaire') 15 | newToast = Toast(['How are you?']) 16 | 17 | # Add two actions (buttons) 18 | newToast.AddAction(ToastButton('Decent', 'response=decent')) 19 | newToast.AddAction(ToastButton('Not so good', 'response=bad')) 20 | 21 | # Display it like usual 22 | interactableToaster.show_toast(newToast) 23 | 24 | And we have buttons! We can't do much with them though, at least until we use on_activated. 25 | 26 | .. code-block:: python 27 | 28 | def activated_callback(activatedEventArgs: ToastActivatedEventArgs): 29 | print(activatedEventArgs.arguments) # response=decent/response=bad 30 | 31 | newToast.on_activated = activated_callback 32 | 33 | .. note:: 34 | To make sure the activation of the toast triggers the callback following its relegation to the action center, you must use a :doc:`custom AUMID `. 35 | 36 | Input fields 37 | ~~~~~~~~~~~~ 38 | 39 | Windows-Toasts also supports using text fields and selection boxes. 40 | 41 | .. code-block:: python 42 | 43 | from windows_toasts import InteractableWindowsToaster, Toast, ToastInputTextBox, ToastInputSelectionBox, ToastSelection 44 | 45 | interactableToaster = InteractableWindowsToaster('Questionnaire') 46 | newToast = Toast(['Please enter your details']) 47 | 48 | # A text input field asking the user for their name 49 | newToast.AddInput(ToastInputTextBox('name', 'Your name', 'Barack Obama')) 50 | 51 | # Create three selections: Male, female, other, and prefer not to say 52 | toastSelections = (ToastSelection('male', 'Male'), ToastSelection('female', 'Female'), ToastSelection('other', 'Other'), ToastSelection('unknown', 'Prefer not to say')) 53 | # Initialise the selection box with a caption 'What is your gender?'. The selections are passed in, and it defaults to 'prefer not to say.' 54 | selectionBoxInput = ToastInputSelectionBox('gender', 'What is your gender?', toastSelections, default_selection=toastSelections[3]) 55 | newToast.AddInput(selectionBoxInput) 56 | 57 | # For example: {'name': 'John Smith', 'gender': 'male'} 58 | newToast.on_activated = lambda activatedEventArgs: print(activatedEventArgs.inputs) 59 | 60 | interactableToaster.show_toast(newToast) 61 | 62 | In this case, the on_activated callback will be executed when the user presses on the notification. 63 | 64 | Combining the two 65 | ~~~~~~~~~~~~~~~~~ 66 | 67 | We can combine the two and a submit button 68 | 69 | .. code-block:: python 70 | :emphasize-lines: 7,8 71 | 72 | from windows_toasts import InteractableWindowsToaster, Toast 73 | 74 | interactableToaster = InteractableWindowsToaster('Questionnaire') 75 | newToast = Toast() 76 | 77 | newToast.text_fields = ['What\'s your name?'] 78 | newToast.AddInput(ToastInputTextBox('name', 'Your name', 'Barack Obama')) 79 | newToast.AddAction(ToastButton('Submit', 'submit')) 80 | newToast.on_activated = lambda activatedEventArgs: print(activatedEventArgs.input) 81 | 82 | interactableToaster.show_toast(newToast) 83 | 84 | Caveats 85 | ------- 86 | 87 | You may have noticed something weird when testing the above code. Why, when we display the toast, does it say command prompt in the top left, and have the icon for it? 88 | InteractableWindowsToaster requires an Application User Model ID (AUMID) to function properly. 89 | The package provides the command prompt as the default, and the applicationText becomes the :meth:`attribution text `. 90 | 91 | You can choose between staying with the default command prompt AUMID, :ref:`finding another one `, or :ref:`creating your own `. 92 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/migration.rst: -------------------------------------------------------------------------------- 1 | Migrating from v0.x to v1.0.0 2 | ============================= 3 | 4 | Version 1.0.0 comes with a large backend refactoring and simplification of existing features, along with a few new features. 5 | This guide will detail the changes and how to adapt to them. 6 | 7 | Replaced winsdk requirement with modularisation 8 | ----------------------------------------------- 9 | Instead of the 12 MB `winsdk `_ release, Windows-Toasts now uses a number of streamlined package to lessen install times and storage requirements. 10 | 11 | Toast class simplification 12 | -------------------------- 13 | Toasts no longer require a ToastType, but are rather initialised with just :class:`windows_toasts.toast.Toast`. 14 | 15 | In addition, all of the SetX methods have been removed in favour of directly modifying the attributes (the AddX methods remain for now). 16 | Set[Headline/Body/FirstLine/SecondLine] is now a list named :attr:`~windows_toasts.toast.Toast.text_fields`. Instead of using :code:`SetDuration()` and the like, just set it directly: :code:`toast.duration = ToastDuration.Short`. 17 | 18 | For instance, 19 | 20 | Here is how you would configure toasts before: 21 | 22 | .. code-block:: python 23 | 24 | from windows_toasts import WindowsToaster, ToastDuration 25 | 26 | from windows_toasts import ToastText2 27 | 28 | toast = ToastText2() 29 | 30 | toast.SetHeadline('Hello,') 31 | toast.SetBody('World!') 32 | 33 | toast.SetDuration(ToastDuration.Short) 34 | 35 | WindowsToaster('Python').show_toast(toast) 36 | 37 | Here's how you would do it now: 38 | 39 | .. code-block:: python 40 | 41 | from windows_toasts import WindowsToaster, ToastDuration 42 | 43 | from windows_toasts import Toast 44 | 45 | toast = Toast() 46 | 47 | toast.text_fields = ['Hello', 'World!'] 48 | # Or, directly, toast = Toast(['Hello', 'World!']) 49 | 50 | toast.duration = ToastDuration.short 51 | 52 | WindowsToaster('Python').show_toast(toast) 53 | 54 | and here's the highlighted difference between the two: 55 | 56 | .. code-block:: diff 57 | 58 | from windows_toasts import WindowsToaster, ToastDuration 59 | 60 | - from windows_toasts import ToastText2 61 | + from windows_toasts import Toast 62 | 63 | - toast = ToastText2() 64 | + toast = Toast() 65 | 66 | - toast.SetHeadline('Hello,') 67 | - toast.SetBody('World!') 68 | + toast.text_fields = ['Hello', 'World!'] 69 | 70 | - toast.SetDuration(ToastDuration.Short) 71 | + toast.duration = ToastDuration.short 72 | 73 | WindowsToaster('Python').show_toast(toast) 74 | 75 | 76 | New features 77 | ------------ 78 | 79 | Release v1.0.0 also arrives with a few new features 80 | 81 | Launching through protocols 82 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 83 | 84 | For applications that support protocols, you can now make your toasts and buttons launch that protocol directly. 85 | 86 | .. code-block:: python 87 | 88 | from windows_toasts import InteractableWindowsToaster, Toast, ToastButton 89 | 90 | protocol_toast = Toast(['Click the toast to launch google.com', 'or, alternatively'], launch_action='https://google.com') 91 | 92 | bing_button = ToastButton('Launch Bing', launch='https://bing.com') 93 | baidu_button = ToastButton('Launch Baidu', launch='https://baidu.com') 94 | 95 | protocol_toast.AddAction(bing_button) 96 | protocol_toast.AddAction(baidu_button) 97 | 98 | InteractableWindowsToaster('Browser Launcher').show_toast(protocol_toast) 99 | 100 | .. note:: 101 | Web browsers are not the only thing you can launch with protocols. 102 | Set :attr:`windows_toasts.wrappers.ToastButton.launch` to ``spotify:playlist:37i9dQZEVXbMDoHDwVN2tF`` to launch the Spotify client on the global Top 50, set it to ``steam://friends/status/offline`` to set yourself offline on the Steam client, et cetera. 103 | You can also launch files by entering their path. 104 | 105 | Inline images 106 | ^^^^^^^^^^^^^ 107 | 108 | Images have been reworked, with the :class:`windows_toasts.wrappers.ToastImagePosition` enum introducted as to make it possible to display more than two. 109 | 110 | .. code-block:: python 111 | 112 | # Downloads the Python logo 113 | import urllib.request 114 | from pathlib import Path 115 | 116 | # Save the image to python.png 117 | image_url = 'https://www.python.org/static/community_logos/python-powered-h-140x182.png' 118 | image_path = Path.cwd() / 'python.png' 119 | urllib.request.urlretrieve(image_url, image_path) 120 | 121 | from windows_toasts import InteractableWindowsToaster, Toast, ToastDisplayImage, ToastImage, ToastImagePosition 122 | toast_image_python = ToastImage(image_path) 123 | 124 | toast_images = [ 125 | ToastDisplayImage(toast_image_python, position=ToastImagePosition.Hero), 126 | ToastDisplayImage(toast_image_python, position=ToastImagePosition.AppLogo), 127 | ToastDisplayImage(toast_image_python, position=ToastImagePosition.Inline), 128 | ToastDisplayImage(toast_image_python, position=ToastImagePosition.Inline) 129 | ] 130 | new_toast = Toast(text_fields=['Hiss!'], images=toast_images) 131 | 132 | InteractableWindowsToaster('Python').show_toast(new_toast) 133 | 134 | System actions 135 | ^^^^^^^^^^^^^^ 136 | 137 | There is a writeup on how to use the snooze and dismiss system actions in the :ref:`system-actions` section -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==8.2.3 2 | 3 | sphinx-jinja2-compat==0.3.0 4 | sphinx-toolbox==3.9.0 5 | enum_tools==0.13.0 6 | 7 | furo==2024.8.6 -------------------------------------------------------------------------------- /docs/solving.rst: -------------------------------------------------------------------------------- 1 | Problem solving 2 | =============== 3 | 4 | If you are sending too many notifications within a short timespan, you may encounter the following exception: 5 | ``OSError: [WinError -2143420155] The notification platform is unavailable.`` 6 | 7 | You can solve this by either **rebooting**, or: 8 | 9 | #. disabling the WpnUserService through services.msc or the task manager 10 | #. deleting the ``%LOCALAPPDATA%\Microsoft\Windows\Notifications`` directory 11 | #. restarting the aforementioned service, and possibly WpnService as well 12 | 13 | The destructiveness of the latter process is undetermined. As always, rebooting the computer is the safest procedure. -------------------------------------------------------------------------------- /docs/user/audio.rst: -------------------------------------------------------------------------------- 1 | Audio 2 | ===== 3 | 4 | Classes 5 | ------- 6 | 7 | .. autosummary:: 8 | windows_toasts.toast_audio.AudioSource 9 | windows_toasts.toast_audio.ToastAudio 10 | 11 | API 12 | --------------- 13 | 14 | .. automodule:: windows_toasts.toast_audio 15 | -------------------------------------------------------------------------------- /docs/user/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | Classes 5 | ------- 6 | 7 | .. autosummary:: 8 | windows_toasts.exceptions.InvalidImageException 9 | windows_toasts.exceptions.ToastNotFoundError 10 | 11 | API 12 | --- 13 | 14 | .. automodule:: windows_toasts.exceptions -------------------------------------------------------------------------------- /docs/user/toast.rst: -------------------------------------------------------------------------------- 1 | Toast 2 | ==================================== 3 | 4 | 5 | Classes 6 | ------- 7 | 8 | .. autosummary:: 9 | windows_toasts.toast.ToastInput 10 | windows_toasts.toast.Toast 11 | 12 | Data 13 | ---- 14 | 15 | .. autodata:: windows_toasts.toast.ToastInput 16 | 17 | API 18 | --- 19 | 20 | .. automodule:: windows_toasts.toast 21 | :exclude-members: Toast, ToastInput 22 | 23 | .. autoclass:: windows_toasts.toast.Toast() 24 | 25 | .. automethod:: __init__ 26 | -------------------------------------------------------------------------------- /docs/user/toasters.rst: -------------------------------------------------------------------------------- 1 | Toasters 2 | ======== 3 | 4 | Classes 5 | ------- 6 | 7 | .. autosummary:: 8 | windows_toasts.toasters.ToastNotificationT 9 | windows_toasts.toasters.BaseWindowsToaster 10 | windows_toasts.toasters.WindowsToaster 11 | windows_toasts.toasters.InteractableWindowsToaster 12 | 13 | Data 14 | ---- 15 | 16 | .. autotypevar:: windows_toasts.toasters.ToastNotificationT 17 | 18 | API 19 | --- 20 | 21 | .. automodule:: windows_toasts.toasters 22 | :exclude-members: ToastNotificationT -------------------------------------------------------------------------------- /docs/user/wrappers.rst: -------------------------------------------------------------------------------- 1 | Wrappers 2 | ======== 3 | 4 | Classes 5 | ------- 6 | 7 | .. autosummary:: 8 | windows_toasts.wrappers.ToastButtonColour 9 | windows_toasts.wrappers.ToastDuration 10 | windows_toasts.wrappers.ToastImagePosition 11 | windows_toasts.wrappers.ToastScenario 12 | windows_toasts.wrappers.ToastSystemButtonAction 13 | windows_toasts.wrappers.ToastImage 14 | windows_toasts.wrappers.ToastDisplayImage 15 | windows_toasts.wrappers.ToastProgressBar 16 | windows_toasts.wrappers.ToastInputTextBox 17 | windows_toasts.wrappers.ToastSelection 18 | windows_toasts.wrappers.ToastInputSelectionBox 19 | windows_toasts.wrappers.ToastButton 20 | windows_toasts.wrappers.ToastSystemButton 21 | windows_toasts.events.ToastActivatedEventArgs 22 | 23 | API 24 | --- 25 | 26 | .. automodule:: windows_toasts.wrappers 27 | :exclude-members: ToastImage 28 | 29 | .. autoclass:: windows_toasts.wrappers.ToastImage 30 | 31 | .. automethod:: __init__ 32 | 33 | .. 34 | This will probably go into another file soon 35 | 36 | .. autoclass:: windows_toasts.events.ToastActivatedEventArgs 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.black] 9 | line_length = 120 10 | target_version = ["py38", "py39", "py310", "py311"] 11 | skip_magic_trailing_comma = true 12 | 13 | [tool.isort] 14 | profile = "black" 15 | combine_as_imports = true 16 | line_length = 120 17 | 18 | [tool.pytest.ini_options] 19 | pythonpath = ["src"] 20 | 21 | [tool.mypy] 22 | exclude = ["build/", "main.py"] -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Formatting 2 | black[colorama]==25.1.0 3 | flake8==7.2.0 4 | isort==6.0.1 5 | 6 | # Testing 7 | pytest==8.3.5 8 | pytest-cov==6.0.0 9 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatGuy1/Windows-Toasts/8d3be759c13bd5d7b11a86a8fc6ef10350ffc8bc/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/publish_gh_release_notes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Taken from https://github.com/pytest-dev/pytest/blob/main/scripts/publish-gh-release-notes.py 3 | 4 | Script used to publish GitHub release notes extracted from CHANGELOG.rst. 5 | 6 | This script is meant to be executed after a successful deployment in GitHub actions. 7 | 8 | Uses the following environment variables: 9 | 10 | * GIT_TAG: the name of the tag of the current commit. 11 | * GH_RELEASE_NOTES_TOKEN: a personal access token with 'repo' permissions. 12 | 13 | Create one at: 14 | 15 | https://github.com/settings/tokens 16 | 17 | This token should be set in a secret in the repository, which is exposed as an 18 | environment variable in the main.yml workflow file. 19 | 20 | The script also requires ``pandoc`` to be previously installed in the system. 21 | 22 | Requires Python3.6+. 23 | """ 24 | 25 | import os 26 | import re 27 | import sys 28 | from pathlib import Path 29 | 30 | import github3 31 | import pypandoc 32 | 33 | 34 | def upload_package_assets(created_release): 35 | dist_path = Path(__file__).parent.parent / "dist" 36 | for built_file in os.listdir(dist_path): 37 | with open(os.path.join(dist_path, built_file), "rb") as file: 38 | created_release.upload_asset(content_type="application/zip", name=built_file, asset=file) 39 | 40 | 41 | def publish_github_release(slug, token, tag_name, body): 42 | github = github3.login(token=token) 43 | owner, repo = slug.split("/") 44 | repo = github.repository(owner, repo) 45 | return repo.create_release(tag_name=tag_name, body=body) 46 | 47 | 48 | def parse_changelog(tag_name): 49 | p = Path(__file__).parent.parent / "CHANGELOG.rst" 50 | changelog_lines = p.read_text(encoding="UTF-8").splitlines() 51 | 52 | title_regex = re.compile(r"(\d\.\d+\.\d+) \(\d{4}-\d{2}-\d{2}\)") 53 | consuming_version = False 54 | version_lines = [] 55 | for line in changelog_lines: 56 | m = title_regex.match(line) 57 | if m: 58 | # found the version we want: start to consume lines until we find the next version title 59 | if m.group(1) == tag_name: 60 | consuming_version = True 61 | # found a new version title while parsing the version we want: break out 62 | elif consuming_version: 63 | break 64 | if consuming_version: 65 | version_lines.append(line) 66 | 67 | return "\n".join(version_lines) 68 | 69 | 70 | def convert_rst_to_md(text): 71 | return pypandoc.convert_text(text, "md", format="rst", extra_args=["--wrap=preserve"]) 72 | 73 | 74 | def main(argv): 75 | if len(argv) > 1: 76 | tag_name = argv[1] 77 | else: 78 | tag_name = os.environ.get("GITHUB_REF") 79 | if not tag_name: 80 | print("tag_name not given and $GITHUB_REF not set", file=sys.stderr) 81 | return 1 82 | if tag_name.startswith("refs/tags/"): 83 | # fmt: off 84 | tag_name = tag_name[len("refs/tags/"):] 85 | # fmt: on 86 | 87 | token = os.environ.get("GH_RELEASE_NOTES_TOKEN") 88 | if not token: 89 | print("GH_RELEASE_NOTES_TOKEN not set", file=sys.stderr) 90 | return 1 91 | 92 | slug = os.environ.get("GITHUB_REPOSITORY") 93 | if not slug: 94 | print("GITHUB_REPOSITORY not set", file=sys.stderr) 95 | return 1 96 | 97 | rst_body = parse_changelog(tag_name.lstrip("v")) 98 | md_body = convert_rst_to_md(rst_body) 99 | 100 | github_release = publish_github_release(slug, token, tag_name, md_body) 101 | if not github_release: 102 | print("Could not publish release notes:", file=sys.stderr) 103 | print(md_body, file=sys.stderr) 104 | return 5 105 | 106 | upload_package_assets(github_release) 107 | 108 | print() 109 | print(f"Release notes for {tag_name} published successfully:") 110 | print(f"https://github.com/{slug}/releases/tag/{tag_name}") 111 | print() 112 | return 0 113 | 114 | 115 | if __name__ == "__main__": 116 | sys.exit(main(sys.argv)) 117 | -------------------------------------------------------------------------------- /scripts/register_hkey_aumid.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pathlib 3 | 4 | # noinspection PyCompatibility 5 | import winreg 6 | from typing import Optional 7 | 8 | 9 | def register_hkey(appId: str, appName: str, iconPath: Optional[pathlib.Path]): 10 | if iconPath is not None: 11 | if not iconPath.exists(): 12 | raise ValueError(f"Could not register the application: File {iconPath} does not exist") 13 | elif iconPath.suffix != ".ico": 14 | raise ValueError(f"Could not register the application: File {iconPath} must be of type .ico") 15 | 16 | winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) 17 | keyPath = f"SOFTWARE\\Classes\\AppUserModelId\\{appId}" 18 | with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, keyPath) as masterKey: 19 | winreg.SetValueEx(masterKey, "DisplayName", 0, winreg.REG_SZ, appName) 20 | if iconPath is not None: 21 | winreg.SetValueEx(masterKey, "IconUri", 0, winreg.REG_SZ, str(iconPath.resolve())) 22 | 23 | 24 | def main(): # pragma: no cover 25 | parser = argparse.ArgumentParser(description="Register AUMID in the registry for use in toast notifications") 26 | parser.add_argument("--app_id", "-a", type=str, required=True, help="Application User Model ID for identification") 27 | parser.add_argument("--name", "-n", type=str, required=True, help="Display name on notification") 28 | parser.add_argument("--icon", "-i", type=pathlib.Path, required=False, help="Path to image file for desired icon") 29 | args = parser.parse_args() 30 | 31 | register_hkey(args.app_id, args.name, args.icon) 32 | print(f"Successfully registered the application ID '{args.app_id}'") 33 | 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | packages = ["windows_toasts", "scripts"] 4 | 5 | requires = [ 6 | "winrt-runtime~=3.0", 7 | "winrt-Windows.Data.Xml.Dom~=3.0", 8 | "winrt-Windows.Foundation~=3.0", 9 | "winrt-Windows.Foundation.Collections~=3.0", 10 | "winrt-Windows.UI.Notifications~=3.0", 11 | ] 12 | 13 | with open("README.md", "r", encoding="utf-8") as f: 14 | readme = f.read() 15 | 16 | about: dict[str, str] = {} 17 | with open("src/windows_toasts/_version.py", "r") as f: 18 | exec(f.read(), None, about) 19 | 20 | setup( 21 | name=about["__title__"], 22 | version=about["__version__"], 23 | description=about["__description__"], 24 | long_description=readme, 25 | long_description_content_type="text/markdown", 26 | author=about["__author__"], 27 | author_email="datguysteam@gmail.com", 28 | url=about["__url__"], 29 | packages=packages, 30 | package_dir={"windows_toasts": "src/windows_toasts"}, 31 | package_data={"": ["LICENSE"], "windows_toasts": ["py.typed"]}, 32 | include_package_data=True, 33 | entry_points={"console_scripts": ["register_hkey_aumid = scripts.register_hkey_aumid:main"]}, 34 | python_requires=">=3.9", 35 | install_requires=requires, 36 | license_files=["LICENSE"], 37 | zip_safe=False, 38 | classifiers=[ 39 | "Development Status :: 4 - Beta", 40 | "Programming Language :: Python :: 3", 41 | "Programming Language :: Python :: 3.9", 42 | "Programming Language :: Python :: 3.10", 43 | "Programming Language :: Python :: 3.11", 44 | "Programming Language :: Python :: 3.12", 45 | "Operating System :: Microsoft :: Windows :: Windows 10", 46 | "Operating System :: Microsoft :: Windows :: Windows 11", 47 | ], 48 | project_urls={ 49 | "Documentation": "https://windows-toasts.readthedocs.io", 50 | "Source": "https://github.com/DatGuy1/Windows-Toasts", 51 | }, 52 | ) 53 | -------------------------------------------------------------------------------- /src/windows_toasts/__init__.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from .exceptions import UnsupportedOSVersionException 4 | 5 | # We'll assume it's Windows since if it's on another OS it should be self-explanatory 6 | MIN_VERSION = 10240 7 | if platform.system() == "Windows" and (osVersion := int(platform.version().split(".")[2])) < MIN_VERSION: 8 | raise UnsupportedOSVersionException( 9 | f"Platform version {osVersion} is not supported. Required minimum is {MIN_VERSION}" 10 | ) 11 | 12 | from ._version import __author__, __description__, __license__, __title__, __url__, __version__ # noqa: F401 13 | from .events import ToastActivatedEventArgs, ToastDismissalReason, ToastDismissedEventArgs, ToastFailedEventArgs 14 | from .exceptions import InvalidImageException, ToastNotFoundError 15 | from .toast import Toast 16 | from .toast_audio import AudioSource, ToastAudio 17 | from .toasters import InteractableWindowsToaster, WindowsToaster 18 | from .wrappers import ( 19 | ToastButton, 20 | ToastButtonColour, 21 | ToastDisplayImage, 22 | ToastDuration, 23 | ToastImage, 24 | ToastImagePosition, 25 | ToastInputSelectionBox, 26 | ToastInputTextBox, 27 | ToastProgressBar, 28 | ToastScenario, 29 | ToastSelection, 30 | ToastSystemButton, 31 | ToastSystemButtonAction, 32 | ) 33 | 34 | __all__ = [ 35 | # _version.py 36 | "__author__", 37 | "__description__", 38 | "__license__", 39 | "__title__", 40 | "__url__", 41 | "__version__", 42 | # events.py 43 | "ToastActivatedEventArgs", 44 | "ToastDismissalReason", 45 | "ToastDismissedEventArgs", 46 | "ToastFailedEventArgs", 47 | # exceptions.py 48 | "InvalidImageException", 49 | "ToastNotFoundError", 50 | "UnsupportedOSVersionException", 51 | # toast_audio.py 52 | "AudioSource", 53 | "ToastAudio", 54 | # toast.py 55 | "Toast", 56 | # toasters.py 57 | "InteractableWindowsToaster", 58 | "WindowsToaster", 59 | # wrappers.py 60 | "ToastButton", 61 | "ToastButtonColour", 62 | "ToastDisplayImage", 63 | "ToastDuration", 64 | "ToastImage", 65 | "ToastImagePosition", 66 | "ToastInputSelectionBox", 67 | "ToastInputTextBox", 68 | "ToastProgressBar", 69 | "ToastScenario", 70 | "ToastSelection", 71 | "ToastSystemButton", 72 | "ToastSystemButtonAction", 73 | ] 74 | -------------------------------------------------------------------------------- /src/windows_toasts/_version.py: -------------------------------------------------------------------------------- 1 | __title__ = "Windows-Toasts" 2 | __description__ = "Python library used to send toast notifications on Windows machines" 3 | __url__ = "https://github.com/DatGuy1/Windows-Toasts" 4 | __version__ = "1.3.1" 5 | __author__ = "DatGuy" 6 | __license__ = "Apache-2.0" 7 | -------------------------------------------------------------------------------- /src/windows_toasts/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Dict, Optional 5 | 6 | from winrt import system 7 | from winrt.windows.ui.notifications import ( # noqa: F401 8 | ToastActivatedEventArgs as WinRtToastActivatedEventArgs, 9 | ToastDismissalReason, 10 | ToastDismissedEventArgs, 11 | ToastFailedEventArgs, 12 | ) 13 | 14 | 15 | @dataclass 16 | class ToastActivatedEventArgs: 17 | """ 18 | Wrapper over Windows' ToastActivatedEventArgs to fix an issue with reading user input 19 | """ 20 | 21 | arguments: Optional[str] = None 22 | """Arguments provided to :func:`~windows_toasts.toast.Toast.AddAction`""" 23 | inputs: Optional[dict] = None 24 | """Inputs received when using :func:`~windows_toasts.toast.Toast.AddInput`""" 25 | 26 | # noinspection PyProtectedMember 27 | @classmethod 28 | def fromWinRt(cls, eventArgs: system.Object) -> ToastActivatedEventArgs: 29 | activatedEventArgs = eventArgs.as_(WinRtToastActivatedEventArgs) 30 | receivedInputs: Optional[Dict[str, str]] = None 31 | try: 32 | receivedInputs = {k: system.unbox_string(v) for k, v in activatedEventArgs.user_input.items()} 33 | except OSError: 34 | pass 35 | 36 | return cls(activatedEventArgs.arguments, receivedInputs) 37 | -------------------------------------------------------------------------------- /src/windows_toasts/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidImageException(Exception): 2 | """The image provided was invalid""" 3 | 4 | 5 | class ToastNotFoundError(Exception): 6 | """The toast could not be found""" 7 | 8 | 9 | class UnsupportedOSVersionException(ImportError): 10 | """The operating system version is not supported""" 11 | -------------------------------------------------------------------------------- /src/windows_toasts/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatGuy1/Windows-Toasts/8d3be759c13bd5d7b11a86a8fc6ef10350ffc8bc/src/windows_toasts/py.typed -------------------------------------------------------------------------------- /src/windows_toasts/toast.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | import datetime 5 | import urllib.parse 6 | import uuid 7 | import warnings 8 | from collections.abc import Iterable 9 | from typing import Callable, Optional, Union 10 | 11 | from winrt.windows.ui.notifications import ToastDismissedEventArgs, ToastFailedEventArgs 12 | 13 | from .events import ToastActivatedEventArgs 14 | from .toast_audio import ToastAudio 15 | from .wrappers import ( 16 | ToastButton, 17 | ToastDisplayImage, 18 | ToastDuration, 19 | ToastInputSelectionBox, 20 | ToastInputTextBox, 21 | ToastProgressBar, 22 | ToastScenario, 23 | ToastSystemButton, 24 | ) 25 | 26 | ToastInput = Union[ToastInputTextBox, ToastInputSelectionBox] 27 | 28 | 29 | class Toast: 30 | audio: Optional[ToastAudio] 31 | """The custom audio configuration for the toast""" 32 | duration: ToastDuration 33 | """:class:`~windows_toasts.wrappers.ToastDuration`, be it the default, short, or long""" 34 | expiration_time: Optional[datetime.datetime] 35 | """The time for the toast to expire on in the action center. If it is on-screen, nothing will happen""" 36 | group: Optional[str] 37 | """An internal identifier, where you can assign groups like "wallPosts", "messages", "friendRequests", etc.""" 38 | scenario: ToastScenario 39 | """Scenario for the toast""" 40 | suppress_popup: bool 41 | """Whether to suppress the toast popup and relegate it immediately to the action center""" 42 | timestamp: Optional[datetime.datetime] 43 | """A custom timestamp. If you don't provide one, Windows uses the time that your notification was sent""" 44 | progress_bar: Optional[ToastProgressBar] 45 | """An adjustable progress bar for the toast""" 46 | attribution_text: Optional[str] 47 | """Text displayed below any text elements, but above inline images""" 48 | on_activated: Optional[Callable[[ToastActivatedEventArgs], None]] 49 | """Callable to execute when the toast is clicked if basic, or a button is clicked if interactable""" 50 | on_dismissed: Optional[Callable[[ToastDismissedEventArgs], None]] 51 | """Callable to execute when the toast is dismissed (X is clicked or times out) if interactable""" 52 | on_failed: Optional[Callable[[ToastFailedEventArgs], None]] 53 | """Callable to execute when the toast fails to display""" 54 | actions: list[Union[ToastButton, ToastSystemButton]] 55 | """List of buttons to include. Implemented through :func:`AddAction`""" 56 | images: list[ToastDisplayImage] 57 | """See :func:`AddImage`""" 58 | inputs: list[ToastInput] 59 | """Text/selection input boxes""" 60 | text_fields: list[Optional[str]] 61 | """Various text fields""" 62 | tag: str 63 | """Unique tag for the toast, automatically set as a UUID""" 64 | updates: int 65 | """Number of times the toast has been updated; mostly for internal use""" 66 | _launch_action: Optional[str] 67 | """Protocol to launch when the toast is clicked""" 68 | 69 | def __init__( 70 | self, 71 | text_fields: Union[None, list[Optional[str]], tuple[Optional[str]], set[Optional[str]]] = None, 72 | audio: Optional[ToastAudio] = None, 73 | duration: ToastDuration = ToastDuration.Default, 74 | expiration_time: Optional[datetime.datetime] = None, 75 | group: Optional[str] = None, 76 | launch_action: Optional[str] = None, 77 | progress_bar: Optional[ToastProgressBar] = None, 78 | attribution_text: Optional[str] = None, 79 | scenario: ToastScenario = ToastScenario.Default, 80 | suppress_popup: bool = False, 81 | timestamp: Optional[datetime.datetime] = None, 82 | on_activated: Optional[Callable[[ToastActivatedEventArgs], None]] = None, 83 | on_dismissed: Optional[Callable[[ToastDismissedEventArgs], None]] = None, 84 | on_failed: Optional[Callable[[ToastFailedEventArgs], None]] = None, 85 | actions: Iterable[Union[ToastButton, ToastSystemButton]] = (), 86 | images: Iterable[ToastDisplayImage] = (), 87 | inputs: Iterable[ToastInput] = (), 88 | ) -> None: 89 | """ 90 | Initialise a toast 91 | 92 | :param actions: Iterable of actions to add; see :meth:`AddAction` 93 | :type actions: Iterable[Union[ToastButton, ToastSystemButton]] 94 | :param images: See :meth:`AddImage` 95 | :type images: Iterable[ToastDisplayImage] 96 | :param inputs: See :meth:`AddInput` 97 | :type inputs: Iterable[ToastInput] 98 | """ 99 | self.audio = audio 100 | self.duration = duration 101 | self.scenario = scenario 102 | self.progress_bar = progress_bar 103 | self.attribution_text = attribution_text 104 | self.timestamp = timestamp 105 | self.group = group 106 | self.expiration_time = expiration_time 107 | self.suppress_popup = suppress_popup 108 | self.launch_action = launch_action 109 | 110 | self.actions = [] 111 | self.images = [] 112 | self.inputs = [] 113 | self.text_fields = [] if text_fields is None else list(text_fields) 114 | 115 | for action in actions: 116 | self.AddAction(action) 117 | 118 | for image in images: 119 | self.AddImage(image) 120 | 121 | for toast_input in inputs: 122 | self.AddInput(toast_input) 123 | 124 | self.on_activated = on_activated 125 | self.on_dismissed = on_dismissed 126 | self.on_failed = on_failed 127 | 128 | self.tag = str(uuid.uuid4()) 129 | self.updates = 0 130 | 131 | def __eq__(self, other): 132 | if isinstance(other, Toast): 133 | return other.tag == self.tag 134 | 135 | return False 136 | 137 | def __repr__(self): 138 | kws = [f"{key}={value!r}" for key, value in self.__dict__.items()] 139 | return "{}({})".format(type(self).__name__, ", ".join(kws)) 140 | 141 | def AddAction(self, action: Union[ToastButton, ToastSystemButton]) -> None: 142 | """ 143 | Add an action to the action list. For example, if you're setting up a reminder, 144 | you would use 'action=remindlater&date=2020-01-20' as arguments. Maximum of five. 145 | 146 | :type action: Union[ToastButton, ToastSystemButton] 147 | """ 148 | if len(self.actions) + len(self.inputs) >= 5: 149 | warnings.warn( 150 | f"Cannot add action '{action.content}', you've already reached the maximum of five actions + inputs" 151 | ) 152 | return 153 | 154 | self.actions.append(action) 155 | 156 | def AddImage(self, image: ToastDisplayImage) -> None: 157 | """ 158 | Adds an the image that will be displayed on the toast. 159 | If using WindowsToaster, a maximum of two (one as the logo and one hero) images will work. 160 | 161 | :param image: :class:`ToastDisplayImage` to display in the toast 162 | """ 163 | self.images.append(image) 164 | 165 | def AddInput(self, toast_input: ToastInput) -> None: 166 | """ 167 | Adds an input field to the notification. It will be supplied as user_input of type ValueSet in on_activated 168 | 169 | :param toast_input: :class:`ToastInput` to display in the toast 170 | """ 171 | if len(self.actions) + len(self.inputs) >= 5: 172 | warnings.warn( 173 | f"Cannot add input '{toast_input.input_id}', " 174 | f"you've already reached the maximum of five actions + inputs" 175 | ) 176 | return 177 | 178 | self.inputs.append(toast_input) 179 | 180 | @property 181 | def launch_action(self) -> Optional[str]: 182 | """Protocol to launch when the toast is clicked""" 183 | return self._launch_action 184 | 185 | @launch_action.setter 186 | def launch_action(self, value: Optional[str]): 187 | if value is None: 188 | self._launch_action = None 189 | else: 190 | if not urllib.parse.urlparse(value).scheme: 191 | warnings.warn(f"Ensure your launch action of {value} is of a proper protocol") 192 | 193 | self._launch_action = value 194 | 195 | def clone(self) -> Toast: 196 | """ 197 | Clone the current toast and return the new one 198 | 199 | :return: A deep copy of the toast 200 | :rtype: Toast 201 | """ 202 | newToast = copy.deepcopy(self) 203 | newToast.tag = str(uuid.uuid4()) 204 | 205 | return newToast 206 | -------------------------------------------------------------------------------- /src/windows_toasts/toast_audio.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from dataclasses import dataclass 3 | from enum import Enum 4 | from pathlib import Path 5 | from typing import Union 6 | 7 | # According to https://learn.microsoft.com/windows/apps/design/shell/tiles-and-notifications/custom-audio-on-toasts 8 | SUPPORTED_FILE_TYPES = [".aac", ".flac", ".m4a", ".mp3", ".wav", ".wma"] 9 | 10 | 11 | class AudioSource(Enum): 12 | """ 13 | Different audios built into Windows 14 | """ 15 | 16 | Default = "Default" 17 | IM = "IM" 18 | Mail = "Mail" 19 | Reminder = "Reminder" 20 | SMS = "SMS" 21 | Alarm = "Looping.Alarm" 22 | Alarm2 = "Looping.Alarm2" 23 | Alarm3 = "Looping.Alarm3" 24 | Alarm4 = "Looping.Alarm4" 25 | Alarm5 = "Looping.Alarm5" 26 | Alarm6 = "Looping.Alarm6" 27 | Alarm7 = "Looping.Alarm7" 28 | Alarm8 = "Looping.Alarm8" 29 | Alarm9 = "Looping.Alarm9" 30 | Alarm10 = "Looping.Alarm10" 31 | Call = "Looping.Call" 32 | Call2 = "Looping.Call2" 33 | Call3 = "Looping.Call3" 34 | Call4 = "Looping.Call4" 35 | Call5 = "Looping.Call5" 36 | Call6 = "Looping.Call6" 37 | Call7 = "Looping.Call7" 38 | Call8 = "Looping.Call8" 39 | Call9 = "Looping.Call9" 40 | Call10 = "Looping.Call10" 41 | 42 | 43 | @dataclass 44 | class ToastAudio: 45 | """ 46 | Audio configuration in a toast 47 | 48 | :param sound: Selected AudioSource or pathlib.Path to an audio file to play 49 | :type sound: Union[AudioSource, Path] 50 | :param looping: Whether the audio should loop continuously. Stops abruptly when the notification is dismissed 51 | :type looping: bool 52 | :param silent: Silence any audio 53 | :type silent: bool 54 | """ 55 | 56 | sound: Union[AudioSource, Path] = AudioSource.Default 57 | looping: bool = False 58 | silent: bool = False 59 | 60 | @property 61 | def sound_value(self) -> str: 62 | """ 63 | Returns the string value of the selected sound. 64 | Warns if using a non-existant file, or one which has a unsupported extension 65 | """ 66 | if isinstance(self.sound, AudioSource): 67 | return f"ms-winsoundevent:Notification.{self.sound.value}" 68 | else: 69 | # We warn instead of erroring out because that's what Windows does, but I'm open to changing it 70 | if not self.sound.exists(): 71 | warnings.warn(f"Custom audio file '{self.sound}' could not be found") 72 | if self.sound.suffix not in SUPPORTED_FILE_TYPES: 73 | warnings.warn(f"Custom audio file '{self.sound}' has unsupported extension '{self.sound.suffix}'") 74 | 75 | return self.sound.absolute().as_uri() 76 | -------------------------------------------------------------------------------- /src/windows_toasts/toast_document.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Union 3 | 4 | from winrt.windows.data.xml.dom import IXmlNode, XmlDocument, XmlElement 5 | 6 | from .toast import Toast 7 | from .toast_audio import ToastAudio 8 | from .wrappers import ( 9 | ToastButton, 10 | ToastButtonColour, 11 | ToastDisplayImage, 12 | ToastDuration, 13 | ToastInputSelectionBox, 14 | ToastInputTextBox, 15 | ToastProgressBar, 16 | ToastScenario, 17 | ToastSystemButton, 18 | ToastSystemButtonAction, 19 | ) 20 | 21 | IXmlType = Union[IXmlNode, XmlElement] 22 | 23 | 24 | class ToastDocument: 25 | """ 26 | The XmlDocument wrapper for toasts, which applies all the 27 | attributes configured in :class:`~windows_toasts.toast.Toast` 28 | """ 29 | 30 | xmlDocument: XmlDocument 31 | bindingNode: IXmlType 32 | """Binding node, as to avoid having to find it every time""" 33 | _inputFields: int 34 | """Tracker of number of input fields""" 35 | 36 | def __init__(self, toast: Toast) -> None: 37 | self.xmlDocument = XmlDocument() 38 | self.xmlDocument.load_xml("") 39 | self.bindingNode = self.GetElementByTagName("binding") 40 | 41 | # Unclear whether this leads to issues regarding spacing 42 | for i in range(len(toast.text_fields)): 43 | textElement = self.xmlDocument.create_element("text") 44 | # Needed for WindowsToaster 45 | self.SetAttribute(textElement, "id", str(i + 1)) 46 | self.bindingNode.append_child(textElement) 47 | 48 | # Not sure if this is the best way to do this along with the clone bit in AddImage() 49 | if len(toast.images) > 0: 50 | imageElement = self.xmlDocument.create_element("image") 51 | self.SetAttribute(imageElement, "src", "") 52 | # Needed for WindowsToaster 53 | self.SetAttribute(imageElement, "id", "1") 54 | self.bindingNode.append_child(imageElement) 55 | 56 | self._inputFields = 0 57 | 58 | @staticmethod 59 | def GetAttributeValue(nodeAttribute: IXmlType, attributeName: str) -> str: 60 | """ 61 | Helper function that returns an attribute's value 62 | 63 | :param nodeAttribute: Node that has the attribute 64 | :type nodeAttribute: IXmlType 65 | :param attributeName: Name of the attribute, e.g. "duration" 66 | :type attributeName: str 67 | :return: The value of the attribute 68 | :rtype: str 69 | """ 70 | return nodeAttribute.attributes.get_named_item(attributeName).inner_text 71 | 72 | def GetElementByTagName(self, tagName: str) -> IXmlType: 73 | """ 74 | Helper function to get the first element by its tag name 75 | 76 | :param tagName: The name of the tag for the element 77 | :type tagName: str 78 | :rtype: IXmlType 79 | """ 80 | # Is this way faster? Or is self.xmlDocument.select_single_node(f"/{tagName}") ? 81 | return self.xmlDocument.get_elements_by_tag_name(tagName).item(0) 82 | 83 | def SetAttribute(self, nodeAttribute: IXmlType, attributeName: str, attributeValue: str) -> None: 84 | """ 85 | Helper function to set an attribute to a node. 86 | 87 | :param nodeAttribute: Node to apply attributes to 88 | :type nodeAttribute: IXmlType 89 | :param attributeName: Name of the attribute, e.g. "duration" 90 | :type attributeName: str 91 | :param attributeValue: Value of the attribute, e.g. "long" 92 | :type attributeValue: str 93 | """ 94 | nodeAttribute.attributes.set_named_item(self.xmlDocument.create_attribute(attributeName)) 95 | nodeAttribute.attributes.get_named_item(attributeName).inner_text = attributeValue 96 | 97 | def SetNodeStringValue(self, targetNode: IXmlType, newValue: str) -> None: 98 | """ 99 | Helper function to set the inner value of a node. newValue 100 | 101 | :param targetNode: Node to apply attributes to 102 | :type targetNode: IXmlType 103 | :param newValue: Inner text of the node, e.g. "Hello, World!" 104 | :type newValue: str 105 | """ 106 | newNode = self.xmlDocument.create_text_node(newValue) 107 | targetNode.append_child(newNode) 108 | 109 | def SetAttributionText(self, attributionText: str) -> None: 110 | """ 111 | Set attribution text for the toast. This is used if we're using 112 | :class:`~windows_toasts.toasters.InteractableWindowsToaster` but haven't set up our own AUMID. 113 | `AttributionText on Microsoft.com `_ 115 | 116 | :param attributionText: Attribution text to set 117 | """ 118 | newElement = self.xmlDocument.create_element("text") 119 | self.bindingNode.append_child(newElement) 120 | self.SetAttribute(newElement, "placement", "attribution") 121 | self.SetNodeStringValue(newElement, attributionText) 122 | 123 | def SetAudioAttributes(self, audioConfiguration: ToastAudio) -> None: 124 | """ 125 | Apply audio attributes for the toast. If a loop is requested, the toast duration has to be set to long. `Audio 126 | on Microsoft.com `_ 128 | """ 129 | audioNode = self.GetElementByTagName("audio") 130 | if audioNode is None: 131 | audioNode = self.xmlDocument.create_element("audio") 132 | self.GetElementByTagName("toast").append_child(audioNode) 133 | 134 | if audioConfiguration.silent: 135 | self.SetAttribute(audioNode, "silent", str(audioConfiguration.silent).lower()) 136 | return 137 | 138 | self.SetAttribute(audioNode, "src", audioConfiguration.sound_value) 139 | if audioConfiguration.looping: 140 | self.SetAttribute(audioNode, "loop", str(audioConfiguration.looping).lower()) 141 | # Looping audio requires the duration attribute in the audio element's parent toast element to be "long" 142 | self.SetDuration(ToastDuration.Long) 143 | 144 | def SetTextField(self, nodePosition: int) -> None: 145 | """ 146 | Set a simple text field. `Text elements on Microsoft.com 147 | `_ 148 | 149 | :param nodePosition: Index of the text fields of the toast type for the text to be written in 150 | """ 151 | targetNode = self.xmlDocument.get_elements_by_tag_name("text").item(nodePosition) 152 | 153 | # We used to simply set it to newValue, but since we've now switched to BindableString we just set it to text{i} 154 | # Set it to i + 1 just because starting at 1 rather than 0 is easier on the eye 155 | self.SetNodeStringValue(targetNode, f"{{text{nodePosition + 1}}}") 156 | 157 | def SetTextFieldStatic(self, nodePosition: int, newValue: str) -> None: 158 | """ 159 | :meth:`SetTextField` but static, generally used for scheduled toasts 160 | 161 | :param nodePosition: Index of the text fields of the toast type for the text to be written in 162 | :param newValue: Content value of the text field 163 | """ 164 | targetNode = self.xmlDocument.get_elements_by_tag_name("text").item(nodePosition) 165 | self.SetNodeStringValue(targetNode, newValue) 166 | 167 | def SetCustomTimestamp(self, customTimestamp: datetime.datetime) -> None: 168 | """ 169 | Apply a custom timestamp to display on the toast and in the notification center. `Custom timestamp on 170 | Microsoft.com `_ 172 | 173 | :param customTimestamp: The target datetime 174 | :type customTimestamp: datetime.datetime 175 | """ 176 | toastNode = self.GetElementByTagName("toast") 177 | self.SetAttribute(toastNode, "displayTimestamp", customTimestamp.strftime("%Y-%m-%dT%H:%M:%SZ")) 178 | 179 | def AddImage(self, displayImage: ToastDisplayImage) -> None: 180 | """ 181 | Add an image to display. `Inline image on Microsoft.com `_ 183 | 184 | :type displayImage: ToastDisplayImage 185 | """ 186 | imageNode = self.GetElementByTagName("image") 187 | if self.GetAttributeValue(imageNode, "src") != "": 188 | # For WindowsToaster 189 | imageNode = imageNode.clone_node(True) 190 | self.SetAttribute(imageNode, "id", "2") 191 | self.bindingNode.append_child(imageNode) 192 | 193 | self.SetAttribute(imageNode, "src", str(displayImage.image.path)) 194 | 195 | if displayImage.altText is not None: 196 | self.SetAttribute(imageNode, "alt", displayImage.altText) 197 | 198 | self.SetAttribute(imageNode, "placement", displayImage.position.value) 199 | 200 | if displayImage.circleCrop: 201 | self.SetAttribute(imageNode, "hint-crop", "circle") 202 | 203 | def SetScenario(self, scenario: ToastScenario) -> None: 204 | """ 205 | Set whether the notification should be marked as important. `Important Notifications on Microsoft.com 206 | `_ 208 | 209 | :param scenario: Scenario to mark the toast as 210 | :type scenario: ToastScenario 211 | """ 212 | toastNode = self.GetElementByTagName("toast") 213 | self.SetAttribute(toastNode, "scenario", scenario.value) 214 | 215 | def AddInput(self, toastInput: Union[ToastInputTextBox, ToastInputSelectionBox]) -> None: 216 | """ 217 | Add a field for the user to input. `Inputs with button bar on Microsoft.com 218 | `_ 220 | 221 | :type toastInput: Union[ToastInputTextBox, ToastInputSelectionBox] 222 | """ 223 | self._inputFields += 1 224 | inputNode = self.xmlDocument.create_element("input") 225 | self.SetAttribute(inputNode, "id", toastInput.input_id) 226 | self.SetAttribute(inputNode, "title", toastInput.caption) 227 | 228 | if isinstance(toastInput, ToastInputTextBox): 229 | self.SetAttribute(inputNode, "type", "text") 230 | # noinspection PyUnresolvedReferences 231 | self.SetAttribute(inputNode, "placeHolderContent", toastInput.placeholder) 232 | elif isinstance(toastInput, ToastInputSelectionBox): 233 | self.SetAttribute(inputNode, "type", "selection") 234 | if toastInput.default_selection is not None: 235 | self.SetAttribute(inputNode, "defaultInput", toastInput.default_selection.selection_id) 236 | 237 | for selection in toastInput.selections: 238 | selectionElement = self.xmlDocument.create_element("selection") 239 | self.SetAttribute(selectionElement, "id", selection.selection_id) 240 | self.SetAttribute(selectionElement, "content", selection.content) 241 | inputNode.append_child(selectionElement) 242 | 243 | actionNodes = self.xmlDocument.get_elements_by_tag_name("actions") 244 | if actionNodes.length > 0: 245 | actionsNode = actionNodes.item(0) 246 | # actionsNode.insert_before(inputNode, actionsNode.first_child) 247 | actionsNode.append_child(inputNode) 248 | else: 249 | toastNode = self.GetElementByTagName("toast") 250 | 251 | actionsNode = self.xmlDocument.create_element("actions") 252 | toastNode.append_child(actionsNode) 253 | 254 | actionsNode.append_child(inputNode) 255 | 256 | def SetDuration(self, duration: ToastDuration) -> None: 257 | """ 258 | Set the duration of the toast. If looping audio is enabled, it will automatically be set to long 259 | 260 | :type duration: ToastDuration 261 | """ 262 | toastNode = self.GetElementByTagName("toast") 263 | self.SetAttribute(toastNode, "duration", duration.value) 264 | 265 | def AddAction(self, action: Union[ToastButton, ToastSystemButton]) -> None: 266 | """ 267 | Adds a button to the toast. Only works on :obj:`~windows_toasts.toasters.InteractableWindowsToaster` 268 | 269 | :type action: Union[ToastButton, ToastSystemButton] 270 | """ 271 | actionNodes = self.xmlDocument.get_elements_by_tag_name("actions") 272 | if actionNodes.length > 0: 273 | actionsNode = actionNodes.item(0) 274 | else: 275 | actionsNode = self.xmlDocument.create_element("actions") 276 | self.GetElementByTagName("toast").append_child(actionsNode) 277 | 278 | actionNode = self.xmlDocument.create_element("action") 279 | self.SetAttribute(actionNode, "content", action.content) 280 | 281 | if isinstance(action, ToastButton): 282 | if action.launch is None: 283 | self.SetAttribute(actionNode, "arguments", action.arguments) 284 | else: 285 | self.SetAttribute(actionNode, "activationType", "protocol") 286 | self.SetAttribute(actionNode, "arguments", action.launch) 287 | 288 | if action.inContextMenu: 289 | self.SetAttribute(actionNode, "placement", "contextMenu") 290 | if action.tooltip is not None: 291 | self.SetAttribute(actionNode, "hint-tooltip", action.tooltip) 292 | elif isinstance(action, ToastSystemButton): 293 | self.SetAttribute(actionNode, "activationType", "system") 294 | if action.action == ToastSystemButtonAction.Snooze: 295 | self.SetAttribute(actionNode, "arguments", "snooze") 296 | elif action.action == ToastSystemButtonAction.Dismiss: 297 | self.SetAttribute(actionNode, "arguments", "dismiss") 298 | 299 | if action.image is not None: 300 | self.SetAttribute(actionNode, "imageUri", action.image.path) 301 | if action.relatedInput is not None: 302 | self.SetAttribute(actionNode, "hint-inputId", action.relatedInput.input_id) 303 | if action.colour is not ToastButtonColour.Default: 304 | self.SetAttribute(actionNode, "hint-buttonStyle", action.colour.value) 305 | 306 | actionsNode.append_child(actionNode) 307 | 308 | def AddProgressBar(self) -> None: 309 | """ 310 | Add a progress bar on your app notification to keep the user informed of the progress of operations. 311 | `Progress bar on Microsoft.com `_ 313 | """ 314 | progressBarNode = self.xmlDocument.create_element("progress") 315 | self.SetAttribute(progressBarNode, "status", "{status}") 316 | self.SetAttribute(progressBarNode, "value", "{progress}") 317 | 318 | self.SetAttribute(progressBarNode, "valueStringOverride", "{progress_override}") 319 | self.SetAttribute(progressBarNode, "title", "{caption}") 320 | 321 | self.bindingNode.append_child(progressBarNode) 322 | 323 | def AddStaticProgressBar(self, progressBar: ToastProgressBar) -> None: 324 | """ 325 | :meth:`AddProgressBar` but static, generally used for scheduled toasts 326 | """ 327 | progressBarNode = self.xmlDocument.create_element("progress") 328 | self.SetAttribute(progressBarNode, "status", progressBar.status) 329 | self.SetAttribute( 330 | progressBarNode, "value", "indeterminate" if progressBar.progress is None else str(progressBar.progress) 331 | ) 332 | 333 | if progressBar.progress_override is not None: 334 | self.SetAttribute(progressBarNode, "valueStringOverride", progressBar.progress_override) 335 | if progressBar.caption is not None: 336 | self.SetAttribute(progressBarNode, "title", progressBar.caption) 337 | 338 | self.bindingNode.append_child(progressBarNode) 339 | -------------------------------------------------------------------------------- /src/windows_toasts/toasters.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from datetime import datetime 3 | from typing import Optional, TypeVar 4 | 5 | from winrt.windows.ui.notifications import ( 6 | NotificationData, 7 | NotificationUpdateResult, 8 | ScheduledToastNotification, 9 | ToastNotification, 10 | ToastNotificationHistory, 11 | ToastNotificationManager, 12 | ToastNotifier, 13 | ) 14 | 15 | from .events import ToastActivatedEventArgs 16 | from .exceptions import ToastNotFoundError 17 | from .toast import Toast 18 | from .toast_document import ToastDocument 19 | from .wrappers import ToastDuration, ToastImagePosition, ToastScenario 20 | 21 | ToastNotificationT = TypeVar("ToastNotificationT", ToastNotification, ScheduledToastNotification) 22 | 23 | 24 | def _build_adaptable_data(toast: Toast) -> NotificationData: 25 | """ 26 | Build the adaptable content from a toast 27 | 28 | :param toast: Toast that has adaptable content 29 | :type toast: Toast 30 | :return: A NotificationData object ready to be used in ToastNotifier.Update 31 | :rtype: NotificationData 32 | """ 33 | notificationData = NotificationData() 34 | 35 | toast.updates += 1 36 | notificationData.sequence_number = toast.updates 37 | 38 | for i, fieldContent in enumerate(toast.text_fields): 39 | if fieldContent is not None: 40 | notificationData.values[f"text{i + 1}"] = fieldContent 41 | 42 | progressBar = toast.progress_bar 43 | if progressBar is not None: 44 | notificationData.values["status"] = progressBar.status 45 | notificationData.values.insert( 46 | "progress", "indeterminate" if progressBar.progress is None else str(progressBar.progress) 47 | ) 48 | progressOverride = progressBar.progress_override 49 | if progressOverride is None and progressBar.progress is not None: 50 | # Recreate default Windows behaviour while still allowing it to be changed in the future 51 | progressOverride = f"{round(progressBar.progress * 100)}%" 52 | 53 | notificationData.values["progress_override"] = progressOverride 54 | notificationData.values["caption"] = progressBar.caption or "" 55 | 56 | return notificationData 57 | 58 | 59 | def _build_toast_notification(toast: Toast, toastNotification: ToastNotificationT) -> ToastNotificationT: 60 | """ 61 | Builds a ToastNotification appropriately 62 | :param toast: The toast with the bindings 63 | :type toastNotification: ToastNotificationGeneric 64 | :rtype: ToastNotificationT 65 | """ 66 | toastNotification.tag = toast.tag 67 | # Group, a non-empty string, is required for some functionality. If one isn't provided, use the tag 68 | toastNotification.group = toast.group or toast.tag 69 | 70 | if toast.expiration_time is not None: 71 | toastNotification.expiration_time = toast.expiration_time 72 | 73 | toastNotification.suppress_popup = toast.suppress_popup 74 | 75 | return toastNotification 76 | 77 | 78 | class BaseWindowsToaster: 79 | """ 80 | Wrapper to simplify WinRT's ToastNotificationManager 81 | 82 | :param applicationText: Text to display the application as 83 | """ 84 | 85 | applicationText: str 86 | notifierAUMID: Optional[str] 87 | toastNotifier: ToastNotifier 88 | 89 | def __init__(self, applicationText: str): 90 | self.applicationText = applicationText 91 | 92 | @property 93 | def _AUMID(self) -> str: 94 | return self.notifierAUMID or self.applicationText 95 | 96 | def _setup_toast(self, toast: Toast, dynamic: bool) -> ToastDocument: 97 | """ 98 | Setup toast to send. Should generally be used internally 99 | 100 | :return: XML built from the toast 101 | """ 102 | # Should this be done in ToastDocument? 103 | toastContent = ToastDocument(toast) 104 | for image in toast.images: 105 | toastContent.AddImage(image) 106 | 107 | if toast.duration != ToastDuration.Default: 108 | toastContent.SetDuration(toast.duration) 109 | 110 | if toast.timestamp is not None: 111 | toastContent.SetCustomTimestamp(toast.timestamp) 112 | 113 | if toast.audio is not None: 114 | toastContent.SetAudioAttributes(toast.audio) 115 | 116 | if toast.attribution_text is not None: 117 | toastContent.SetAttributionText(toast.attribution_text) 118 | 119 | if toast.scenario != ToastScenario.Default: 120 | toastContent.SetScenario(toast.scenario) 121 | 122 | if toast.launch_action is not None: 123 | toastContent.SetAttribute(toastContent.GetElementByTagName("toast"), "launch", toast.launch_action) 124 | toastContent.SetAttribute(toastContent.GetElementByTagName("toast"), "activationType", "protocol") 125 | else: 126 | toastContent.SetAttribute(toastContent.GetElementByTagName("toast"), "launch", toast.tag) 127 | 128 | return toastContent 129 | 130 | def show_toast(self, toast: Toast) -> None: 131 | """ 132 | Displays the specified toast notification. 133 | If `toast` has already been shown, it will pop up again, but make no new sections in the action center 134 | 135 | :param toast: Toast to display 136 | """ 137 | toastNotification = ToastNotification(self._setup_toast(toast, True).xmlDocument) 138 | toastNotification.data = _build_adaptable_data(toast) 139 | 140 | if toast.on_activated is not None: # pragma: no cover 141 | # For some reason on_activated's type is generic, so cast it 142 | toastNotification.add_activated( 143 | lambda _, eventArgs: toast.on_activated(ToastActivatedEventArgs.fromWinRt(eventArgs)) 144 | ) 145 | 146 | if toast.on_dismissed is not None: # pragma: no cover 147 | toastNotification.add_dismissed(lambda _, eventArgs: toast.on_dismissed(eventArgs)) 148 | 149 | if toast.on_failed is not None: # pragma: no cover 150 | toastNotification.add_failed(lambda _, eventArgs: toast.on_failed(eventArgs)) 151 | 152 | notificationToSend = _build_toast_notification(toast, toastNotification) 153 | 154 | self.toastNotifier.show(notificationToSend) 155 | 156 | def update_toast(self, toast: Toast) -> bool: 157 | """ 158 | Update the passed notification data with the new data in the clas 159 | 160 | :param toast: Toast to update 161 | :type toast: Toast 162 | :return: Whether the update succeeded 163 | """ 164 | newData = _build_adaptable_data(toast) 165 | updateResult = self.toastNotifier.update_with_tag_and_group(newData, toast.tag, toast.group or toast.tag) 166 | return updateResult == NotificationUpdateResult.SUCCEEDED 167 | 168 | def schedule_toast(self, toast: Toast, displayTime: datetime) -> None: 169 | """ 170 | Schedule the passed notification toast. Warning: scheduled toasts cannot be updated or activated (i.e. on_X) 171 | 172 | :param toast: Toast to display 173 | :type toast: Toast 174 | :param displayTime: Time to display the toast on 175 | :type displayTime: datetime 176 | """ 177 | toastNotification = ScheduledToastNotification(self._setup_toast(toast, False).xmlDocument, displayTime) 178 | scheduledNotificationToSend = _build_toast_notification(toast, toastNotification) 179 | 180 | self.toastNotifier.add_to_schedule(scheduledNotificationToSend) 181 | 182 | def unschedule_toast(self, toast: Toast) -> None: 183 | """ 184 | Unschedule the passed notification toast 185 | 186 | :raises: ToastNotFoundError: If the toast could not be found 187 | """ 188 | scheduledToasts = self.toastNotifier.get_scheduled_toast_notifications() 189 | targetNotification = next( 190 | (scheduledToast for scheduledToast in scheduledToasts if scheduledToast.tag == toast.tag), None 191 | ) 192 | if targetNotification is None: 193 | raise ToastNotFoundError(f"Toast unscheduling failed. Toast {toast} not found") 194 | 195 | self.toastNotifier.remove_from_schedule(targetNotification) 196 | 197 | def clear_toasts(self) -> None: 198 | """ 199 | Clear toasts popped by this toaster 200 | """ 201 | toastHistory: ToastNotificationHistory = ToastNotificationManager.history 202 | toastHistory.clear_with_id(self._AUMID) 203 | 204 | def clear_scheduled_toasts(self) -> None: 205 | """ 206 | Clear all scheduled toasts set for the toaster 207 | """ 208 | scheduledToasts = self.toastNotifier.get_scheduled_toast_notifications() 209 | for toast in scheduledToasts: 210 | self.toastNotifier.remove_from_schedule(toast) 211 | 212 | def remove_toast(self, toast: Toast) -> None: 213 | """ 214 | Removes an individual popped toast 215 | """ 216 | # Is fetching toastHistory expensive? Should this be stored in an instance variable? 217 | toastHistory: ToastNotificationHistory = ToastNotificationManager.history 218 | toastHistory.remove_grouped_tag_with_id(toast.tag, toast.group or toast.tag, self._AUMID) 219 | 220 | def remove_toast_group(self, toastGroup: str) -> None: 221 | """ 222 | Removes a group of toast notifications, identified by the specified group ID 223 | """ 224 | toastHistory: ToastNotificationHistory = ToastNotificationManager.history 225 | toastHistory.remove_group_with_id(toastGroup, self._AUMID) 226 | 227 | 228 | class WindowsToaster(BaseWindowsToaster): 229 | """ 230 | Basic toaster, used to display toasts without actions and/or inputs. 231 | If you need to use them, see :class:`InteractableWindowsToaster` 232 | 233 | :param applicationText: Text to display the application as 234 | """ 235 | 236 | __InteractableWarningMessage = ( 237 | "{0} are not supported in WindowsToaster. If you'd like to use {0}, " 238 | "instantiate a InteractableWindowsToaster class instead" 239 | ) 240 | 241 | def __init__(self, applicationText: str): 242 | super().__init__(applicationText) 243 | self.notifierAUMID = None 244 | # .create_toast_notifier() fails with "Element not found" 245 | self.toastNotifier = ToastNotificationManager.create_toast_notifier_with_id(applicationText) 246 | 247 | def show_toast(self, toast: Toast) -> None: # pragma: no cover 248 | if len(toast.inputs) > 0: 249 | warnings.warn(self.__InteractableWarningMessage.format("input fields")) 250 | 251 | if len(toast.actions) > 0: 252 | warnings.warn(self.__InteractableWarningMessage.format("actions")) 253 | 254 | if len(toast.images) > 2: 255 | warnings.warn(self.__InteractableWarningMessage.format("more than two images")) 256 | 257 | if toast.progress_bar is not None: 258 | warnings.warn(self.__InteractableWarningMessage.format("progress bars")) 259 | 260 | if any(toast_image.position == ToastImagePosition.Hero for toast_image in toast.images): 261 | warnings.warn(self.__InteractableWarningMessage.format("hero placements")) 262 | 263 | super().show_toast(toast) 264 | 265 | def _setup_toast(self, toast, dynamic) -> ToastDocument: 266 | toastContent = super()._setup_toast(toast, dynamic) 267 | 268 | for i, fieldContent in enumerate(toast.text_fields): 269 | if fieldContent is None: 270 | fieldContent = "" 271 | 272 | if dynamic: 273 | toastContent.SetTextField(i) 274 | else: 275 | toastContent.SetTextFieldStatic(i, fieldContent) 276 | 277 | toastContent.SetAttribute(toastContent.bindingNode, "template", "ToastImageAndText04") 278 | 279 | return toastContent 280 | 281 | 282 | class InteractableWindowsToaster(BaseWindowsToaster): 283 | """ 284 | :class:`WindowsToaster`, but uses a AUMID to support actions. Actions require a recognised AUMID to trigger 285 | on_activated, otherwise it triggers on_dismissed with no arguments 286 | 287 | :param applicationText: Text to display the application as 288 | :param notifierAUMID: AUMID to use. Defaults to Command Prompt. To use a custom AUMID, see one of the scripts 289 | """ 290 | 291 | def __init__(self, applicationText: str, notifierAUMID: Optional[str] = None): 292 | super().__init__(applicationText) 293 | if notifierAUMID is None: 294 | self.defaultAUMID = True 295 | self.notifierAUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\cmd.exe" 296 | else: 297 | self.defaultAUMID = False 298 | self.notifierAUMID = notifierAUMID 299 | 300 | self.toastNotifier = ToastNotificationManager.create_toast_notifier_with_id(self.notifierAUMID) 301 | 302 | def _setup_toast(self, toast, dynamic): 303 | toastContent = super()._setup_toast(toast, dynamic) 304 | 305 | for i, fieldContent in enumerate(toast.text_fields): 306 | if fieldContent is None: 307 | continue 308 | 309 | if dynamic: 310 | toastContent.SetTextField(i) 311 | else: 312 | toastContent.SetTextFieldStatic(i, fieldContent) 313 | 314 | toastContent.SetAttribute(toastContent.bindingNode, "template", "ToastGeneric") 315 | toastNode = toastContent.GetElementByTagName("toast") 316 | toastContent.SetAttribute(toastNode, "useButtonStyle", "true") 317 | 318 | # If we haven't set up our own AUMID, put our application text in the attribution field 319 | if self.defaultAUMID and toast.attribution_text is None: 320 | toastContent.SetAttributionText(self.applicationText) 321 | 322 | for toastInput in toast.inputs: 323 | toastContent.AddInput(toastInput) 324 | 325 | for customAction in toast.actions: 326 | toastContent.AddAction(customAction) 327 | 328 | if toast.progress_bar is not None: 329 | if dynamic: 330 | toastContent.AddProgressBar() 331 | else: 332 | toastContent.AddStaticProgressBar(toast.progress_bar) 333 | 334 | return toastContent 335 | -------------------------------------------------------------------------------- /src/windows_toasts/wrappers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import urllib 5 | from dataclasses import dataclass 6 | from enum import Enum 7 | from os import PathLike 8 | from pathlib import Path 9 | from typing import Optional, Sequence, Union 10 | from urllib.parse import urlparse 11 | 12 | from .exceptions import InvalidImageException 13 | 14 | 15 | class ToastButtonColour(Enum): 16 | """ 17 | Possible colours for toast buttons 18 | """ 19 | 20 | Default = "" 21 | Green = "Success" 22 | Red = "Critical" 23 | 24 | 25 | class ToastDuration(Enum): 26 | """ 27 | Possible values for duration to display toast for 28 | """ 29 | 30 | Default = "Default" 31 | Short = "short" 32 | Long = "long" 33 | 34 | 35 | class ToastImagePosition(Enum): 36 | """ 37 | Allowed positions for an image to be placed on a toast notification 38 | """ 39 | 40 | Inline = "" 41 | """Inline, after any text elements, filling the full width of the visual area""" 42 | Hero = "hero" 43 | """Displayed prominently within the toast banner and while inside Notification Center""" 44 | AppLogo = "appLogoOverride" 45 | """Displayed in a square on the left side of the visual area""" 46 | 47 | 48 | class ToastScenario(Enum): 49 | """ 50 | Possible scenarios for the toast 51 | """ 52 | 53 | Default = "" 54 | """The default; nothing special""" 55 | Alarm = "alarm" 56 | """Causes the toast to stay on-screen and expanded until the user takes action as well as a default looping sound""" 57 | Reminder = "reminder" 58 | """The toast will stay on-screen and expanded until the user takes action""" 59 | IncomingCall = "incomingCall" 60 | """ 61 | The Toast will stay on-screen and expanded until the user takes action (on Mobile this expands to full screen). \ 62 | Also causes a looping incoming call sound to be selected by default. 63 | """ 64 | Important = "urgent" 65 | """ 66 | Important notifications allow users to have more control over what 1st party and 3rd party apps can send them \ 67 | high-priority app notifications that can break through Focus Assist (Do not Disturb). \ 68 | This can be modified in the notifications settings. 69 | """ 70 | 71 | 72 | class ToastSystemButtonAction(Enum): 73 | Snooze = 0 74 | """Snooze for a time interval and then pop up again""" 75 | Dismiss = 1 76 | """Dismiss immediately, without going to the action center""" 77 | 78 | 79 | @dataclass(init=False) 80 | class ToastImage: 81 | """ 82 | Image that can be displayed in various toast elements 83 | """ 84 | 85 | path: str 86 | """The URI of the image source""" 87 | 88 | def __init__(self, imagePath: Union[str, PathLike]): 89 | """ 90 | Initialise an :class:`ToastImage` class to use in certain classes. 91 | Online images are supported only in packaged apps that have the internet capability in their manifest. 92 | Unpackaged apps don't support http images; you must download the image to your local app data, 93 | and reference it locally. 94 | 95 | :param imagePath: The path to an image file 96 | :type imagePath: Union[str, PathLike] 97 | :raises: InvalidImageException: If the path to an online image is supplied 98 | """ 99 | if isinstance(imagePath, str) and urlparse(imagePath).scheme in ("http", "https"): 100 | raise InvalidImageException("Online images are not supported") 101 | elif not isinstance(imagePath, Path): 102 | imagePath = Path(imagePath) 103 | 104 | if not imagePath.exists(): 105 | raise InvalidImageException(f"Image with path '{imagePath}' could not be found") 106 | 107 | self.path = urllib.parse.unquote(imagePath.absolute().as_uri()) 108 | 109 | 110 | @dataclass 111 | class ToastDisplayImage: 112 | """ 113 | Define an image that will be displayed as the icon of the toast 114 | """ 115 | 116 | image: ToastImage 117 | """An image file""" 118 | altText: Optional[str] = None 119 | """A description of the image, for users of assistive technologies""" 120 | position: ToastImagePosition = ToastImagePosition.Inline 121 | """ 122 | Whether to set the image as 'hero' and at the top, or as the 'logo'. 123 | Only works on :class:`~windows_toasts.toasters.InteractableWindowsToaster` 124 | """ 125 | circleCrop: bool = False 126 | """ 127 | If the image is not positioned as 'hero', whether to crop the image as a circle, or leave it as is 128 | """ 129 | 130 | @classmethod 131 | def fromPath( 132 | cls, 133 | imagePath: Union[str, PathLike], 134 | altText: Optional[str] = None, 135 | position: ToastImagePosition = ToastImagePosition.Inline, 136 | circleCrop: bool = False, 137 | ) -> ToastDisplayImage: 138 | """ 139 | Create a :class:`ToastDisplayImage` object from path without having to create :class:`ToastImage` 140 | """ 141 | image = ToastImage(imagePath) 142 | return cls(image, altText, position, circleCrop) 143 | 144 | 145 | @dataclass 146 | class ToastProgressBar: 147 | """ 148 | Progress bar to be included in a toast 149 | """ 150 | 151 | status: str 152 | """ 153 | Status string, which is displayed underneath the progress bar on the left. \ 154 | This string should reflect the status of the operation, like "Downloading..." or "Installing..." 155 | """ 156 | caption: Optional[str] = None 157 | """An optional title string""" 158 | progress: Optional[float] = 0 159 | """ 160 | The percentage value of the progress bar, {0..1}. Defaults to zero. 161 | If set to None, it will use an indeterminate bar 162 | """ 163 | progress_override: Optional[str] = None 164 | """Optional string to be displayed instead of the default percentage string""" 165 | 166 | 167 | @dataclass 168 | class _ToastInput(abc.ABC): 169 | """ 170 | Base input dataclass to be used in toasts 171 | """ 172 | 173 | input_id: str 174 | """Identifier to use for the input""" 175 | caption: str = "" 176 | """Optional caption to display near the input""" 177 | 178 | 179 | @dataclass(init=False) 180 | class ToastInputTextBox(_ToastInput): 181 | """ 182 | A text box that can be added in toasts for the user to enter their input 183 | """ 184 | 185 | placeholder: str = "" 186 | """Optional placeholder for a text input box""" 187 | 188 | def __init__(self, input_id: str, caption: str = "", placeholder: str = ""): 189 | super().__init__(input_id, caption) 190 | self.placeholder = placeholder 191 | 192 | 193 | @dataclass 194 | class ToastSelection: 195 | """ 196 | An item that the user can select from the drop down list 197 | """ 198 | 199 | selection_id: str 200 | """Identifier for the selection""" 201 | content: str 202 | """Value for the selection to display""" 203 | 204 | 205 | @dataclass(init=False) 206 | class ToastInputSelectionBox(_ToastInput): 207 | """ 208 | A selection box control, which lets users pick from a dropdown list of options 209 | """ 210 | 211 | selections: Sequence[ToastSelection] = () 212 | """Sequence of selections to include in the box""" 213 | default_selection: Optional[ToastSelection] = None 214 | """Selection to default to. If None, the default selection will be empty""" 215 | 216 | def __init__( 217 | self, 218 | input_id: str, 219 | caption: str = "", 220 | selections: Sequence[ToastSelection] = (), 221 | default_selection: Optional[ToastSelection] = None, 222 | ): 223 | super().__init__(input_id, caption) 224 | self.selections = selections 225 | self.default_selection = default_selection 226 | 227 | 228 | # I attempted to make ToastButton and ToastSystemButton inherit from the same class due to the number of 229 | # shared attributes, but encountered issues with default arguments 230 | @dataclass 231 | class ToastButton: 232 | """ 233 | A button that the user can click on a toast notification 234 | """ 235 | 236 | content: str = "" 237 | """The content displayed on the button""" 238 | arguments: str = "" 239 | """String of arguments that the app will later receive if the user clicks this button""" 240 | image: Optional[ToastImage] = None 241 | """An image to be used as an icon for the button""" 242 | relatedInput: Optional[Union[ToastInputTextBox, ToastInputSelectionBox]] = None 243 | """An input to position the button besides""" 244 | inContextMenu: bool = False 245 | """Whether to place the button in the context menu rather than the actual toast""" 246 | tooltip: Optional[str] = None 247 | """The tooltip for a button, if the button has an empty content string""" 248 | launch: Optional[str] = None 249 | """An optional protocol to launch when the button is pressed""" 250 | colour: ToastButtonColour = ToastButtonColour.Default 251 | """:class:`ToastButtonColour` for the button""" 252 | 253 | 254 | @dataclass 255 | class ToastSystemButton: 256 | """ 257 | Button used to perform a system action, snooze or dismiss 258 | """ 259 | 260 | action: ToastSystemButtonAction 261 | """Action the system button should perform""" 262 | content: str = "" 263 | """A custom content string. If you don't provide one, Windows will automatically use a localized string""" 264 | relatedInput: Optional[ToastInputSelectionBox] = None 265 | """If you want the user to select a snooze interval, set this to a ToastInputSelectionBox with the minutes as IDs""" 266 | image: Optional[ToastImage] = None 267 | """An image to be used as an icon for the button""" 268 | tooltip: Optional[str] = None 269 | """The tooltip for the button""" 270 | colour: ToastButtonColour = ToastButtonColour.Default 271 | """:class:`ToastButtonColour` to be displayed on the button""" 272 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatGuy1/Windows-Toasts/8d3be759c13bd5d7b11a86a8fc6ef10350ffc8bc/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Iterator 3 | from unittest.mock import patch 4 | 5 | from pytest import fixture 6 | 7 | 8 | # We used to just allow all the notifications to actually happen, but Windows doesn't like that 9 | @fixture(scope="session", autouse=True) 10 | def real_run_fixture(pytestconfig) -> Iterator[None]: 11 | if pytestconfig.getoption("real_run"): 12 | yield 13 | else: 14 | with ( 15 | patch("winrt.windows.ui.notifications.ToastNotificationManager.create_toast_notifier"), 16 | patch("winrt.windows.ui.notifications.ToastNotifier.show"), 17 | patch("winrt.windows.ui.notifications.ToastNotifier.add_to_schedule"), 18 | patch("winrt.windows.ui.notifications.ToastNotificationHistory.clear"), 19 | patch("time.sleep"), 20 | ): 21 | yield 22 | 23 | 24 | @fixture(scope="session", autouse=True) 25 | def download_example_image(): 26 | # Download an example image and delete it at the end 27 | import urllib.request 28 | from pathlib import Path 29 | 30 | # Save the image to python.png 31 | imageUrl = "https://www.python.org/static/community_logos/python-powered-h-140x182.png" 32 | imagePath = Path.cwd() / "python.png" 33 | urllib.request.urlretrieve(imageUrl, imagePath) 34 | 35 | yield 36 | 37 | imagePath.unlink() 38 | 39 | 40 | @fixture(scope="session", autouse=True) 41 | def download_example_audio(): 42 | # Download an example audio and delete it at the end 43 | import urllib.request 44 | from pathlib import Path 45 | 46 | # Save the audio to audio.mp3 47 | audioUrl = "https://upload.wikimedia.org/wikipedia/commons/transcoded/9/91/Wikimedia_Sonic_Logo_-_4-seconds.wav/Wikimedia_Sonic_Logo_-_4-seconds.wav.mp3" 48 | audioPath = Path.cwd() / "audio.mp3" 49 | urllib.request.urlretrieve(audioUrl, audioPath) 50 | 51 | yield 52 | 53 | audioPath.unlink() 54 | 55 | 56 | @fixture 57 | def example_image_path(): 58 | from pathlib import Path 59 | 60 | return Path.cwd() / "python.png" 61 | 62 | 63 | @fixture 64 | def example_audio_path(): 65 | from pathlib import Path 66 | 67 | return Path.cwd() / "audio.mp3" 68 | 69 | 70 | @fixture(scope="function", autouse=True) 71 | def slow_down_tests(pytestconfig): 72 | yield 73 | if pytestconfig.getoption("real_run"): 74 | time.sleep(10) 75 | 76 | 77 | def pytest_addoption(parser): 78 | parser.addoption( 79 | "--real_run", action="store_true", default=False, help="Whether to actually display the notifications" 80 | ) 81 | -------------------------------------------------------------------------------- /tests/test_aumid.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | from pytest import raises 4 | 5 | 6 | def test_register_hkey_icon(example_image_path): 7 | from scripts.register_hkey_aumid import register_hkey 8 | 9 | appId = "Test.Notification" 10 | appName = "Notification Test" 11 | 12 | with raises(ValueError, match="does not exist"): 13 | register_hkey(appId, appName, example_image_path.with_suffix(".nonexistant")) 14 | with raises(ValueError, match="must be of type .ico"): 15 | register_hkey(appId, appName, example_image_path) 16 | 17 | exampleIcoPath = example_image_path.with_suffix(".ico") 18 | shutil.copy(example_image_path, exampleIcoPath) 19 | pathExists = exampleIcoPath.exists() 20 | register_hkey(appId, appName, exampleIcoPath if pathExists else None) 21 | 22 | import winreg 23 | 24 | winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) 25 | keyPath = f"SOFTWARE\\Classes\\AppUserModelId\\{appId}" 26 | with winreg.OpenKeyEx(winreg.HKEY_CURRENT_USER, keyPath) as masterKey: 27 | displayValue = winreg.QueryValueEx(masterKey, "DisplayName") 28 | assert displayValue[1] == winreg.REG_SZ 29 | assert displayValue[0] == appName 30 | 31 | if pathExists: 32 | iconUri = winreg.QueryValueEx(masterKey, "IconUri") 33 | assert iconUri[1] == winreg.REG_SZ 34 | assert iconUri[0] == str(exampleIcoPath) 35 | else: 36 | try: 37 | winreg.QueryValueEx(masterKey, "IconUri") 38 | assert False 39 | except FileNotFoundError: 40 | pass 41 | 42 | winreg.DeleteKeyEx(winreg.HKEY_CURRENT_USER, keyPath) 43 | try: 44 | winreg.QueryInfoKey(masterKey) 45 | assert False 46 | except (FileNotFoundError, OSError): 47 | pass 48 | 49 | exampleIcoPath.unlink() 50 | 51 | 52 | def test_register_hkey_no_icon(): 53 | from scripts.register_hkey_aumid import register_hkey 54 | 55 | appId = "Test.Notification" 56 | appName = "Notification Test" 57 | 58 | register_hkey(appId, appName, None) 59 | 60 | import winreg 61 | 62 | winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) 63 | keyPath = f"SOFTWARE\\Classes\\AppUserModelId\\{appId}" 64 | with winreg.OpenKeyEx(winreg.HKEY_CURRENT_USER, keyPath) as masterKey: 65 | displayValue = winreg.QueryValueEx(masterKey, "DisplayName") 66 | assert displayValue[1] == winreg.REG_SZ 67 | assert displayValue[0] == appName 68 | 69 | try: 70 | winreg.QueryValueEx(masterKey, "IconUri") 71 | assert False 72 | except FileNotFoundError: 73 | pass 74 | 75 | winreg.DeleteKeyEx(winreg.HKEY_CURRENT_USER, keyPath) 76 | try: 77 | winreg.QueryInfoKey(masterKey) 78 | assert False 79 | except (FileNotFoundError, OSError): 80 | pass 81 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from pytest import raises 4 | 5 | 6 | # noinspection PyUnresolvedReferences 7 | def test_import_current(): 8 | import importlib 9 | 10 | # Successful import 11 | import src.windows_toasts 12 | 13 | # 6.1.7601 = Windows 7 14 | with patch("platform.version", return_value="6.1.7601"): 15 | with raises(src.windows_toasts.UnsupportedOSVersionException): 16 | # Reload 17 | importlib.reload(src.windows_toasts) 18 | -------------------------------------------------------------------------------- /tests/test_toasts.py: -------------------------------------------------------------------------------- 1 | from pytest import raises, warns 2 | 3 | from src.windows_toasts import InteractableWindowsToaster, Toast, WindowsToaster 4 | 5 | 6 | def test_simple_toast(): 7 | simpleToast = Toast() 8 | 9 | simpleToast.text_fields = ["Hello, World!", "Foobar"] 10 | 11 | simpleToast.on_activated = lambda _: print("Toast clicked!") 12 | simpleToast.on_dismissed = lambda dismissedEventArgs: print(f"Toast dismissed! {dismissedEventArgs.reason}") 13 | simpleToast.on_failed = lambda failedEventArgs: print(f"Toast failed. {failedEventArgs.error_code}") 14 | 15 | WindowsToaster("Python").show_toast(simpleToast) 16 | 17 | 18 | def test_multiline_toast(): 19 | multilineToast = Toast(["Hello, World!", None, "Goodbye, World!"]) 20 | 21 | WindowsToaster("Python").show_toast(multilineToast) 22 | InteractableWindowsToaster("Python").show_toast(multilineToast) 23 | 24 | 25 | def test_interactable_toast(example_image_path): 26 | from src.windows_toasts import ( 27 | ToastActivatedEventArgs, 28 | ToastButton, 29 | ToastButtonColour, 30 | ToastImage, 31 | ToastInputTextBox, 32 | ) 33 | 34 | def notificationActivated(activatedEventArgs: ToastActivatedEventArgs): 35 | print(f"Clicked event args: {activatedEventArgs.arguments}") 36 | print(activatedEventArgs.inputs) 37 | 38 | newInput = ToastInputTextBox("input", "Your input:", "Write your placeholder text here!") 39 | firstButton = ToastButton("First", "clicked=first", image=ToastImage(example_image_path), relatedInput=newInput) 40 | newToast = Toast(actions=(firstButton,)) 41 | newToast.text_fields = ["Hello, interactable world!"] 42 | 43 | newToast.AddInput(newInput) 44 | 45 | newToast.AddAction(ToastButton("Second", "clicked=second", colour=ToastButtonColour.Green)) 46 | newToast.AddAction(ToastButton("", "clicked=context", tooltip="Tooltip", inContextMenu=True)) 47 | 48 | newToast.on_activated = notificationActivated 49 | InteractableWindowsToaster("Python").show_toast(newToast) 50 | 51 | 52 | def test_audio_toast(): 53 | from src.windows_toasts import AudioSource, ToastAudio 54 | 55 | toaster = WindowsToaster("Python") 56 | 57 | originalToast = Toast(["Ding ding!"]) 58 | originalToast.audio = ToastAudio(AudioSource.IM) 59 | 60 | toaster.show_toast(originalToast) 61 | 62 | # Branching 63 | silentToast = originalToast.clone() 64 | assert silentToast != "False EQ" and silentToast != originalToast 65 | 66 | silentToast.text_fields = ["Silence..."] 67 | silentToast.audio.silent = True 68 | 69 | toaster.show_toast(silentToast) 70 | 71 | loopingToast = silentToast.clone() 72 | assert loopingToast != silentToast and loopingToast != originalToast 73 | 74 | loopingToast.audio.sound = AudioSource.Call7 75 | loopingToast.audio.looping = True 76 | loopingToast.audio.silent = False 77 | loopingToast.text_fields = ["Ring, ring"] 78 | 79 | toaster.show_toast(loopingToast) 80 | 81 | 82 | def test_custom_audio_toast(example_audio_path, example_image_path): 83 | from src.windows_toasts import ToastAudio 84 | 85 | toaster = WindowsToaster("Python") 86 | 87 | nonExistantFile = example_audio_path.with_stem("nonexistant") 88 | toast = Toast(["This should pop with the default sound"], audio=ToastAudio(nonExistantFile)) 89 | with warns(UserWarning, match="could not be found"): 90 | toaster.show_toast(toast) 91 | 92 | unsupportedFile = example_image_path 93 | toast.audio = ToastAudio(unsupportedFile) 94 | with warns(UserWarning, match=f"has unsupported extension '{example_image_path.suffix}'"): 95 | toaster.show_toast(toast) 96 | 97 | toast.audio = ToastAudio(example_audio_path) 98 | toast.text_fields = ["This should pop with the Wikipedia sound logo"] 99 | toaster.show_toast(toast) 100 | 101 | 102 | def test_errors_toast(example_image_path): 103 | from src.windows_toasts import InvalidImageException, ToastButton, ToastDisplayImage, ToastImage, ToastInputTextBox 104 | 105 | textToast = Toast() 106 | textToast.text_fields = ["Hello, World!"] 107 | 108 | displayImage = ToastDisplayImage.fromPath(example_image_path) 109 | 110 | fakeProtocol = "notanactualprotocol" 111 | with warns(UserWarning, match=f"Ensure your launch action of {fakeProtocol} is of a proper protocol"): 112 | textToast.launch_action = fakeProtocol 113 | 114 | assert textToast.text_fields[0] == "Hello, World!" 115 | 116 | newToast = Toast(["Hello, World!"]) 117 | 118 | with raises(InvalidImageException, match="could not be found"): 119 | _ = ToastImage(example_image_path.with_suffix(".nonexistant")) 120 | 121 | newToast.AddImage(displayImage) 122 | newToast.AddImage(displayImage) 123 | for i in range(1, 6): 124 | if i < 4: 125 | newToast.AddAction(ToastButton(f"Button #{i}", str(i))) 126 | else: 127 | newToast.AddInput(ToastInputTextBox(f"Input #{i}", str(i))) 128 | 129 | with warns(UserWarning, match="Cannot add action 'Button 6', you've already reached"): 130 | newToast.AddAction(ToastButton("Button 6", str(6))) 131 | 132 | with warns(UserWarning, match="Cannot add input 'Input 6', you've already reached"): 133 | newToast.AddInput(ToastInputTextBox("Input 6", str(6))) 134 | 135 | with raises(InvalidImageException, match="Online images are not supported"): 136 | ToastImage("https://www.python.org/static/community_logos/python-powered-h-140x182.png") 137 | 138 | assert len(newToast.actions) + len(newToast.inputs) == 5 139 | for i, toastAction in enumerate(newToast.actions): 140 | if i < 3: 141 | assert newToast.actions[i] == ToastButton(f"Button #{i + 1}", str(i + 1)) 142 | else: 143 | assert newToast.inputs[i] == ToastInputTextBox(f"Input #{i + 1}", str(i + 1)) 144 | 145 | InteractableWindowsToaster("Python").show_toast(newToast) 146 | 147 | 148 | def test_image_toast(example_image_path): 149 | from src.windows_toasts import ToastDisplayImage, ToastImage, ToastImagePosition 150 | 151 | toastImage = ToastImage(example_image_path) 152 | toastDP = ToastDisplayImage(toastImage, altText="Python logo", position=ToastImagePosition.Hero) 153 | newToast = Toast(images=(toastDP,)) 154 | 155 | newToast.text_fields = ["Hello, World!", "Foo", "Bar"] 156 | 157 | newToast.AddImage(ToastDisplayImage.fromPath(str(example_image_path), circleCrop=True)) 158 | 159 | InteractableWindowsToaster("Python").show_toast(newToast) 160 | 161 | 162 | def test_custom_timestamp_toast(): 163 | from datetime import datetime, timedelta, timezone 164 | 165 | newToast = Toast(["This should display as being sent an hour ago"]) 166 | newToast.timestamp = datetime.now(timezone.utc) - timedelta(hours=1) 167 | 168 | WindowsToaster("Python").show_toast(newToast) 169 | 170 | 171 | def test_input_toast(): 172 | import copy 173 | 174 | from src.windows_toasts import ToastInputSelectionBox, ToastInputTextBox, ToastSelection 175 | 176 | toastTextBoxInput = ToastInputTextBox("question", "How are you today?", "Enter here!") 177 | newToast = Toast(inputs=[toastTextBoxInput]) 178 | 179 | toastSelectionBoxInput = ToastInputSelectionBox("selection", "How about some predefined options?") 180 | toastSelections = ( 181 | ToastSelection("happy", "Pretty happy"), 182 | ToastSelection("ok", "Meh"), 183 | ToastSelection("bad", "Bad"), 184 | ) 185 | toastSelectionBoxInput.selections = toastSelections 186 | toastSelectionBoxInput.default_selection = toastSelections[1] 187 | newToast.AddInput(toastSelectionBoxInput) 188 | 189 | newBoxInput = copy.deepcopy(toastSelectionBoxInput) 190 | newBoxInput.default_selection = None 191 | newToast.AddInput(newBoxInput) 192 | 193 | newToast.text_fields = ["You. Yes, you."] 194 | 195 | InteractableWindowsToaster("Python").show_toast(newToast) 196 | 197 | 198 | def test_custom_duration_toast(): 199 | from src.windows_toasts import ToastDuration 200 | 201 | newToast = Toast(["A short toast"], duration=ToastDuration.Short) 202 | WindowsToaster("Python").show_toast(newToast) 203 | 204 | 205 | def test_attribution_text_toast(): 206 | newToast = Toast(["Incoming Message", "How are you?"]) 207 | newToast.attribution_text = "Via FakeMessenger" 208 | 209 | InteractableWindowsToaster("Python").show_toast(newToast) 210 | 211 | 212 | def test_scenario_toast(): 213 | from src.windows_toasts import ToastScenario 214 | 215 | newToast = Toast(["Very important toast!", "Are you ready?", "Here it comes!"]) 216 | newToast.scenario = ToastScenario.Important 217 | 218 | WindowsToaster("Python").show_toast(newToast) 219 | 220 | 221 | def test_update_toast(): 222 | toaster = WindowsToaster("Python") 223 | 224 | newToast = Toast() 225 | newToast.text_fields = ["Hello, World!"] 226 | 227 | toaster.show_toast(newToast) 228 | 229 | import time 230 | 231 | time.sleep(0.5) 232 | newToast.text_fields = ["Goodbye, World!"] 233 | 234 | toaster.update_toast(newToast) 235 | 236 | 237 | def test_progress_bar(): 238 | from src.windows_toasts import ToastProgressBar 239 | 240 | progressBar = ToastProgressBar( 241 | "Preparing...", "Python 4 release", progress=None, progress_override="? millenniums remaining" 242 | ) 243 | newToast = Toast(progress_bar=progressBar) 244 | 245 | toaster = InteractableWindowsToaster("Python", "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\cmd.exe") 246 | toaster.show_toast(newToast) 247 | 248 | # Branching 249 | newToast.progress_bar.progress = 0.75 250 | newToast.progress_bar.progress_override = None 251 | 252 | toaster.show_toast(newToast) 253 | 254 | 255 | def test_scheduled_toast(pytestconfig): 256 | from datetime import datetime, timedelta 257 | 258 | from src.windows_toasts import ToastNotFoundError, ToastProgressBar 259 | 260 | progressBar = ToastProgressBar( 261 | "Preparing...", "Python 4 release", progress=0.5, progress_override="? millenniums remaining" 262 | ) 263 | newToast = Toast(progress_bar=progressBar, text_fields=["Incoming:"]) 264 | 265 | # Branching 266 | clonedToast = newToast.clone() 267 | clonedToast.progress_bar.progress_override = None 268 | clonedToast.progress_bar.caption = None 269 | 270 | toaster = InteractableWindowsToaster("Python") 271 | toaster.schedule_toast(newToast, datetime.now() + timedelta(seconds=5)) 272 | toaster.schedule_toast(clonedToast, datetime.now() + timedelta(seconds=10)) 273 | 274 | if pytestconfig.getoption("real_run"): 275 | toaster.unschedule_toast(clonedToast) 276 | else: 277 | with raises(ToastNotFoundError, match="Toast unscheduling failed."): 278 | toaster.unschedule_toast(clonedToast) 279 | 280 | 281 | def test_clear_toasts(): 282 | toaster = InteractableWindowsToaster("Python") 283 | toaster.clear_scheduled_toasts() 284 | toaster.clear_toasts() 285 | 286 | 287 | def test_expiration_toasts(): 288 | from datetime import datetime, timedelta 289 | 290 | expirationTime = datetime.now() + timedelta(minutes=1) 291 | newToast = Toast(["Hello, World!"], group="Test Toasts", expiration_time=expirationTime) 292 | WindowsToaster("Python").show_toast(newToast) 293 | 294 | 295 | def test_protocol_launch(): 296 | from src.windows_toasts import ToastButton 297 | 298 | newToast = Toast(["Click on me to open google.com"], launch_action="https://google.com") 299 | newToast.AddAction(ToastButton("Launch calculator", launch="calculator://")) 300 | InteractableWindowsToaster("Python").show_toast(newToast) 301 | 302 | 303 | def test_system_toast(): 304 | from src.windows_toasts import ToastInputSelectionBox, ToastSelection, ToastSystemButton, ToastSystemButtonAction 305 | 306 | newToast = Toast(["Reminder", "It's time to stretch!"]) 307 | 308 | selections = (ToastSelection("1", "1 minute"), ToastSelection("2", "2 minutes"), ToastSelection("5", "5 minutes")) 309 | selectionBox = ToastInputSelectionBox( 310 | "snoozeBox", caption="Snooze duration", selections=selections, default_selection=selections[0] 311 | ) 312 | newToast.AddInput(selectionBox) 313 | 314 | snoozeButton = ToastSystemButton(ToastSystemButtonAction.Snooze, "Remind Me Later", relatedInput=selectionBox) 315 | dismissBox = ToastSystemButton(ToastSystemButtonAction.Dismiss) 316 | newToast.AddAction(snoozeButton) 317 | newToast.AddAction(dismissBox) 318 | 319 | InteractableWindowsToaster("Python").show_toast(newToast) 320 | 321 | 322 | def test_remove_toast(): 323 | toaster = WindowsToaster("Python") 324 | newToast = Toast(["Disappearing act"]) 325 | 326 | toaster.show_toast(newToast) 327 | 328 | import time 329 | 330 | time.sleep(0.5) 331 | 332 | toaster.remove_toast(newToast) 333 | 334 | 335 | def test_remove_toast_group(): 336 | import time 337 | 338 | from src.windows_toasts import ToastActivatedEventArgs 339 | 340 | toaster = WindowsToaster("Python") 341 | 342 | toast1 = Toast(["A bigger disappearing act"], group="begone") 343 | toast2 = Toast(["Click me to shazam!"], group="begone") 344 | 345 | toast2.on_activated = lambda _: toaster.remove_toast_group("begone") 346 | 347 | toaster.show_toast(toast1) 348 | toaster.show_toast(toast2) 349 | 350 | # If it hasn't been activated, remove it ourselves 351 | time.sleep(1) 352 | 353 | toast2.on_activated(ToastActivatedEventArgs()) 354 | --------------------------------------------------------------------------------