├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── binder-on-pr.yml │ └── build.yml ├── .gitignore ├── .prettierignore ├── .yarnrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── binder ├── environment.yml └── postBuild ├── docs └── notify-screenshot.png ├── install.json ├── jupyter-config ├── jupyterlab_notify.json ├── nb-config │ └── jupyterlab_notify.json └── server-config │ └── jupyterlab_notify.json ├── jupyterlab_notify ├── __init__.py ├── _version.py └── magics.py ├── notebooks └── Notify.ipynb ├── package.json ├── pyproject.toml ├── src └── index.ts ├── style ├── base.css ├── index.css └── index.js ├── tsconfig.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | yarn.lock binary 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Binder Badge 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | jobs: 7 | binder: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: jupyterlab/maintainer-tools/.github/actions/binder-link@v1 13 | with: 14 | github_token: ${{ secrets.github_token }} 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Base Setup 18 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 19 | 20 | - name: Install dependencies 21 | run: python -m pip install -U "jupyterlab>=4.0.0b0,<5" 22 | 23 | - name: Lint the extension 24 | run: | 25 | set -eux 26 | jlpm 27 | jlpm run lint:check 28 | 29 | - name: Build the extension 30 | run: | 31 | set -eux 32 | python -m pip install .[test] 33 | 34 | jupyter server extension list 35 | jupyter server extension list 2>&1 | grep -ie "jupyterlab_notify.*OK" 36 | 37 | jupyter labextension list 38 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-notify.*OK" 39 | python -m jupyterlab.browser_check 40 | 41 | - name: Package the extension 42 | run: | 43 | set -eux 44 | 45 | pip install build 46 | python -m build 47 | pip uninstall -y "jupyterlab_notify" jupyterlab 48 | 49 | - name: Upload extension packages 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: extension-artifacts 53 | path: dist/jupyterlab_notify* 54 | if-no-files-found: error 55 | 56 | test_isolated: 57 | needs: build 58 | runs-on: ubuntu-latest 59 | 60 | steps: 61 | - name: Checkout 62 | uses: actions/checkout@v4 63 | - name: Install Python 64 | uses: actions/setup-python@v5 65 | with: 66 | python-version: '3.9' 67 | architecture: 'x64' 68 | 69 | - uses: actions/download-artifact@v4 70 | with: 71 | name: extension-artifacts 72 | 73 | - name: Install and Test 74 | run: | 75 | set -eux 76 | # Remove NodeJS, twice to take care of system and locally installed node versions. 77 | sudo rm -rf $(which node) 78 | sudo rm -rf $(which node) 79 | 80 | pip install "jupyterlab>=4.0.0b0,<5" jupyterlab_notify*.whl 81 | 82 | jupyter server extension list 83 | jupyter server extension list 2>&1 | grep -ie "jupyterlab_notify.*OK" 84 | 85 | jupyter labextension list 86 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-notify.*OK" 87 | python -m jupyterlab.browser_check --no-browser-test 88 | 89 | check_links: 90 | name: Check Links 91 | runs-on: ubuntu-latest 92 | timeout-minutes: 15 93 | steps: 94 | - uses: actions/checkout@v4 95 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 96 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyterlab_notify/labextension 11 | # Version file is handled by hatchling 12 | jupyterlab_notify/_version.py 13 | 14 | # Created by https://www.gitignore.io/api/python 15 | # Edit at https://www.gitignore.io/?templates=python 16 | 17 | ### Python ### 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | pip-wheel-metadata/ 41 | share/python-wheels/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage/ 65 | coverage.xml 66 | *.cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # Mr Developer 100 | .mr.developer.cfg 101 | .project 102 | .pydevproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | .dmypy.json 110 | dmypy.json 111 | 112 | # Pyre type checker 113 | .pyre/ 114 | 115 | # End of https://www.gitignore.io/api/python 116 | 117 | # OSX files 118 | .DS_Store 119 | 120 | # Yarn cache 121 | .yarn/ 122 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyterlab_notify 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableImmutableInstalls: false 2 | 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.2](https://github.com/deshaw/jupyterlab-notify/compare/v2.0.2...v2.0.1) (UNRELEASED) 2 | 3 | ## Changed 4 | 5 | - Added options to configure the SMTP client to use 6 | 7 | ## [2.0.1](https://github.com/deshaw/jupyterlab-notify/compare/v2.0.1...v2.0.0) (2024-01-16) 8 | 9 | ## Fixed 10 | 11 | - Fix the pre run cell hook to be compatible with ipython 8.18.x+ 12 | 13 | ## [2.0.0](https://github.com/deshaw/jupyterlab-notify/compare/v2.0.0...v1.0.0) (2023-05-17) 14 | 15 | ## Changed 16 | 17 | - Upgrade to jupyterlab4 18 | 19 | ## [1.0.0](https://github.com/deshaw/jupyterlab-notify/compare/v1.0.0...v1.0.0) (2021-07-12) 20 | 21 | ### Added 22 | 23 | - Public release 24 | 25 | The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and 26 | this CHANGELOG follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) standard. 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions! Before you can contribute, please [sign and submit this Contributor License Agreement (CLA)](https://www.deshaw.com/oss/cla), 4 | declaring that you have both the rights to your contribution and you grant us us the rights to use your contribution. 5 | This CLA is in place to protect all users of this project. You only need to sign this once for all projects using our CLA. 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 D. E. Shaw & Co., L.P. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include pyproject.toml 3 | include jupyter-config/jupyterlab_notify.json 4 | 5 | include package.json 6 | include install.json 7 | include ts*.json 8 | include yarn.lock 9 | 10 | graft jupyterlab_notify/labextension 11 | 12 | # Javascript files 13 | graft src 14 | graft style 15 | prune **/node_modules 16 | prune lib 17 | prune binder 18 | 19 | # Patterns to exclude from any directory 20 | global-exclude *~ 21 | global-exclude *.pyc 22 | global-exclude *.pyo 23 | global-exclude .git 24 | global-exclude .ipynb_checkpoints 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupyterlab-notify 2 | 3 | [![PyPI version][pypi-image]][pypi-url] [![PyPI DM][pypi-dm-image]][pypi-url] 4 | [![Github Actions Status][github-status-image]][github-status-url] [![Binder][binder-image]][binder-url] 5 | 6 | JupyterLab extension to notify cell completion 7 | 8 | ![notify-extension-in-action](https://github.com/deshaw/jupyterlab-notify/blob/main/docs/notify-screenshot.png?raw=true) 9 | 10 | This is inspired by the notebook version [here](https://github.com/ShopRunner/jupyter-notify). 11 | 12 | ## Usage 13 | 14 | ### Register magics 15 | 16 | ```python 17 | %load_ext jupyterlab_notify 18 | ``` 19 | 20 | ### Notify completion of single cell: 21 | 22 | ```python 23 | %%notify 24 | import time 25 | time.sleep(1) 26 | ``` 27 | 28 | ### Mail output upon completion (with optional title for successfull execution) 29 | 30 | ```python 31 | %%notify --mail --success 'Long-running cell in notebook is done!' 32 | time.sleep(1) 33 | ``` 34 | 35 | **Note:** Mail requires/assumes that you have an SMTP server running on "localhost" - refer [SMTP doc](https://docs.python.org/3/library/smtplib.html#smtplib.SMTP.connect) for more details. 36 | In case this assumption does not hold true for you, please open an issue with relevant details. 37 | 38 | ### Failure scenarios 39 | 40 | ```python 41 | %%notify -f 'Long-running cell in notebook failed' 42 | raise ValueError 43 | ``` 44 | 45 | ### Threshold-based notifications (unit in seconds) 46 | 47 | ```python 48 | %notify_all --threshold 1 49 | time.sleep(1) 50 | ``` 51 | 52 | Once enabled, `notify_all` will raise a notification for cells that either exceed the given threshold or raise exception. This ability can also be used to check if/when all cells in a notebook completes execution. For instance, 53 | 54 | ```python 55 | # In first cell 56 | %notify_all -t 86400 -f 'Notebook execution failed' 57 | # ... 58 | # ... 59 | # In last cell 60 | %%notify -s 'Notebook execution completed' 61 | ``` 62 | 63 | ### Disable notifications 64 | 65 | ```python 66 | %notify_all --disable 67 | time.sleep(1) 68 | ``` 69 | 70 | ### Learn more 71 | 72 | ```python 73 | %%notify? 74 | ``` 75 | 76 | ```python 77 | %notify_all? 78 | ``` 79 | 80 | ## Troubleshoot 81 | 82 | If you notice that the desktop notifications are not showing up, check the below: 83 | 84 | 1. Make sure JupyterLab is running in a secure context (i.e. either using HTTPS or localhost) 85 | 2. If you've previously denied notification permissions for the site, update the browser settings accordingly. In Chrome, you can do so by navigating to `Setttings -> Privacy and security -> Site Settings -> Notifications` and updating the permissions against your JupyterLab URL. 86 | 3. Verify that notifications work for your browser. You may need to configure an OS setting first. You can test on [this site](https://web-push-book.gauntface.com/demos/notification-examples/). 87 | 88 | ## Requirements 89 | 90 | - JupyterLab >= 4.0 91 | 92 | ## Install 93 | 94 | To install this package with [`pip`](https://pip.pypa.io/en/stable/) run 95 | 96 | ```bash 97 | pip install jupyterlab_notify 98 | ``` 99 | 100 | ## Contributing 101 | 102 | ### Development install 103 | 104 | Note: You will need NodeJS to build the extension package. 105 | 106 | The `jlpm` command is JupyterLab's pinned version of 107 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 108 | `yarn` or `npm` in lieu of `jlpm` below. 109 | 110 | ```bash 111 | # Clone the repo to your local environment 112 | # Change directory to the jupyterlab_notify directory 113 | # Install package in development mode 114 | pip install -e . 115 | # Link your development version of the extension with JupyterLab 116 | jupyter-labextension develop . --overwrite 117 | # Rebuild extension Typescript source after making changes 118 | jlpm run build 119 | ``` 120 | 121 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 122 | 123 | ```bash 124 | # Watch the source directory in one terminal, automatically rebuilding when needed 125 | jlpm run watch 126 | # Run JupyterLab in another terminal 127 | jupyter lab 128 | ``` 129 | 130 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 131 | 132 | By default, the `jlpm run build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 133 | 134 | ```bash 135 | jupyter lab build --minimize=False 136 | ``` 137 | 138 | ### Uninstall 139 | 140 | ```bash 141 | pip uninstall jupyterlab_notify 142 | ``` 143 | 144 | ## Publishing 145 | 146 | Before starting, you'll need to have run: `pip install twine jupyter_packaging` 147 | 148 | 1. Update the version in `package.json` and update the release date in `CHANGELOG.md` 149 | 2. Commit the change in step 1, tag it, then push it 150 | 151 | ``` 152 | git commit -am 153 | git tag vX.Z.Y 154 | git push && git push --tags 155 | ``` 156 | 157 | 3. Create the artifacts 158 | 159 | ``` 160 | rm -rf dist 161 | python setup.py sdist bdist_wheel 162 | ``` 163 | 164 | 4. Test this against the test pypi. You can then install from here to test as well: 165 | 166 | ``` 167 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 168 | # In a new venv 169 | pip install --index-url https://test.pypi.org/simple/ jupyterlab_notify 170 | ``` 171 | 172 | 5. Upload this to pypi: 173 | 174 | ``` 175 | twine upload dist/* 176 | ``` 177 | 178 | ### Uninstall 179 | 180 | ```bash 181 | pip uninstall jupyterlab_notify 182 | ``` 183 | 184 | ## History 185 | 186 | This plugin was contributed back to the community by the [D. E. Shaw group](https://www.deshaw.com/). 187 | 188 |

189 | 190 | D. E. Shaw Logo 191 | 192 |

193 | 194 | ## License 195 | 196 | This project is released under a [BSD-3-Clause license](https://github.com/deshaw/jupyterlab-notify/blob/master/LICENSE.txt). 197 | 198 | We love contributions! Before you can contribute, please sign and submit this [Contributor License Agreement (CLA)](https://www.deshaw.com/oss/cla). 199 | This CLA is in place to protect all users of this project. 200 | 201 | "Jupyter" is a trademark of the NumFOCUS foundation, of which Project Jupyter is a part. 202 | 203 | [pypi-url]: https://pypi.org/project/jupyterlab-notify 204 | [pypi-image]: https://img.shields.io/pypi/v/jupyterlab-notify 205 | [pypi-dm-image]: https://img.shields.io/pypi/dm/jupyterlab-notify 206 | [github-status-image]: https://github.com/deshaw/jupyterlab-notify/workflows/Build/badge.svg 207 | [github-status-url]: https://github.com/deshaw/jupyterlab-notify/actions?query=workflow%3ABuild 208 | [binder-image]: https://mybinder.org/badge_logo.svg 209 | [binder-url]: https://mybinder.org/v2/gh/deshaw/jupyterlab-notify.git/main?urlpath=lab%2Ftree%2Fnotebooks%2Findex.ipynb 210 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | # a mybinder.org-ready environment for demoing jupyterlab_notify 2 | # this environment may also be used locally on Linux/MacOS/Windows, e.g. 3 | # 4 | # conda env update --file binder/environment.yml 5 | # conda activate jupyterlab-notify-demo 6 | # 7 | name: jupyterlab-notify-demo 8 | 9 | channels: 10 | - conda-forge 11 | 12 | dependencies: 13 | # runtime dependencies 14 | - python >=3.10,<3.11.0a0 15 | - jupyterlab >=4.0.0b0,<5 16 | # labextension build dependencies 17 | - nodejs >=18,<19 18 | - pip 19 | - wheel 20 | # additional packages for demos 21 | # - ipywidgets 22 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of jupyterlab_notify 3 | 4 | On Binder, this will run _after_ the environment has been fully created from 5 | the environment.yml in this directory. 6 | 7 | This script should also run locally on Linux/MacOS/Windows: 8 | 9 | python3 binder/postBuild 10 | """ 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | 15 | 16 | ROOT = Path.cwd() 17 | 18 | def _(*args, **kwargs): 19 | """ Run a command, echoing the args 20 | 21 | fails hard if something goes wrong 22 | """ 23 | print("\n\t", " ".join(args), "\n") 24 | return_code = subprocess.call(args, **kwargs) 25 | if return_code != 0: 26 | print("\nERROR", return_code, " ".join(args)) 27 | sys.exit(return_code) 28 | 29 | # verify the environment is self-consistent before even starting 30 | _(sys.executable, "-m", "pip", "check") 31 | 32 | # install the labextension 33 | _(sys.executable, "-m", "pip", "install", "-e", ".") 34 | _(sys.executable, "-m", "jupyter", "labextension", "develop", "--overwrite", ".") 35 | _( 36 | sys.executable, 37 | "-m", 38 | "jupyter", 39 | "server", 40 | "extension", 41 | "enable", 42 | "jupyterlab_notify", 43 | ) 44 | 45 | # verify the environment the extension didn't break anything 46 | _(sys.executable, "-m", "pip", "check") 47 | 48 | # list the extensions 49 | _("jupyter", "server", "extension", "list") 50 | 51 | # initially list installed extensions to determine if there are any surprises 52 | _("jupyter", "labextension", "list") 53 | 54 | 55 | print("JupyterLab with jupyterlab_notify is ready to run with:\n") 56 | print("\tjupyter lab\n") 57 | -------------------------------------------------------------------------------- /docs/notify-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deshaw/jupyterlab-notify/dd0c1d3297c589738e7f00bc43b00efe8b3b7290/docs/notify-screenshot.png -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyterlab_notify", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab_notify" 5 | } 6 | -------------------------------------------------------------------------------- /jupyter-config/jupyterlab_notify.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyterlab_notify": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/nb-config/jupyterlab_notify.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyterlab_notify": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/server-config/jupyterlab_notify.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyterlab_notify": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyterlab_notify/__init__.py: -------------------------------------------------------------------------------- 1 | from .magics import NotifyCellCompletionMagics 2 | from ._version import __version__ 3 | 4 | 5 | def _jupyter_labextension_paths(): 6 | return [{ 7 | "src": "labextension", 8 | "dest": "jupyterlab-notify" 9 | }] 10 | 11 | 12 | def _jupyter_server_extension_points(): 13 | return [{ 14 | "module": "jupyterlab_notify" 15 | }] 16 | 17 | 18 | def _load_jupyter_server_extension(server_app): 19 | pass 20 | 21 | 22 | def load_ipython_extension(ipython): 23 | ipython.register_magics(NotifyCellCompletionMagics) 24 | -------------------------------------------------------------------------------- /jupyterlab_notify/_version.py: -------------------------------------------------------------------------------- 1 | # This file is auto-generated by Hatchling. As such, do not: 2 | # - modify 3 | # - track in version control e.g. be sure to add to .gitignore 4 | __version__ = VERSION = '2.0.0' 5 | -------------------------------------------------------------------------------- /jupyterlab_notify/magics.py: -------------------------------------------------------------------------------- 1 | from email.message import EmailMessage 2 | from enum import Enum 3 | from getpass import getuser 4 | from importlib import import_module 5 | import inspect 6 | import time 7 | from traitlets import Any, Unicode 8 | import uuid 9 | 10 | from IPython import get_ipython 11 | from IPython.core.magic import (Magics, cell_magic, line_magic, 12 | magics_class) 13 | from IPython.core.magic_arguments \ 14 | import (argument, magic_arguments, 15 | parse_argstring) 16 | from IPython.display import display 17 | 18 | 19 | _DEFAULT_SUCCESS_MESSAGE = "Cell execution completed successfully" 20 | _DEFAULT_FAILURE_MESSAGE = "Cell execution failed" 21 | 22 | 23 | class _NotificationType(Enum): 24 | """ 25 | Supported notification types in jupyterlab extension 26 | """ 27 | 28 | INIT = "INIT" 29 | NOTIFY = "NOTIFY" 30 | 31 | 32 | class _Notification(object): 33 | 34 | NOTIFICATION_MIMETYPE = "application/desktop-notify+json" 35 | 36 | @property 37 | def message_type(self): 38 | return self._message_type 39 | 40 | @message_type.setter 41 | def message_type(self, msg_type): 42 | self._message_type = _NotificationType(msg_type) 43 | 44 | def __init__(self, message_type, title=None): 45 | """ 46 | Used to send notifications to the client using custom mimetypes 47 | """ 48 | self.message_type = message_type 49 | self.title = title 50 | 51 | def _repr_mimebundle_(self, **kwargs): 52 | return { 53 | self.NOTIFICATION_MIMETYPE: { 54 | "type": self.message_type.value, 55 | "payload": {"title": self.title}, 56 | "id": str(uuid.uuid4()), 57 | } 58 | } 59 | 60 | 61 | class SMTPConfigurationError(Exception): 62 | pass 63 | 64 | 65 | @magics_class 66 | class NotifyCellCompletionMagics(Magics): 67 | smtp_class: str = Unicode( 68 | "smtplib.SMTP", 69 | config=True, 70 | help="Fully qualified class name for the SMTP class to use", 71 | ) 72 | smtp_args: str = Any( 73 | "localhost", 74 | config=True, 75 | help="Arguments to pass to the SMTP class constructor, as a string", 76 | ) 77 | 78 | def __init__(self, shell): 79 | super(NotifyCellCompletionMagics, self).__init__(shell) 80 | self.smtp_instance = None 81 | self._setup_smtp_instance() 82 | display(_Notification(_NotificationType.INIT)) 83 | 84 | def _setup_smtp_instance(self): 85 | try: 86 | smtp_class = self._import_smtp_class() 87 | self._validate_smtp_class(smtp_class) 88 | self.smtp_instance = self._create_smtp_instance(smtp_class) 89 | self._validate_smtp_instance(self.smtp_instance) 90 | except SMTPConfigurationError as e: 91 | print(f"SMTP Configuration Error: {str(e)}") 92 | 93 | def _import_smtp_class(self): 94 | try: 95 | module_name, class_name = self.smtp_class.rsplit(".", 1) 96 | except ValueError: 97 | raise SMTPConfigurationError( 98 | f"Invalid smtp_class format: {self.smtp_class}. " 99 | "It should be in the format 'module.ClassName'." 100 | ) 101 | 102 | try: 103 | module = import_module(module_name) 104 | except ImportError: 105 | raise SMTPConfigurationError(f"Could not import module: {module_name}") 106 | 107 | try: 108 | return getattr(module, class_name) 109 | except AttributeError: 110 | raise SMTPConfigurationError( 111 | f"Class {class_name} not found in module {module_name}" 112 | ) 113 | 114 | def _validate_smtp_class(self, smtp_class): 115 | if not inspect.isclass(smtp_class): 116 | raise SMTPConfigurationError(f"{smtp_class.__name__} is not a class") 117 | 118 | if not hasattr(smtp_class, "send_message") or not callable( 119 | getattr(smtp_class, "send_message") 120 | ): 121 | raise SMTPConfigurationError( 122 | f"{smtp_class.__name__} does not have a callable 'send_message' method" 123 | ) 124 | 125 | def _create_smtp_instance(self, smtp_class): 126 | args = self._process_smtp_args() 127 | 128 | try: 129 | if isinstance(args, dict): 130 | return smtp_class(**args) 131 | elif isinstance(args, (list, tuple)): 132 | return smtp_class(*args) 133 | else: 134 | return smtp_class() 135 | except Exception as e: 136 | raise SMTPConfigurationError( 137 | f"Failed to instantiate {smtp_class.__name__}: {str(e)}" 138 | ) 139 | 140 | def _validate_smtp_instance(self, smtp_instance): 141 | if not hasattr(smtp_instance, "connect") or not callable( 142 | getattr(smtp_instance, "connect") 143 | ): 144 | raise SMTPConfigurationError( 145 | f"{type(smtp_instance).__name__} instance does not have a callable 'connect' method" 146 | ) 147 | 148 | def _process_smtp_args(self): 149 | if self.smtp_args is None: 150 | return [] 151 | 152 | if isinstance(self.smtp_args, str): 153 | try: 154 | import ast 155 | 156 | return ast.literal_eval(self.smtp_args) 157 | except: 158 | return self.smtp_args 159 | elif callable(self.smtp_args): 160 | return self.smtp_args() 161 | else: 162 | return self.smtp_args 163 | 164 | @magic_arguments() 165 | @argument( 166 | "--success", 167 | "-s", 168 | default=_DEFAULT_SUCCESS_MESSAGE, 169 | help="Title for the notification upon successful cell completion", 170 | ) 171 | @argument( 172 | "--failure", 173 | "-f", 174 | default=_DEFAULT_FAILURE_MESSAGE, 175 | help="Title for the notification upon unsuccessful cell completion", 176 | ) 177 | @argument( 178 | "--mail", 179 | "-m", 180 | action="store_true", 181 | default=False, 182 | help="When opted-in, a mail is sent as notification including the cell result", 183 | ) 184 | @cell_magic 185 | def notify(self, line, cell): 186 | """ 187 | Cell magic that notifies either via desktop notification or email 188 | 189 | """ 190 | args = parse_argstring(self.notify_all, line) 191 | ip = get_ipython() 192 | exec_result = ip.run_cell(cell) 193 | self.handle_result(exec_result, args.mail, args.success, args.failure) 194 | 195 | def handle_result(self, exec_result, should_mail, success_msg, failure_msg): 196 | title = success_msg if exec_result.success else failure_msg 197 | if should_mail: 198 | 199 | message = EmailMessage() 200 | message["Subject"] = title 201 | message["From"] = getuser() 202 | message["To"] = getuser() 203 | 204 | # Append only string content to mail body - this can be later extended to 205 | # other MIME contents 206 | if exec_result.success: 207 | msg_body = str(exec_result.result) if exec_result.result else "" 208 | else: 209 | msg_body = ( 210 | str(exec_result.error_in_exec) 211 | if exec_result.error_in_exec 212 | else str(exec_result.error_before_exec) 213 | ) 214 | 215 | # TODO: Add link to the notebook that executed this magic 216 | # Related Refs: https://github.com/ipython/ipython/issues/10123, 217 | # https://github.com/kzm4269/ipynb-path 218 | 219 | message.set_content(msg_body) 220 | 221 | # ASSUMPTION: smtp server to be running on localhost 222 | # 223 | # 224 | # The below is only a basic way to implement mail using smtplib - 225 | # given the number of ways this is set up in different orgs, the 226 | # is bound to evolve per those requirements. 227 | # 228 | # If this assumption does not hold true, this needs to accept 229 | # related args from the user to open a session with the target 230 | # SMTP server (in NotifyCellCompletionMagics initializer) / provide 231 | # hooks for users to plugin their implementations of mail 232 | self.smtp_instance.send_message(message) 233 | else: 234 | display(_Notification(_NotificationType.NOTIFY, title)) 235 | 236 | @magic_arguments() 237 | @argument( 238 | "--threshold", 239 | "-t", 240 | type=int, 241 | help=( 242 | "Notification is fired for cells that take more than this amount of time" 243 | " (in seconds). Defaults to 120 seconds" 244 | ), 245 | ) 246 | @argument( 247 | "--success", 248 | "-s", 249 | default=_DEFAULT_SUCCESS_MESSAGE, 250 | help="Title for the notification upon successful cell completion", 251 | ) 252 | @argument( 253 | "--failure", 254 | "-f", 255 | default=_DEFAULT_FAILURE_MESSAGE, 256 | help="Title for the notification upon unsuccessful cell completion", 257 | ) 258 | @argument( 259 | "--mail", 260 | "-m", 261 | action="store_true", 262 | default=False, 263 | help=( 264 | "When opted-in, a mail is sent as notification including the cell result." 265 | " Defaults to False" 266 | ), 267 | ) 268 | @argument( 269 | "--disable", 270 | "-d", 271 | action="store_true", 272 | help=( 273 | "Disable notebook notifications - clears threshold set for notifications" 274 | " (if any)" 275 | ), 276 | ) 277 | @line_magic 278 | def notify_all(self, line): 279 | """ 280 | Line magic that notifies for every cell that finishes execution after the given threshold 281 | 282 | Note that, when this magic is enabled, a notification will be triggered for cell execution 283 | failures (irrespective of the time it took to execute the cell) 284 | 285 | """ 286 | args = parse_argstring(self.notify_all, line) 287 | 288 | if args.disable and (args.mail or args.threshold): 289 | raise ValueError("--disable cannot be used with --threshold or --mail") 290 | 291 | self.notify_threshold = args.threshold if args.threshold else 120 292 | self.should_notify_in_mail = args.mail 293 | self.success = args.success 294 | self.failure = args.failure 295 | 296 | ip = get_ipython() 297 | 298 | if args.disable: 299 | if self._pre_run_cell in ip.events.callbacks["pre_run_cell"]: 300 | ip.events.unregister("pre_run_cell", self._pre_run_cell) 301 | 302 | if self._post_run_cell in ip.events.callbacks["post_run_cell"]: 303 | ip.events.unregister("post_run_cell", self._post_run_cell) 304 | 305 | print("Notebook notifications are disabled") 306 | return 307 | 308 | # If a callback is already registered, skip re-registering 309 | if self._pre_run_cell not in ip.events.callbacks["pre_run_cell"]: 310 | ip.events.register("pre_run_cell", self._pre_run_cell) 311 | if self._post_run_cell not in ip.events.callbacks["post_run_cell"]: 312 | ip.events.register("post_run_cell", self._post_run_cell) 313 | 314 | def _pre_run_cell(self, info): 315 | self.run_start_time = time.time() 316 | 317 | def _post_run_cell(self, exec_result): 318 | # Do not run the hook for the cell where the magic is registered 319 | if not hasattr(self, "run_start_time"): 320 | return 321 | 322 | sec_elapsed = time.time() - self.run_start_time 323 | # Notify either if the threshold is breached or the execution failed 324 | if (sec_elapsed >= self.notify_threshold) or ( 325 | exec_result.error_before_exec or exec_result.error_in_exec 326 | ): 327 | self.handle_result( 328 | exec_result, self.should_notify_in_mail, self.success, self.failure 329 | ) 330 | -------------------------------------------------------------------------------- /notebooks/Notify.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "fdc3c61e-822b-4f2c-a119-262406b8f64a", 6 | "metadata": { 7 | "execution": { 8 | "iopub.execute_input": "2021-06-26T13:44:42.202786Z", 9 | "iopub.status.busy": "2021-06-26T13:44:42.202379Z", 10 | "iopub.status.idle": "2021-06-26T13:44:42.210147Z", 11 | "shell.execute_reply": "2021-06-26T13:44:42.206971Z", 12 | "shell.execute_reply.started": "2021-06-26T13:44:42.202695Z" 13 | } 14 | }, 15 | "source": [ 16 | "## First load the extension to make the magic avaiable" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 1, 22 | "id": "58e491b0-891b-439c-b254-8e8284ab0a4d", 23 | "metadata": { 24 | "execution": { 25 | "iopub.execute_input": "2021-06-26T13:49:21.123200Z", 26 | "iopub.status.busy": "2021-06-26T13:49:21.122613Z", 27 | "iopub.status.idle": "2021-06-26T13:49:21.164800Z", 28 | "shell.execute_reply": "2021-06-26T13:49:21.163799Z", 29 | "shell.execute_reply.started": "2021-06-26T13:49:21.123000Z" 30 | }, 31 | "tags": [] 32 | }, 33 | "outputs": [ 34 | { 35 | "data": { 36 | "application/desktop-notify+json": { 37 | "id": "5b58ada4-a013-44dc-a953-bf6a53220e7a", 38 | "payload": { 39 | "title": null 40 | }, 41 | "type": "INIT" 42 | }, 43 | "text/plain": [ 44 | "" 45 | ] 46 | }, 47 | "metadata": {}, 48 | "output_type": "display_data" 49 | } 50 | ], 51 | "source": [ 52 | "%load_ext jupyterlab_notify" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": 2, 58 | "id": "131e5355-3f24-4f1b-8fc0-97ca1798e249", 59 | "metadata": { 60 | "execution": { 61 | "iopub.execute_input": "2021-06-26T13:49:21.167953Z", 62 | "iopub.status.busy": "2021-06-26T13:49:21.167553Z", 63 | "iopub.status.idle": "2021-06-26T13:49:21.179360Z", 64 | "shell.execute_reply": "2021-06-26T13:49:21.178161Z", 65 | "shell.execute_reply.started": "2021-06-26T13:49:21.167917Z" 66 | }, 67 | "tags": [] 68 | }, 69 | "outputs": [ 70 | { 71 | "name": "stdout", 72 | "output_type": "stream", 73 | "text": [ 74 | "Hello world\n" 75 | ] 76 | }, 77 | { 78 | "data": { 79 | "application/desktop-notify+json": { 80 | "id": "86d6e76c-1d76-4287-980b-57b912a2a89a", 81 | "payload": { 82 | "title": "Cell execution completed successfully" 83 | }, 84 | "type": "NOTIFY" 85 | }, 86 | "text/plain": [ 87 | "" 88 | ] 89 | }, 90 | "metadata": {}, 91 | "output_type": "display_data" 92 | } 93 | ], 94 | "source": [ 95 | "%%notify\n", 96 | "# Always notify\n", 97 | "print('Hello world')" 98 | ] 99 | }, 100 | { 101 | "cell_type": "code", 102 | "execution_count": 3, 103 | "id": "33b642e5-3a69-4213-9a0c-81ec61553a88", 104 | "metadata": { 105 | "execution": { 106 | "iopub.execute_input": "2021-06-26T13:49:21.182713Z", 107 | "iopub.status.busy": "2021-06-26T13:49:21.182391Z", 108 | "iopub.status.idle": "2021-06-26T13:49:23.193565Z", 109 | "shell.execute_reply": "2021-06-26T13:49:23.191073Z", 110 | "shell.execute_reply.started": "2021-06-26T13:49:21.182674Z" 111 | }, 112 | "tags": [] 113 | }, 114 | "outputs": [], 115 | "source": [ 116 | "%notify_all --threshold 1\n", 117 | "# Notify after a cell that takes 1s or more\n", 118 | "import time\n", 119 | "time.sleep(2)" 120 | ] 121 | }, 122 | { 123 | "cell_type": "code", 124 | "execution_count": 4, 125 | "id": "99822b36-9f09-42ba-90ba-256eece72725", 126 | "metadata": { 127 | "execution": { 128 | "iopub.execute_input": "2021-06-26T13:49:23.196897Z", 129 | "iopub.status.busy": "2021-06-26T13:49:23.196460Z", 130 | "iopub.status.idle": "2021-06-26T13:49:23.202589Z", 131 | "shell.execute_reply": "2021-06-26T13:49:23.201292Z", 132 | "shell.execute_reply.started": "2021-06-26T13:49:23.196847Z" 133 | }, 134 | "tags": [] 135 | }, 136 | "outputs": [], 137 | "source": [ 138 | "# Notify for any cell that errors or takes more than a day\n", 139 | "%notify_all -t 86400 -f 'Notebook execution failed'" 140 | ] 141 | }, 142 | { 143 | "cell_type": "code", 144 | "execution_count": 5, 145 | "id": "912ef76d-74b6-4160-a0be-68993c33e2fe", 146 | "metadata": { 147 | "execution": { 148 | "iopub.execute_input": "2021-06-26T13:49:23.205093Z", 149 | "iopub.status.busy": "2021-06-26T13:49:23.204610Z", 150 | "iopub.status.idle": "2021-06-26T13:49:23.228532Z", 151 | "shell.execute_reply": "2021-06-26T13:49:23.226690Z", 152 | "shell.execute_reply.started": "2021-06-26T13:49:23.205039Z" 153 | }, 154 | "tags": [] 155 | }, 156 | "outputs": [ 157 | { 158 | "name": "stdout", 159 | "output_type": "stream", 160 | "text": [ 161 | "doing work...\n" 162 | ] 163 | } 164 | ], 165 | "source": [ 166 | "print('doing work...')" 167 | ] 168 | }, 169 | { 170 | "cell_type": "code", 171 | "execution_count": 6, 172 | "id": "e8d0db9f-71e7-4fa0-b5b4-4892c162387a", 173 | "metadata": { 174 | "execution": { 175 | "iopub.execute_input": "2021-06-26T13:49:23.232259Z", 176 | "iopub.status.busy": "2021-06-26T13:49:23.231895Z", 177 | "iopub.status.idle": "2021-06-26T13:49:23.236874Z", 178 | "shell.execute_reply": "2021-06-26T13:49:23.235480Z", 179 | "shell.execute_reply.started": "2021-06-26T13:49:23.232227Z" 180 | }, 181 | "tags": [] 182 | }, 183 | "outputs": [ 184 | { 185 | "name": "stdout", 186 | "output_type": "stream", 187 | "text": [ 188 | "doing work...\n" 189 | ] 190 | } 191 | ], 192 | "source": [ 193 | "print('doing work...')" 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": 7, 199 | "id": "5645d5f7-0949-4bff-b5fb-edd6376d5698", 200 | "metadata": { 201 | "execution": { 202 | "iopub.execute_input": "2021-06-26T13:49:23.239499Z", 203 | "iopub.status.busy": "2021-06-26T13:49:23.239071Z", 204 | "iopub.status.idle": "2021-06-26T13:49:23.259758Z", 205 | "shell.execute_reply": "2021-06-26T13:49:23.258746Z", 206 | "shell.execute_reply.started": "2021-06-26T13:49:23.239452Z" 207 | }, 208 | "tags": [] 209 | }, 210 | "outputs": [ 211 | { 212 | "data": { 213 | "application/desktop-notify+json": { 214 | "id": "91295ce8-b0df-4936-aadb-cece78705e19", 215 | "payload": { 216 | "title": "'Notebook execution completed'" 217 | }, 218 | "type": "NOTIFY" 219 | }, 220 | "text/plain": [ 221 | "" 222 | ] 223 | }, 224 | "metadata": {}, 225 | "output_type": "display_data" 226 | } 227 | ], 228 | "source": [ 229 | "%%notify -s 'Notebook execution completed'\n", 230 | "# In last cell we do this so we can run all and get notified that the notebook failed (per above) or finished!" 231 | ] 232 | } 233 | ], 234 | "metadata": { 235 | "kernelspec": { 236 | "display_name": "Python 3", 237 | "language": "python", 238 | "name": "python3" 239 | }, 240 | "language_info": { 241 | "codemirror_mode": { 242 | "name": "ipython", 243 | "version": 3 244 | }, 245 | "file_extension": ".py", 246 | "mimetype": "text/x-python", 247 | "name": "python", 248 | "nbconvert_exporter": "python", 249 | "pygments_lexer": "ipython3", 250 | "version": "3.7.3" 251 | } 252 | }, 253 | "nbformat": 4, 254 | "nbformat_minor": 5 255 | } 256 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-notify", 3 | "version": "2.0.2", 4 | "description": "JupyterLab extension to notify cell completion", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/deshaw/jupyterlab-notify", 11 | "bugs": { 12 | "url": "https://github.com/deshaw/jupyterlab-notify/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "files": [ 16 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 17 | "style/index.js" 18 | ], 19 | "main": "lib/index.js", 20 | "types": "lib/index.d.ts", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/deshaw/jupyterlab-notify.git" 24 | }, 25 | "scripts": { 26 | "build": "jlpm build:lib && jlpm build:labextension:dev", 27 | "build:labextension": "jupyter labextension build .", 28 | "build:labextension:dev": "jupyter labextension build --development True .", 29 | "build:lib": "tsc --sourceMap", 30 | "build:lib:prod": "tsc", 31 | "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", 32 | "clean": "jlpm clean:lib", 33 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 34 | "clean:labextension": "rimraf jupyterlab_notify/labextension jupyterlab_notify/_version.py", 35 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 36 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 37 | "eslint": "jlpm eslint:check --fix", 38 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 39 | "install:extension": "jlpm build", 40 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 41 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 42 | "prepare": "jlpm run clean && jlpm run build:prod", 43 | "prettier": "jlpm prettier:base --write --list-different", 44 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 45 | "prettier:check": "jlpm prettier:base --check", 46 | "stylelint": "jlpm stylelint:check --fix", 47 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 48 | "watch": "run-p watch:src watch:labextension", 49 | "watch:labextension": "jupyter labextension watch .", 50 | "watch:src": "tsc -w --sourceMap" 51 | }, 52 | "dependencies": { 53 | "@jupyterlab/application": "^4.0.0-beta.0", 54 | "@jupyterlab/rendermime-interfaces": "^3.8.0-rc.1", 55 | "@lumino/coreutils": "^2.0.0", 56 | "@lumino/widgets": "^2.0.1" 57 | }, 58 | "devDependencies": { 59 | "@jupyterlab/builder": "^4.0.0-beta.0", 60 | "@types/json-schema": "^7.0.11", 61 | "@types/react": "^18.0.26", 62 | "@typescript-eslint/eslint-plugin": "^5.55.0", 63 | "@typescript-eslint/parser": "^5.55.0", 64 | "css-loader": "^6.7.3", 65 | "eslint": "^8.36.0", 66 | "eslint-config-prettier": "^8.7.0", 67 | "eslint-plugin-prettier": "^4.2.1", 68 | "mkdirp": "^3.0.1", 69 | "npm-run-all": "^4.1.5", 70 | "prettier": "^2.8.7", 71 | "rimraf": "^5.0.0", 72 | "source-map-loader": "^4.0.1", 73 | "style-loader": "^3.3.2", 74 | "stylelint": "^14.9.1", 75 | "stylelint-config-prettier": "^9.0.4", 76 | "stylelint-config-recommended": "^8.0.0", 77 | "stylelint-config-standard": "^26.0.0", 78 | "stylelint-prettier": "^2.0.0", 79 | "typescript": "~5.0.2", 80 | "yjs": "^13.5.40" 81 | }, 82 | "jupyterlab": { 83 | "mimeExtension": true, 84 | "outputDir": "jupyterlab_notify/labextension" 85 | }, 86 | "styleModule": "style/index.js", 87 | "eslintIgnore": [ 88 | "node_modules", 89 | "dist", 90 | "coverage", 91 | "**/*.d.ts", 92 | "tests" 93 | ], 94 | "prettier": { 95 | "singleQuote": true, 96 | "trailingComma": "all", 97 | "arrowParens": "avoid", 98 | "endOfLine": "auto" 99 | }, 100 | "eslintConfig": { 101 | "extends": [ 102 | "eslint:recommended", 103 | "plugin:@typescript-eslint/eslint-recommended", 104 | "plugin:@typescript-eslint/recommended", 105 | "plugin:prettier/recommended" 106 | ], 107 | "parser": "@typescript-eslint/parser", 108 | "parserOptions": { 109 | "project": "tsconfig.json", 110 | "sourceType": "module" 111 | }, 112 | "plugins": [ 113 | "@typescript-eslint" 114 | ], 115 | "rules": { 116 | "@typescript-eslint/naming-convention": [ 117 | "error", 118 | { 119 | "selector": "interface", 120 | "format": [ 121 | "PascalCase" 122 | ], 123 | "custom": { 124 | "regex": "^I[A-Z]", 125 | "match": true 126 | } 127 | } 128 | ], 129 | "@typescript-eslint/no-unused-vars": [ 130 | "warn", 131 | { 132 | "args": "none" 133 | } 134 | ], 135 | "@typescript-eslint/no-explicit-any": "off", 136 | "@typescript-eslint/no-namespace": "off", 137 | "@typescript-eslint/no-use-before-define": "off", 138 | "@typescript-eslint/quotes": [ 139 | "error", 140 | "single", 141 | { 142 | "avoidEscape": true, 143 | "allowTemplateLiterals": false 144 | } 145 | ], 146 | "curly": [ 147 | "error", 148 | "all" 149 | ], 150 | "eqeqeq": "error", 151 | "prefer-arrow-callback": "error" 152 | } 153 | }, 154 | "stylelint": { 155 | "extends": [ 156 | "stylelint-config-recommended", 157 | "stylelint-config-standard", 158 | "stylelint-prettier/recommended" 159 | ], 160 | "rules": { 161 | "property-no-vendor-prefix": null, 162 | "selector-no-vendor-prefix": null, 163 | "value-no-vendor-prefix": null 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0b0,<5", "hatch-nodejs-version"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyterlab_notify" 7 | readme = "README.md" 8 | license = { file = "LICENSE.txt" } 9 | requires-python = ">=3.8" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 4", 14 | "Framework :: Jupyter :: JupyterLab :: Extensions", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | ] 24 | dependencies = [ 25 | "jupyter_server>=2.0.1,<3" 26 | ] 27 | dynamic = ["version", "description", "authors", "urls", "keywords"] 28 | 29 | [tool.hatch.version] 30 | source = "nodejs" 31 | 32 | [tool.hatch.metadata.hooks.nodejs] 33 | fields = ["description", "authors", "urls"] 34 | 35 | [tool.hatch.build.targets.sdist] 36 | artifacts = ["jupyterlab_notify/labextension"] 37 | exclude = [".github", "binder"] 38 | 39 | [tool.hatch.build.targets.wheel.shared-data] 40 | "jupyterlab_notify/labextension" = "share/jupyter/labextensions/jupyterlab-notify" 41 | "install.json" = "share/jupyter/labextensions/jupyterlab-notify/install.json" 42 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 43 | "jupyter-config/nb-config" = "etc/jupyter/jupyter_notebook_config.d" 44 | 45 | [tool.hatch.build.hooks.version] 46 | path = "jupyterlab_notify/_version.py" 47 | 48 | [tool.hatch.build.hooks.jupyter-builder] 49 | dependencies = ["hatch-jupyter-builder>=0.5"] 50 | build-function = "hatch_jupyter_builder.npm_builder" 51 | ensured-targets = [ 52 | "jupyterlab_notify/labextension/static/style.js", 53 | "jupyterlab_notify/labextension/package.json", 54 | ] 55 | skip-if-exists = ["jupyterlab_notify/labextension/static/style.js"] 56 | 57 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 58 | build_cmd = "build:prod" 59 | npm = ["jlpm"] 60 | 61 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 62 | build_cmd = "install:extension" 63 | npm = ["jlpm"] 64 | source_dir = "src" 65 | build_dir = "jupyterlab_notify/labextension" 66 | 67 | [tool.jupyter-releaser.options] 68 | version_cmd = "hatch version" 69 | 70 | [tool.jupyter-releaser.hooks] 71 | before-build-npm = [ 72 | "python -m pip install 'jupyterlab>=4.0.0b0,<5'", 73 | "jlpm", 74 | "jlpm build:prod" 75 | ] 76 | before-build-python = ["jlpm clean:all"] 77 | 78 | [tool.check-wheel-contents] 79 | ignore = ["W002"] 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; 2 | 3 | import { JSONObject } from '@lumino/coreutils'; 4 | 5 | import { Widget } from '@lumino/widgets'; 6 | 7 | /** 8 | * The default mime type for the extension. 9 | */ 10 | const MIME_TYPE = 'application/desktop-notify+json'; 11 | const PROCESSED_KEY = 'isProcessed'; 12 | // The below can be used to customize notifications 13 | const NOTIFICATION_OPTIONS = { 14 | icon: '/static/favicons/favicon.ico', 15 | }; 16 | 17 | interface INotifyMimeData { 18 | type: 'INIT' | 'NOTIFY'; 19 | payload: Record; 20 | isProcessed: boolean; 21 | id: string; 22 | } 23 | 24 | /** 25 | * A widget for rendering desktop-notify. 26 | */ 27 | class OutputWidget extends Widget implements IRenderMime.IRenderer { 28 | constructor(options: IRenderMime.IRendererOptions) { 29 | super(); 30 | this._mimeType = options.mimeType; 31 | } 32 | 33 | renderModel(model: IRenderMime.IMimeModel): Promise { 34 | const mimeData = model.data[this._mimeType] as unknown as INotifyMimeData; 35 | 36 | const payload = mimeData.payload as JSONObject; 37 | 38 | // If the PROCESSED_KEY is available - do not take any action 39 | // This is done so that notifications are not repeated on page refresh 40 | if (mimeData[PROCESSED_KEY]) { 41 | return Promise.resolve(); 42 | } 43 | 44 | // For first-time users, check for necessary permissions and prompt if needed 45 | if ( 46 | (mimeData.type === 'INIT' && Notification.permission === 'default') || 47 | Notification.permission !== 'granted' 48 | ) { 49 | // We do not have any actions to perform upon acquiring permission and so 50 | // handle only the errors (if any) 51 | Notification.requestPermission().catch(err => { 52 | alert( 53 | `Encountered error - ${err} while requesting permissions for notebook notifications`, 54 | ); 55 | }); 56 | } 57 | 58 | if (mimeData.type === 'NOTIFY') { 59 | // Notify only if there's sufficient permissions and this has not been processed previously 60 | if (Notification.permission === 'granted' && !mimeData[PROCESSED_KEY]) { 61 | new Notification(payload.title as string, NOTIFICATION_OPTIONS); 62 | } else { 63 | this.node.innerHTML = `
Missing permissions - update "Notifications" preferences under browser settings to receive notifications
`; 64 | } 65 | } 66 | 67 | if (!mimeData[PROCESSED_KEY]) { 68 | // Add isProcessed property to each notification message so that we can avoid repeating notifications on page reloads 69 | const updatedModel: IRenderMime.IMimeModel = JSON.parse( 70 | JSON.stringify(model), 71 | ); 72 | const updatedMimeData = updatedModel.data[ 73 | this._mimeType 74 | ] as unknown as INotifyMimeData; 75 | updatedMimeData[PROCESSED_KEY] = true; 76 | // The below model update is done inside a separate function and added to 77 | // the event queue - this is done so to avoid re-rendering before the 78 | // initial render is complete. 79 | // 80 | // Without the setTimeout, calling model.setData triggers the callbacks 81 | // registered on model-updates that re-renders the widget and it again tries 82 | // to update the model which again causes a re-render and so on. 83 | setTimeout(() => { 84 | model.setData(updatedModel); 85 | }, 0); 86 | } 87 | 88 | return Promise.resolve(); 89 | } 90 | 91 | private _mimeType: string; 92 | } 93 | 94 | /** 95 | * A mime renderer factory for desktop-notify data. 96 | */ 97 | const rendererFactory: IRenderMime.IRendererFactory = { 98 | safe: true, 99 | mimeTypes: [MIME_TYPE], 100 | createRenderer: options => new OutputWidget(options), 101 | }; 102 | 103 | /** 104 | * Extension definition. 105 | */ 106 | const extension: IRenderMime.IExtension = { 107 | id: 'desktop-notify:plugin', 108 | rendererFactory, 109 | rank: 0, 110 | dataType: 'json', 111 | }; 112 | 113 | console.log('jupyterlab-notify render activated'); 114 | 115 | export default extension; 116 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | See the JupyterLab Developer Guide for useful CSS Patterns: 3 | 4 | https://jupyterlab.readthedocs.io/en/stable/developer/css.html 5 | */ 6 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import 'base.css'; 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2018", 21 | "types": [] 22 | }, 23 | "include": ["src/*"] 24 | } 25 | --------------------------------------------------------------------------------