├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── MANIFEST.in ├── README.md ├── babel.config.js ├── binder ├── environment.yml ├── jupyter_notebook_config.json └── postBuild ├── doc └── preview-jupyter-project.gif ├── jest.config.js ├── jupyter-config └── jupyter_project.json ├── jupyter_notebook_config.json ├── jupyter_project ├── __init__.py ├── _version.py ├── autoinstance.py ├── config.py ├── examples │ ├── demo.ipynb │ ├── example.ipynb │ └── train_model.py ├── files.py ├── handlers.py ├── jinja2.py ├── project.py ├── tests │ ├── test_autoinstance.py │ ├── test_config.py │ ├── test_file.py │ ├── test_project.py │ ├── test_settings.py │ └── utils.py └── traits.py ├── package.json ├── pyproject.toml ├── release.py ├── setup.py ├── src ├── __tests__ │ ├── project.spec.ts │ └── utils.spec.ts ├── filetemplates.ts ├── form.tsx ├── index.ts ├── jupyter-project.ts ├── project.ts ├── statusbar.tsx ├── style.ts ├── svg.d.ts ├── theme.ts ├── tokens.ts ├── utils.ts └── validator.ts ├── style ├── icons │ ├── project.svg │ └── template.svg └── index.css ├── tsconfig.eslint.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | **/*.d.ts 5 | tests 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:prettier/recommended', 7 | 'plugin:react/recommended' 8 | ], 9 | env: { 10 | "es2017": true 11 | }, 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | project: 'tsconfig.eslint.json', 15 | sourceType: 'module' 16 | 17 | }, 18 | plugins: ['@typescript-eslint'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': [ 21 | 'error', 22 | { prefixWithI: 'always' } 23 | ], 24 | '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | '@typescript-eslint/no-namespace': 'off', 27 | '@typescript-eslint/no-use-before-define': 'off', 28 | '@typescript-eslint/quotes': [ 29 | 'error', 30 | 'single', 31 | { avoidEscape: true, allowTemplateLiterals: false } 32 | ], 33 | curly: ['error', 'all'], 34 | eqeqeq: 'error', 35 | 'prefer-arrow-callback': 'error' 36 | }, 37 | settings: { 38 | react: { 39 | version: 'detect' 40 | } 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: master 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Install node 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: '12.x' 19 | - name: Install Python 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: '3.7' 23 | architecture: 'x64' 24 | - name: Upgrade pip 25 | run: python -m pip install --upgrade pip wheel 26 | - name: Install dependencies 27 | run: python -m pip install jupyterlab~=1.2 coverage[toml] 28 | 29 | - name: Test the extension 30 | run: | 31 | jlpm 32 | jlpm run lint:check 33 | jlpm run test 34 | 35 | pip install .[test] 36 | 37 | # Avoid the example jupyter_notebook_config.json to be read. 38 | mv jupyter_notebook_config.json example.json 39 | python -m coverage run -m pytest . 40 | python -m coverage report 41 | 42 | jupyter lab build 43 | jupyter serverextension list 1>serverextensions 2>&1 44 | cat serverextensions | grep "jupyter_project.*OK" 45 | jupyter labextension list 1>labextensions 2>&1 46 | cat labextensions | grep "jupyter-project.*OK" 47 | 48 | python -m jupyterlab.browser_check 49 | 50 | - name: Install Ruby 51 | uses: ruby/setup-ruby@v1 52 | with: 53 | ruby-version: 2.6 54 | - name: Coveralls 55 | env: 56 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | run: | 59 | gem install coveralls-lcov 60 | coveralls-lcov -v -n coverage/lcov.info > jscoverage.json 61 | python -m pip install coveralls 62 | python -m coveralls --merge=jscoverage.json 63 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Install node 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '12.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - name: Install Python 19 | uses: actions/setup-python@v1 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel twine jupyter-packaging jupyterlab packaging 26 | - name: Check compatibility version 27 | run: | 28 | python -c "from release import assert_equal_version; assert_equal_version()" 29 | - name: Build and publish NPM package 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | npm publish 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | - name: Publish Python package 36 | env: 37 | TWINE_USERNAME: __token__ 38 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 39 | run: 40 | twine upload dist/* 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.egg-info/ 5 | .ipynb_checkpoints 6 | *.tsbuildinfo 7 | 8 | */labextension/*.tgz 9 | # Created by https://www.gitignore.io/api/python 10 | # Edit at https://www.gitignore.io/?templates=python 11 | 12 | ### Python ### 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | pip-wheel-metadata/ 36 | share/python-wheels/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | .spyproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Mr Developer 94 | .mr.developer.cfg 95 | .project 96 | .pydevproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | .dmypy.json 104 | dmypy.json 105 | 106 | # Pyre type checker 107 | .pyre/ 108 | 109 | # End of https://www.gitignore.io/api/python 110 | .vscode/ 111 | coverage/ 112 | *.log 113 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /packages/*/node_modules 3 | /packages/*/lib 4 | /packages/*/package.json 5 | /packages/*/__tests__ 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Frederic Collonval All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 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. 29 | 30 | This software uses part of is-svg package released under the following license 31 | 32 | Source: https://github.com/sindresorhus/is-svg 33 | 34 | MIT License 35 | 36 | Copyright (c) Sindre Sorhus (sindresorhus.com) 37 | 38 | Permission is hereby granted, free of charge, to any person obtaining a copy 39 | of this software and associated documentation files (the "Software"), to deal 40 | in the Software without restriction, including without limitation the rights 41 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 42 | copies of the Software, and to permit persons to whom the Software is 43 | furnished to do so, subject to the following conditions: 44 | 45 | The above copyright notice and this permission notice shall be included in all 46 | copies or substantial portions of the Software. 47 | 48 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 49 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 50 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 51 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 52 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 53 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 54 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include pyproject.toml 4 | include jupyter_notebook_config.json 5 | 6 | include jupyter-config/jupyter_project.json 7 | 8 | include package.json 9 | include ts*.json 10 | include jupyter_project/labextension/*.tgz 11 | graft jupyter_project/examples 12 | prune jupyter_project/examples/.ipynb_checkpoints 13 | 14 | # Javascript files 15 | graft src 16 | graft style 17 | prune **/node_modules 18 | prune lib 19 | 20 | # Patterns to exclude from any directory 21 | global-exclude *~ 22 | global-exclude *.pyc 23 | global-exclude *.pyo 24 | global-exclude .git 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This project is archived (lack of time to maintain)** If you wish to take over its development, feel free to fork it or to contact me on https://gitter.im/jupyterlab/jupyterlab. 2 | 3 | # jupyter-project 4 | 5 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/fcollonval/jupyter-project/master?urlpath=lab) 6 | [![Github Actions Status](https://github.com/fcollonval/jupyter-project/workflows/Test/badge.svg)](https://github.com/fcollonval/jupyter-project/actions?query=workflow%3ATest) 7 | [![Coverage Status](https://coveralls.io/repos/github/fcollonval/jupyter-project/badge.svg?branch=master)](https://coveralls.io/github/fcollonval/jupyter-project?branch=master) 8 | [![Conda (channel only)](https://img.shields.io/conda/vn/conda-forge/jupyter-project)](https://anaconda.org/conda-forge/jupyter-project) 9 | [![PyPI](https://img.shields.io/pypi/v/jupyter-project)](https://pypi.org/project/jupyter-project/) 10 | [![npm](https://img.shields.io/npm/v/jupyter-project)](https://www.npmjs.com/package/jupyter-project) 11 | 12 | An JupyterLab extension to handle (a unique) project and files templates. It adds the ability 13 | to generate projects from a [cookiecutter](https://cookiecutter.readthedocs.io/en/latest/) template as well as generate files 14 | from [Jinja2](https://jinja.palletsprojects.com/en/master/) templates. Those templates can be parametrized directly from 15 | the frontend by specifying [JSON schemas](https://json-schema.org/). 16 | 17 | This extension is composed of a Python package named `jupyter_project` 18 | for the server extension and a NPM package named `jupyter-project` 19 | for the frontend extension. 20 | 21 | - [Requirements](#Requirements) 22 | - [Install](#Install) 23 | - [Configuration](#Configuring-the-extension) 24 | - [File templates](#File-templates) 25 | - [Project template](#Project-template) 26 | - [Conda integration](#Conda-environment-integration) 27 | - [Git integration](#Git-integration) 28 | - [Complete configuration](#Full-configuration) 29 | - [Troubleshoot](#Troubleshoot) 30 | - [Contributing](#Contributing) 31 | - [Uninstall](#Uninstall) 32 | - [Alternatives](#Alternatives) 33 | 34 | ![screencast](doc/preview-jupyter-project.gif) 35 | 36 | Test it with all third-parties extensions: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/fcollonval/jupyter-project/master?urlpath=lab) 37 | Test it without them: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/fcollonval/jupyter-project/binder-no-3rd-parties?urlpath=lab) 38 | 39 | ## Requirements 40 | 41 | - Python requirements: 42 | 43 | ```py 44 | # setup.py#L63-L66 45 | 46 | "cookiecutter", 47 | "jinja2~=2.9", 48 | "jsonschema", 49 | "jupyterlab~=2.0" 50 | ``` 51 | 52 | This extension is also available for JupyterLab 1.2.x. 53 | 54 | - Optional Python requirements: 55 | 56 | ```py 57 | # setup.py#L69-L72 58 | 59 | "all": [ 60 | "jupyter_conda~=3.3", 61 | "jupyterlab-git~=0.20" 62 | ], 63 | ``` 64 | 65 | - Optional JupyterLab extensions: 66 | 67 | - @jupyterlab/git 68 | - jupyterlab_conda 69 | 70 | > On JupyterLab 2.x, the features coming from the optional JupyterLab extensions are not available due to a [bug in JupyterLab](https://github.com/jupyterlab/jupyterlab/issues/8504). 71 | 72 | ## Install 73 | 74 | > Note: You will need NodeJS to install the extension. 75 | 76 | With pip: 77 | 78 | ```bash 79 | pip install jupyter-project 80 | jupyter lab build 81 | ``` 82 | 83 | Or with conda: 84 | 85 | ```bash 86 | conda install -c conda-forge jupyter-project 87 | jupyter lab build 88 | ``` 89 | 90 | ## Configuring the extension 91 | 92 | By default, this extension will not add anything to JupyterLab as the templates must be configured 93 | as part of the server extension configuration key **JupyterProject** (see [Jupyter server configuration](https://jupyter-notebook.readthedocs.io/en/stable/config_overview.html#) for more information). 94 | 95 | The configuration example for Binder will be described next - this is the file [binder/jupyter_notebook_config.json](binder/jupyter_notebook_config.json). 96 | 97 | The section for this extension must be named **JupyterProject**: 98 | 99 | ```json5 100 | // ./binder/jupyter_notebook_config.json#L7-L7 101 | 102 | "JupyterProject": { 103 | ``` 104 | 105 | It accepts to optional keys: _file_templates_ and _project_template_. The first defines a list 106 | of places containing templated files. And the second describe the project template. They can 107 | both exist alone (i.e. only file templates or only the project template). 108 | 109 | ### File templates 110 | 111 | The file templates can be located in a `location` provided by its fullpath or in a `location` 112 | within a Python `module`. In the Binder example, the template are located in the folder `examples` 113 | part of the `jupyter_project` Python module: 114 | 115 | ```json5 116 | // ./binder/jupyter_notebook_config.json#L8-L12 117 | 118 | "file_templates": [ 119 | { 120 | "name": "data-sciences", 121 | "module": "jupyter_project", 122 | "location": "examples", 123 | ``` 124 | 125 | The last parameter appearing here is _name_. It described uniquely the source of file templates. 126 | 127 | Than comes the list of templated files available in that source. There are three templated 128 | file examples. The shortest configuration is: 129 | 130 | ```json5 131 | // ./binder/jupyter_notebook_config.json#L14-L16 132 | 133 | { 134 | "template": "demo.ipynb" 135 | }, 136 | ``` 137 | 138 | This will create a template by copy of the provided file. 139 | 140 | But usually, a template comes with parameters. This extension handles parameters through 141 | a [JSON schema specification](https://json-schema.org/understanding-json-schema/index.html). 142 | That schema will be used to prompt the user with a form that will be validated against 143 | the schema. Then the form values will be passed to [Jinja2](https://jinja.palletsprojects.com/en/master/) 144 | to rendered the templates. 145 | 146 | > In addition, if a project is active, its properties like name or dirname will be available in 147 | > the Jinja template as ``jproject.`` (e.g. ``jproject.name`` for the project name). 148 | 149 | ```json5 150 | // ./binder/jupyter_notebook_config.json#L74-L92 151 | 152 | { 153 | "default_name": "{{ modelName }}", 154 | "destination": "src/models", 155 | "schema": { 156 | "type": "object", 157 | "properties": { 158 | "authorName": { 159 | "type": "string" 160 | }, 161 | "modelName": { 162 | "type": "string", 163 | "pattern": "^[a-zA-Z_]\\w*$" 164 | } 165 | }, 166 | "required": ["modelName"] 167 | }, 168 | "template_name": "Train Model", 169 | "template": "train_model.py" 170 | } 171 | ``` 172 | 173 | In the settings, you can see three additional entries that have not been explained yet: 174 | 175 | - `template_name`: A nicer name for the template to be displayed in the frontend. 176 | - `default_name`: Default name for the file generated from the template (the string may contain Jinja2 variables defined in the `schema`). 177 | - `destination`: If you are using the project template, the generated file will be placed 178 | within the destination folder inside the active project folder. If no project is active 179 | the file will be written in the current folder. It can contain project templated variable: 180 | 181 | - ``{{jproject.name}}``: Project name 182 | - ``{{jproject.dirname}}``: Project directory name 183 | 184 | The latest file template example is a complete example of all possibilities (including 185 | type of variables that you could used in the schema): 186 | 187 | ```json5 188 | // ./binder/jupyter_notebook_config.json#L17-L73 189 | 190 | { 191 | "destination": "notebooks", 192 | "icon": " ", 193 | "template_name": "Example", 194 | "template": "example.ipynb", 195 | "schema": { 196 | "type": "object", 197 | "properties": { 198 | "exampleBoolean": { 199 | "default": false, 200 | "title": "A choice", 201 | "type": "boolean" 202 | }, 203 | "exampleList": { 204 | "default": [1, 2, 3], 205 | "title": "A list of number", 206 | "type": "array", 207 | "items": { 208 | "default": 0, 209 | "type": "number" 210 | } 211 | }, 212 | "exampleNumber": { 213 | "default": 42, 214 | "title": "A number", 215 | "type": "number", 216 | "minimum": 0, 217 | "maximum": 100 218 | }, 219 | "exampleObject": { 220 | "default": { 221 | "number": 1, 222 | "street_name": "Dog", 223 | "street_type": "Street" 224 | }, 225 | "title": "A object", 226 | "type": "object", 227 | "properties": { 228 | "number": { "type": "integer" }, 229 | "street_name": { "type": "string" }, 230 | "street_type": { 231 | "type": "string", 232 | "enum": ["Street", "Avenue", "Boulevard"] 233 | } 234 | }, 235 | "required": ["number"] 236 | }, 237 | "exampleString": { 238 | "default": "I_m_Beautiful", 239 | "title": "A string", 240 | "type": "string", 241 | "pattern": "^[a-zA-Z_]\\w*$" 242 | } 243 | }, 244 | "required": ["exampleString"] 245 | } 246 | }, 247 | ``` 248 | 249 | A careful reader may notice the last available setting: `icon`. It is a stringified 250 | svg that will be used to set a customized icon in the frontend for the template. 251 | 252 | If you need to set templates from different sources, you can add entry similar to 253 | `data-sciences` in the `file_templates` list. 254 | 255 | ### Project template 256 | 257 | The second major configuration section is `project_template`. The template must 258 | specified a value for `template` that points to a valid [cookiecutter](https://cookiecutter.readthedocs.io/en/latest/) 259 | template source: 260 | 261 | ```json5 262 | // ./binder/jupyter_notebook_config.json#L96-L97 263 | 264 | "project_template": { 265 | "template": "https://github.com/drivendata/cookiecutter-data-science", 266 | ``` 267 | 268 | The cookiecutter template parameters that you wish the user to be able to change must be 269 | specified as a [JSON schema](https://json-schema.org/understanding-json-schema/index.html): 270 | 271 | ```json5 272 | // ./binder/jupyter_notebook_config.json#L98-L125 273 | 274 | "schema": { 275 | "type": "object", 276 | "properties": { 277 | "project_name": { 278 | "type": "string", 279 | "default": "Project Name" 280 | }, 281 | "repo_name": { 282 | "title": "Folder name", 283 | "type": "string", 284 | "pattern": "^[a-zA-Z_]\\w*$", 285 | "default": "project_name" 286 | }, 287 | "author_name": { 288 | "type": "string", 289 | "description": "Your name (or your organization/company/team)" 290 | }, 291 | "description": { 292 | "type": "string", 293 | "description": "A short description of the project." 294 | }, 295 | "open_source_license": { 296 | "type": "string", 297 | "enum": ["MIT", "BSD-3-Clause", "No license file"] 298 | } 299 | }, 300 | "required": ["project_name", "repo_name"] 301 | }, 302 | ``` 303 | 304 | Then you need to set `folder_name` as the name of the folder resulting from the cookiecutter 305 | template. This is a string accepting Jinja2 variables defined in the `schema`. 306 | 307 | The latest option in the example is `default_path`. This is optional and, if set, it should 308 | provide the default path (folder or file) to be opened by JupyterLab once the project has 309 | been generated. It can contain project templated variable: 310 | 311 | - ``{{jproject.name}}``: Project name 312 | - ``{{jproject.dirname}}``: Project directory name 313 | 314 | ```json5 315 | // ./binder/jupyter_notebook_config.json#L126-L127 316 | 317 | "folder_name": "{{ repo_name }}", 318 | "default_path": "README.md", 319 | ``` 320 | 321 | #### Conda environment integration 322 | 323 | If the [`jupyter_conda`](https://github.com/fcollonval/jupyter_conda) optional extension is installed 324 | and if `conda_pkgs` is specified in the `project_template` configuration, then a Conda environment 325 | will follow the life cycle of the project; i.e. creation of an environment at project creation, 326 | update of the environment when opening a project and changing its packages and deletion at project deletion. 327 | 328 | The `conda_pkgs` setting should be set to a string matching the default environment type of conda environment 329 | to be created at project creation (see [`jupyter_conda`](https://github.com/fcollonval/jupyter_conda/blob/master/labextension/schema/plugin.json#L13) 330 | labextension for more information). You can also set a packages list separated by space. 331 | 332 | The binder example defines: 333 | 334 | ```json5 335 | // ./binder/jupyter_notebook_config.json#L128-L128 336 | 337 | "conda_pkgs": "awscli click coverage flake8 ipykernel python-dotenv>=0.5.1 sphinx" 338 | ``` 339 | 340 | > The default conda packages settings is the fallback if `environment.yml` is absent of the project 341 | > cookiecutter template. 342 | 343 | There are two configurable options for the project template when using the conda integration: 344 | 345 | - `editable_install`: If True, the project folder will be installed in editable mode using `pip` in the conda environment (default: True) 346 | - `filter_kernel`: If True, the kernel manager [whitelist](https://jupyter-notebook.readthedocs.io/en/stable/search.html?q=whitelist&check_keywords=yes&area=default) 347 | will be set dynamically to the one of the project environment 348 | kernel (i.e. only that kernel will be available when the project is opened) (default: True). 349 | 350 | #### Git integration 351 | 352 | If the [`jupyterlab-git`](https://github.com/jupyterlab/jupyterlab-git) optional extension is installed, the following features/behaviors are to be expected: 353 | 354 | - When creating a project, it will be initialized as a git repository and a first commit with all produced files will be carried out. 355 | - When the git HEAD changes (branch changes, pull action,...), the conda environment will be updated if the `environment.yml` file changed. 356 | 357 | ### Full configuration 358 | 359 | Here is the description of all server extension settings: 360 | 361 | ```json 362 | { 363 | "JupyterProject": { 364 | "file_templates": { 365 | "description": "List of file template loaders", 366 | "type": "array", 367 | "items": { 368 | "description": , 369 | "type": "object", 370 | "properties": { 371 | "location": { 372 | "description": "Templates path", 373 | "type": "string" 374 | }, 375 | "module": { 376 | "description": "Python package containing the templates 'location' [optional]", 377 | "type": "string" 378 | }, 379 | "name": { 380 | "description": "Templates group name", 381 | "type": "string" 382 | }, 383 | "files": { 384 | "description": "List of template files", 385 | "type": "array", 386 | "minItems": 1, 387 | "items": { 388 | "type": "object", 389 | "properties": { 390 | "default_name": { 391 | "description": "Default file name (without extension; support Jinja2 templating using the schema parameters)", 392 | "default": "Untitled", 393 | "type": "string" 394 | }, 395 | "destination": { 396 | "description": "Relative destination folder [optional]", 397 | "type": "string" 398 | }, 399 | "icon": { 400 | "description": "Template icon to display in the frontend [optional]", 401 | "default": null, 402 | "type": "string" 403 | }, 404 | "schema": { 405 | "description": "JSON schema list describing the templates parameters [optional]", 406 | "type": "object" 407 | }, 408 | "template": { 409 | "description": "Template path", 410 | "type": "string" 411 | }, 412 | "template_name" : { 413 | "description": "Template name in the UI [optional]", 414 | "type": "string" 415 | } 416 | }, 417 | "required": ["template"] 418 | } 419 | } 420 | }, 421 | "required": ["files", "location", "name"] 422 | } 423 | }, 424 | "project_template": { 425 | "description": "The project template options", 426 | "type": "object", 427 | "properties": { 428 | "configuration_filename": { 429 | "description": "Name of the project configuration JSON file [optional]", 430 | "default": "jupyter-project.json", 431 | "type": "string" 432 | }, 433 | "configuration_schema": { 434 | "description": "JSON schema describing the project configuration file [optional]", 435 | "default": { 436 | "type": "object", 437 | "properties": {"name": {"type": "string"}}, 438 | "required": ["name"], 439 | }, 440 | "type": "object" 441 | }, 442 | "conda_pkgs": { 443 | "default": null, 444 | "description": "Type of conda environment or space separated list of conda packages (requires `jupyter_conda`) [optional]", 445 | "type": "string" 446 | }, 447 | "default_path": { 448 | "description": "Default file or folder to open; relative to the project root [optional]", 449 | "type": "string" 450 | }, 451 | "editable_install": { 452 | "description": "Should the project be installed in pip editable mode in the conda environment?", 453 | "type": "boolean", 454 | "default": true 455 | }, 456 | "filter_kernel": { 457 | "description": "Should the kernel be filtered to match only the conda environment?", 458 | "type": "boolean", 459 | "default": true 460 | }, 461 | "folder_name": { 462 | "description": "Project name (support Jinja2 templating using the schema parameters) [optional]", 463 | "default": "{{ name|lower|replace(' ', '_') }}", 464 | "type": "string" 465 | }, 466 | "module": { 467 | "description": "Python package containing the template [optional]", 468 | "type": "string" 469 | }, 470 | "schema": { 471 | "description": "JSON schema describing the template parameters [optional]", 472 | "default": { 473 | "type": "object", 474 | "properties": {"name": {"type": "string", "pattern": "^[a-zA-Z_]\\w*$"}}, 475 | "required": ["name"], 476 | }, 477 | "type": "object" 478 | }, 479 | "template": { 480 | "description": "Cookiecutter template source", 481 | "default": null, 482 | "type": "string" 483 | } 484 | }, 485 | "required": ["template"] 486 | } 487 | } 488 | } 489 | ``` 490 | 491 | ## Troubleshoot 492 | 493 | If you are seeing the frontend extension but it is not working, check 494 | that the server extension is enabled: 495 | 496 | ```bash 497 | jupyter serverextension list 498 | ``` 499 | 500 | If the server extension is installed and enabled but you are not seeing 501 | the frontend, check the frontend is installed: 502 | 503 | ```bash 504 | jupyter labextension list 505 | ``` 506 | 507 | If it is installed, try: 508 | 509 | ```bash 510 | jupyter lab clean 511 | jupyter lab build 512 | ``` 513 | 514 | ## Contributing 515 | 516 | The frontend extension is based on [uniforms](https://uniforms.tools/) with its 517 | [material-ui](https://material-ui.com/) flavor to handle and display automatic 518 | forms from JSON schema. 519 | 520 | ### Install 521 | 522 | The `jlpm` command is JupyterLab's pinned version of 523 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 524 | `yarn` or `npm` in lieu of `jlpm` below. 525 | 526 | ```bash 527 | # Clone the repo to your local environment 528 | # Move to jupyter-project directory 529 | 530 | # Install server extension 531 | pip install -e .[test] 532 | # Register server extension 533 | jupyter serverextension enable --py jupyter_project 534 | 535 | # Install dependencies 536 | jlpm 537 | # Build Typescript source 538 | jlpm build 539 | # Link your development version of the extension with JupyterLab 540 | jupyter labextension link . 541 | # Rebuild Typescript source after making changes 542 | jlpm build 543 | # Rebuild JupyterLab after making any changes 544 | jupyter lab build 545 | ``` 546 | 547 | You can watch the source directory and run JupyterLab in watch mode to watch for changes in the extension's source and automatically rebuild the extension and application. 548 | 549 | ```bash 550 | # Watch the source directory in another terminal tab 551 | jlpm watch 552 | # Run jupyterlab in watch mode in one terminal tab 553 | jupyter lab --watch 554 | ``` 555 | 556 | > To run with an working example, execute `jupyter lab` from the binder folder to use the local `jupyter_notebook_config.json` as configuration. 557 | 558 | ## Uninstall 559 | 560 | With pip: 561 | 562 | ```bash 563 | pip uninstall jupyter-project 564 | jupyter labextension uninstall jupyter-project 565 | ``` 566 | 567 | Or with pip: 568 | 569 | ```bash 570 | conda remove jupyter-project 571 | jupyter labextension uninstall jupyter-project 572 | ``` 573 | 574 | ## Alternatives 575 | 576 | Don't like what you see here? Try these other approaches: 577 | 578 | - [jupyterlab-starters](https://github.com/deathbeds/jupyterlab-starters) 579 | - [jupyterlab_templates](https://github.com/timkpaine/jupyterlab_templates) 580 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("@jupyterlab/testutils/lib/babel.config"); 2 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | name: jupyter-project 2 | channels: 3 | - conda-forge 4 | - defaults 5 | dependencies: 6 | - cookiecutter 7 | - jinja2 ~=2.9 8 | - jsonschema 9 | - jupyter_conda ~=3.3 10 | - jupyterlab ~=1.2 11 | - jupyterlab-git >=0.10,<0.20 12 | - nodejs ~=12.0 13 | - python ~=3.8 14 | -------------------------------------------------------------------------------- /binder/jupyter_notebook_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyter_project": true 5 | } 6 | }, 7 | "JupyterProject": { 8 | "file_templates": [ 9 | { 10 | "name": "data-sciences", 11 | "module": "jupyter_project", 12 | "location": "examples", 13 | "files": [ 14 | { 15 | "template": "demo.ipynb" 16 | }, 17 | { 18 | "destination": "notebooks", 19 | "icon": " ", 20 | "template_name": "Example", 21 | "template": "example.ipynb", 22 | "schema": { 23 | "type": "object", 24 | "properties": { 25 | "exampleBoolean": { 26 | "default": false, 27 | "title": "A choice", 28 | "type": "boolean" 29 | }, 30 | "exampleList": { 31 | "default": [1, 2, 3], 32 | "title": "A list of number", 33 | "type": "array", 34 | "items": { 35 | "default": 0, 36 | "type": "number" 37 | } 38 | }, 39 | "exampleNumber": { 40 | "default": 42, 41 | "title": "A number", 42 | "type": "number", 43 | "minimum": 0, 44 | "maximum": 100 45 | }, 46 | "exampleObject": { 47 | "default": { 48 | "number": 1, 49 | "street_name": "Dog", 50 | "street_type": "Street" 51 | }, 52 | "title": "A object", 53 | "type": "object", 54 | "properties": { 55 | "number": { "type": "integer" }, 56 | "street_name": { "type": "string" }, 57 | "street_type": { 58 | "type": "string", 59 | "enum": ["Street", "Avenue", "Boulevard"] 60 | } 61 | }, 62 | "required": ["number"] 63 | }, 64 | "exampleString": { 65 | "default": "I_m_Beautiful", 66 | "title": "A string", 67 | "type": "string", 68 | "pattern": "^[a-zA-Z_]\\w*$" 69 | } 70 | }, 71 | "required": ["exampleString"] 72 | } 73 | }, 74 | { 75 | "default_name": "{{ modelName }}", 76 | "destination": "src/models", 77 | "schema": { 78 | "type": "object", 79 | "properties": { 80 | "authorName": { 81 | "type": "string" 82 | }, 83 | "modelName": { 84 | "type": "string", 85 | "pattern": "^[a-zA-Z_]\\w*$" 86 | } 87 | }, 88 | "required": ["modelName"] 89 | }, 90 | "template_name": "Train Model", 91 | "template": "train_model.py" 92 | } 93 | ] 94 | } 95 | ], 96 | "project_template": { 97 | "template": "https://github.com/drivendata/cookiecutter-data-science", 98 | "schema": { 99 | "type": "object", 100 | "properties": { 101 | "project_name": { 102 | "type": "string", 103 | "default": "Project Name" 104 | }, 105 | "repo_name": { 106 | "title": "Folder name", 107 | "type": "string", 108 | "pattern": "^[a-zA-Z_]\\w*$", 109 | "default": "project_name" 110 | }, 111 | "author_name": { 112 | "type": "string", 113 | "description": "Your name (or your organization/company/team)" 114 | }, 115 | "description": { 116 | "type": "string", 117 | "description": "A short description of the project." 118 | }, 119 | "open_source_license": { 120 | "type": "string", 121 | "enum": ["MIT", "BSD-3-Clause", "No license file"] 122 | } 123 | }, 124 | "required": ["project_name", "repo_name"] 125 | }, 126 | "folder_name": "{{ repo_name }}", 127 | "default_path": "README.md", 128 | "conda_pkgs": "awscli click coverage flake8 ipykernel python-dotenv>=0.5.1 sphinx" 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Binder postBuild instructions 4 | # 5 | # Note: binder is launching jupyter server in the code repository folder 6 | # Therefore the `jupyter_notebook_config.json` will be picked automatically 7 | # as server configuration 8 | # 9 | set -eux 10 | # python -m pip install . --ignore-installed --no-deps 11 | python -m pip install jupyter-project~=1.0 --ignore-installed --no-deps 12 | jupyter labextension install jupyterlab_conda --no-build 13 | jupyter lab build --minimize=True --dev-build=False --debug 14 | cp -f ./binder/jupyter_notebook_config.json . 15 | -------------------------------------------------------------------------------- /doc/preview-jupyter-project.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcollonval/jupyter-project/c6dea3730c8d7fe32964c2e1b0454b608f799cba/doc/preview-jupyter-project.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config'); 2 | 3 | const jlabConfig = jestJupyterLab('jupyter-project', __dirname); 4 | 5 | const { 6 | coverageDirectory, 7 | moduleFileExtensions, 8 | moduleNameMapper, 9 | preset, 10 | setupFilesAfterEnv, 11 | setupFiles, 12 | testPathIgnorePatterns, 13 | transform 14 | } = jlabConfig; 15 | 16 | module.exports = { 17 | coverageDirectory, 18 | moduleFileExtensions, 19 | moduleNameMapper, 20 | preset, 21 | setupFilesAfterEnv, 22 | setupFiles, 23 | testPathIgnorePatterns, 24 | transform, 25 | automock: false, 26 | collectCoverageFrom: ['src/**.{ts,tsx}', '!src/*.d.ts'], 27 | coverageReporters: ['lcov', 'text'], 28 | globals: { 29 | 'ts-jest': { 30 | tsConfig: `./tsconfig.json` 31 | } 32 | }, 33 | reporters: ['default'], 34 | testRegex: 'src/.*/.*.spec.ts[x]?$', 35 | transformIgnorePatterns: ['/node_modules/(?!(@?jupyterlab.*)/)'] 36 | }; 37 | -------------------------------------------------------------------------------- /jupyter-config/jupyter_project.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyter_project": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter_notebook_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyter_project": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter_project/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | from .config import JupyterProject 3 | from .handlers import setup_handlers 4 | 5 | 6 | def _jupyter_server_extension_paths(): 7 | return [{"module": "jupyter_project"}] 8 | 9 | 10 | def load_jupyter_server_extension(lab_app): 11 | """Registers the API handler to receive HTTP requests from the frontend extension. 12 | 13 | Parameters 14 | ---------- 15 | lab_app: jupyterlab.labapp.LabApp 16 | JupyterLab application instance 17 | """ 18 | config = JupyterProject(config=lab_app.config) 19 | setup_handlers(lab_app.web_app, config, lab_app.log) 20 | lab_app.log.info( 21 | "Registered jupyter_project extension at URL path /jupyter-project" 22 | ) 23 | -------------------------------------------------------------------------------- /jupyter_project/_version.py: -------------------------------------------------------------------------------- 1 | version_info = (2, 0, 0) 2 | __version__ = ".".join(map(str, version_info)) + "rc1" 3 | -------------------------------------------------------------------------------- /jupyter_project/autoinstance.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dynamically build instance from dictionary 3 | 4 | Reference: 5 | https://github.com/ipython/traitlets/pull/466#issuecomment-369971116 6 | """ 7 | from traitlets import HasTraits, Instance 8 | from traitlets.config import Configurable 9 | 10 | 11 | class AutoInstance(Instance): 12 | """Dynamically build instance from dictionary""" 13 | 14 | def validate(self, obj, value): 15 | iclass = self.klass 16 | 17 | if (issubclass(iclass, HasTraits) and isinstance(value, dict)): 18 | if issubclass(iclass, Configurable): 19 | value = iclass(parent=obj, **value) 20 | else: 21 | value = iclass(**value) 22 | 23 | return super().validate(obj, value) 24 | -------------------------------------------------------------------------------- /jupyter_project/config.py: -------------------------------------------------------------------------------- 1 | from traitlets import List, Unicode 2 | from traitlets.config import Configurable 3 | 4 | from .autoinstance import AutoInstance 5 | from .files import FileTemplateLoader 6 | from .project import ProjectTemplate 7 | 8 | 9 | class JupyterProject(Configurable): 10 | """Configuration for jupyter-project server extension.""" 11 | 12 | file_templates = List( 13 | default_value=list(), 14 | trait=AutoInstance(FileTemplateLoader), 15 | help="List of file template loaders", 16 | config=True, 17 | ) 18 | 19 | project_template = AutoInstance( 20 | ProjectTemplate, 21 | allow_none=True, 22 | help="The project template options", 23 | config=True, 24 | ) 25 | -------------------------------------------------------------------------------- /jupyter_project/examples/demo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Output Examples" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "This notebook is designed to provide examples of different types of outputs that can be used to test the JupyterLab frontend and other Jupyter frontends." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "from IPython.display import display\n", 24 | "from IPython.display import HTML, Image, Latex, Math, Markdown, SVG" 25 | ] 26 | }, 27 | { 28 | "cell_type": "markdown", 29 | "metadata": {}, 30 | "source": [ 31 | "## Text" 32 | ] 33 | }, 34 | { 35 | "cell_type": "markdown", 36 | "metadata": {}, 37 | "source": [ 38 | "Plain text:" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": null, 44 | "metadata": {}, 45 | "outputs": [], 46 | "source": [ 47 | "text = \"\"\"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam urna\n", 48 | "libero, dictum a egestas non, placerat vel neque. In imperdiet iaculis fermentum. \n", 49 | "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia \n", 50 | "Curae; Cras augue tortor, tristique vitae varius nec, dictum eu lectus. Pellentesque \n", 51 | "id eleifend eros. In non odio in lorem iaculis sollicitudin. In faucibus ante ut \n", 52 | "arcu fringilla interdum. Maecenas elit nulla, imperdiet nec blandit et, consequat \n", 53 | "ut elit.\"\"\"\n", 54 | "print(text)" 55 | ] 56 | }, 57 | { 58 | "cell_type": "markdown", 59 | "metadata": {}, 60 | "source": [ 61 | "Text as output:\n", 62 | "\n", 63 | "\n", 64 | "\n" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": null, 70 | "metadata": {}, 71 | "outputs": [], 72 | "source": [ 73 | "text" 74 | ] 75 | }, 76 | { 77 | "cell_type": "markdown", 78 | "metadata": {}, 79 | "source": [ 80 | "Standard error:" 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "metadata": {}, 87 | "outputs": [], 88 | "source": [ 89 | "import sys; print('this is stderr', file=sys.stderr)" 90 | ] 91 | }, 92 | { 93 | "cell_type": "markdown", 94 | "metadata": {}, 95 | "source": [ 96 | "## HTML" 97 | ] 98 | }, 99 | { 100 | "cell_type": "code", 101 | "execution_count": null, 102 | "metadata": {}, 103 | "outputs": [], 104 | "source": [ 105 | "div = HTML('
')\n", 106 | "div" 107 | ] 108 | }, 109 | { 110 | "cell_type": "code", 111 | "execution_count": null, 112 | "metadata": {}, 113 | "outputs": [], 114 | "source": [ 115 | "for i in range(3):\n", 116 | " print(10**10)\n", 117 | " display(div)" 118 | ] 119 | }, 120 | { 121 | "cell_type": "markdown", 122 | "metadata": {}, 123 | "source": [ 124 | "## Markdown" 125 | ] 126 | }, 127 | { 128 | "cell_type": "code", 129 | "execution_count": null, 130 | "metadata": {}, 131 | "outputs": [], 132 | "source": [ 133 | "md = Markdown(\"\"\"\n", 134 | "### Subtitle\n", 135 | "\n", 136 | "This is some *markdown* text with math $F=ma$.\n", 137 | "\n", 138 | "\"\"\")\n", 139 | "md" 140 | ] 141 | }, 142 | { 143 | "cell_type": "code", 144 | "execution_count": null, 145 | "metadata": {}, 146 | "outputs": [], 147 | "source": [ 148 | "display(md)" 149 | ] 150 | }, 151 | { 152 | "cell_type": "markdown", 153 | "metadata": {}, 154 | "source": [ 155 | "## LaTeX" 156 | ] 157 | }, 158 | { 159 | "cell_type": "markdown", 160 | "metadata": {}, 161 | "source": [ 162 | "Examples LaTeX in a markdown cell:" 163 | ] 164 | }, 165 | { 166 | "cell_type": "markdown", 167 | "metadata": {}, 168 | "source": [ 169 | "\\begin{align}\n", 170 | "\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} & = \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\\\ \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n", 171 | "\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n", 172 | "\\nabla \\cdot \\vec{\\mathbf{B}} & = 0\n", 173 | "\\end{align}" 174 | ] 175 | }, 176 | { 177 | "cell_type": "code", 178 | "execution_count": null, 179 | "metadata": {}, 180 | "outputs": [], 181 | "source": [ 182 | "math = Latex(\"$F=ma$\")\n", 183 | "math" 184 | ] 185 | }, 186 | { 187 | "cell_type": "code", 188 | "execution_count": null, 189 | "metadata": {}, 190 | "outputs": [], 191 | "source": [ 192 | "maxwells = Latex(r\"\"\"\n", 193 | "\\begin{align}\n", 194 | "\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} & = \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\\\ \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n", 195 | "\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n", 196 | "\\nabla \\cdot \\vec{\\mathbf{B}} & = 0\n", 197 | "\\end{align}\n", 198 | "\"\"\")\n", 199 | "maxwells" 200 | ] 201 | }, 202 | { 203 | "cell_type": "markdown", 204 | "metadata": {}, 205 | "source": [ 206 | "## Image" 207 | ] 208 | }, 209 | { 210 | "cell_type": "code", 211 | "execution_count": null, 212 | "metadata": {}, 213 | "outputs": [], 214 | "source": [ 215 | "img = Image(\"https://apod.nasa.gov/apod/image/1707/GreatWallMilkyWay_Yu_1686.jpg\")\n", 216 | "img" 217 | ] 218 | }, 219 | { 220 | "cell_type": "markdown", 221 | "metadata": {}, 222 | "source": [ 223 | "Set the image metadata:" 224 | ] 225 | }, 226 | { 227 | "cell_type": "code", 228 | "execution_count": null, 229 | "metadata": {}, 230 | "outputs": [], 231 | "source": [ 232 | "img2 = Image(\n", 233 | " \"https://apod.nasa.gov/apod/image/1707/GreatWallMilkyWay_Yu_1686.jpg\",\n", 234 | " width=100,\n", 235 | " height=200\n", 236 | ")\n", 237 | "img2" 238 | ] 239 | }, 240 | { 241 | "cell_type": "markdown", 242 | "metadata": {}, 243 | "source": [ 244 | "## SVG" 245 | ] 246 | }, 247 | { 248 | "cell_type": "code", 249 | "execution_count": null, 250 | "metadata": {}, 251 | "outputs": [], 252 | "source": [ 253 | "svg_source = \"\"\"\n", 254 | "\n", 255 | " \n", 256 | "\n", 257 | "\"\"\"\n", 258 | "svg = SVG(svg_source)\n", 259 | "svg" 260 | ] 261 | }, 262 | { 263 | "cell_type": "code", 264 | "execution_count": null, 265 | "metadata": {}, 266 | "outputs": [], 267 | "source": [ 268 | "for i in range(3):\n", 269 | " print(10**10)\n", 270 | " display(svg)" 271 | ] 272 | }, 273 | { 274 | "cell_type": "code", 275 | "execution_count": null, 276 | "metadata": {}, 277 | "outputs": [], 278 | "source": [] 279 | } 280 | ], 281 | "metadata": { 282 | "language_info": { 283 | "name": "python" 284 | } 285 | }, 286 | "nbformat": 4, 287 | "nbformat_minor": 4 288 | } 289 | -------------------------------------------------------------------------------- /jupyter_project/examples/example.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Examples of parameters{% if jproject %} in {{jproject.name}}{% endif %}" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "choice = {{ exampleBoolean }}\n", 17 | "assert isinstance(choice, bool)" 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": null, 23 | "metadata": {}, 24 | "outputs": [], 25 | "source": [ 26 | "list_ = {{ exampleList }}\n", 27 | "assert isinstance(list_, list)" 28 | ] 29 | }, 30 | { 31 | "cell_type": "code", 32 | "execution_count": null, 33 | "metadata": {}, 34 | "outputs": [], 35 | "source": [ 36 | "number = {{ exampleNumber }}\n", 37 | "assert isinstance(number, (int, float))" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": null, 43 | "metadata": {}, 44 | "outputs": [], 45 | "source": [ 46 | "obj = {{ exampleObject }}\n", 47 | "assert isinstance(obj, dict)" 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": null, 53 | "metadata": {}, 54 | "outputs": [], 55 | "source": [ 56 | "string = '{{ exampleString }}'\n", 57 | "assert isinstance(string, str)" 58 | ] 59 | } 60 | ], 61 | "metadata": { 62 | "language_info": { 63 | "codemirror_mode": { 64 | "name": "ipython", 65 | "version": 3 66 | }, 67 | "file_extension": ".py", 68 | "mimetype": "text/x-python", 69 | "name": "python", 70 | "nbconvert_exporter": "python", 71 | "pygments_lexer": "ipython3", 72 | "version": 3 73 | } 74 | }, 75 | "nbformat": 4, 76 | "nbformat_minor": 2 77 | } -------------------------------------------------------------------------------- /jupyter_project/examples/train_model.py: -------------------------------------------------------------------------------- 1 | """This script train the {{ modelName }} model""" 2 | 3 | __author__ = "{{ authorName }}" 4 | 5 | 6 | def train(data): 7 | """Train the {{ modelName }} model.""" 8 | pass 9 | -------------------------------------------------------------------------------- /jupyter_project/files.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple regex validation for an SVG string taken from: 3 | https://github.com/sindresorhus/is-svg 4 | 5 | MIT License 6 | 7 | Copyright (c) Sindre Sorhus (sindresorhus.com) 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | """ 15 | import pathlib 16 | import re 17 | from typing import Dict 18 | 19 | from traitlets import HasTraits, List, TraitError, Unicode, validate 20 | from traitlets.utils.bunch import Bunch 21 | 22 | from .autoinstance import AutoInstance 23 | from .traits import JSONSchema, Path 24 | 25 | 26 | SVG_PATTERN = re.compile( 27 | r"^\s*(?:<\?xml[^>]*>\s*)?(?:]*\s*(?:\[?(?:\s*]*>\s*)*\]?)*[^>]*>\s*)?(?:]*>.*<\/svg>|]*\/\s*>)\s*$", 28 | re.IGNORECASE | re.DOTALL, 29 | ) 30 | 31 | 32 | class FileTemplate(HasTraits): 33 | """Jinja2 file template class.""" 34 | 35 | default_name = Unicode( 36 | default_value="Untitled", 37 | help="Default file name (without extension; support Jinja2 templating using the schema parameters)", 38 | config=True, 39 | ) 40 | destination = Path(help="Relative destination folder [optional]", config=True) 41 | icon = Unicode( 42 | default_value=None, 43 | allow_none=True, 44 | help="Template icon to display in the frontend [optional]", 45 | config=True, 46 | ) 47 | schema = JSONSchema( 48 | help="JSON schema list describing the templates parameters [optional]", config=True 49 | ) 50 | template = Path(help="Template path", config=True) 51 | template_name = Unicode(help="Template name in the UI [optional]", config=True) 52 | 53 | def __init__(self, *args, **kwargs): 54 | super().__init__(*args, **kwargs) 55 | # Force checking the default value as they are not valid 56 | self._valid_template({"value": self.template}) 57 | 58 | def __eq__(self, other: "FileTemplate") -> bool: 59 | if self is other: 60 | return True 61 | 62 | for attr in ("default_name", "destination", "schema", "template"): 63 | if getattr(self, attr) != getattr(other, attr): 64 | return False 65 | return True 66 | 67 | @validate("default_name") 68 | def _valid_default_name(self, proposal: Bunch) -> str: 69 | if len(proposal["value"]) == 0: 70 | raise TraitError("'default_name' cannot be empty.") 71 | return proposal["value"] 72 | 73 | @validate("icon") 74 | def _valid_icon(self, proposal: Bunch) -> str: 75 | if proposal["value"] is not None: 76 | if SVG_PATTERN.match(proposal["value"]) is None: 77 | raise TraitError("'icon' is not a valid SVG.") 78 | return proposal["value"] 79 | 80 | @validate("template") 81 | def _valid_template(self, proposal: Bunch) -> str: 82 | if proposal["value"] == pathlib.Path(""): 83 | raise TraitError("'template' cannot be empty.") 84 | return proposal["value"] 85 | 86 | 87 | class FileTemplateLoader(HasTraits): 88 | """Jinja2 template file location class.""" 89 | 90 | files = List( 91 | trait=AutoInstance(FileTemplate), 92 | minlen=1, 93 | help="List of template files", 94 | config=True, 95 | ) 96 | location = Unicode(help="Templates path", config=True) 97 | module = Unicode( 98 | help="Python package containing the templates 'location' [optional]", 99 | config=True, 100 | ) 101 | name = Unicode(help="Templates group name", config=True) 102 | 103 | def __init__(self, *args, **kwargs): 104 | super().__init__(*args, **kwargs) 105 | # Force checking the default value as they are not valid 106 | self._valid_name({"value": self.name}) 107 | self._valid_location({"value": self.location}) 108 | if len(self.files) == 0: 109 | raise TraitError("'files' cannot be empty.") 110 | 111 | def __eq__(self, other: "FileTemplateLoader") -> bool: 112 | for attr in ("files", "location", "module", "name"): 113 | if getattr(self, attr) != getattr(other, attr): 114 | return False 115 | return True 116 | 117 | @validate("name") 118 | def _valid_name(self, proposal: Bunch) -> str: 119 | if len(proposal["value"]) == 0: 120 | raise TraitError("'name' cannot be empty.") 121 | return proposal["value"] 122 | 123 | @validate("location") 124 | def _valid_location(self, proposal: Bunch) -> str: 125 | if len(proposal["value"]) == 0: 126 | raise TraitError("'location' cannot be empty.") 127 | return proposal["value"] 128 | -------------------------------------------------------------------------------- /jupyter_project/handlers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import logging 4 | from pathlib import Path 5 | from shutil import rmtree 6 | from typing import Any, Dict 7 | from urllib.parse import quote 8 | 9 | from cookiecutter.exceptions import CookiecutterException 10 | from jinja2 import ( 11 | Environment, 12 | FileSystemLoader, 13 | PackageLoader, 14 | PrefixLoader, 15 | Template, 16 | TemplateError, 17 | ) 18 | from jsonschema.exceptions import ValidationError 19 | from jupyter_client.jsonutil import date_default 20 | from notebook.base.handlers import APIHandler, path_regex 21 | from notebook.utils import url_path_join, url2path 22 | import tornado 23 | 24 | from .config import JupyterProject, ProjectTemplate 25 | from .jinja2 import jinja2_extensions 26 | 27 | NAMESPACE = "jupyter-project" 28 | 29 | 30 | class FileTemplatesHandler(APIHandler): 31 | """Handler for generating file from templates.""" 32 | 33 | def initialize(self, default_name: str = None, template: Template = None): 34 | """Initialize request handler 35 | 36 | Args: 37 | default_name (str): File default name - will be rendered with same parameters than template 38 | template (jinja2.Template): Jinja2 template to use for component generation. 39 | """ 40 | self.default_name = Template( 41 | default_name or "Untitled", extensions=jinja2_extensions 42 | ) 43 | self.template = template 44 | 45 | @tornado.web.authenticated 46 | async def post(self, path: str = ""): 47 | """Create a new file in the specified path. 48 | 49 | POST /jupyter-project/files/ 50 | Creates a new file applying the parameters to the Jinja template. 51 | 52 | Request json body: 53 | Dictionary of parameters for the Jinja template. 54 | """ 55 | if self.template is None: 56 | raise tornado.web.HTTPError(404, reason="File Jinja template not found.") 57 | 58 | cm = self.contents_manager 59 | params = self.get_json_body() 60 | 61 | try: 62 | default_name = self.default_name.render(**params) 63 | except TemplateError as error: 64 | self.log.warning( 65 | f"Fail to render the default name for template '{self.template.name}'" 66 | ) 67 | default_name = cm.untitled_file 68 | 69 | ext = "".join(Path(self.template.name).suffixes) 70 | filename = default_name + ext 71 | filename = cm.increment_filename(filename, path) 72 | fullpath = url_path_join(path, filename) 73 | 74 | realpath = Path(cm.root_dir).absolute() / url2path(fullpath) 75 | if not realpath.parent.exists(): 76 | realpath.parent.mkdir(parents=True) 77 | 78 | current_loop = tornado.ioloop.IOLoop.current() 79 | try: 80 | content = await current_loop.run_in_executor( 81 | None, functools.partial(self.template.render, **params) 82 | ) 83 | realpath.write_text(content) 84 | except (OSError, TemplateError) as error: 85 | raise tornado.web.HTTPError( 86 | 500, 87 | log_message=f"Fail to generate the file from template {self.template.name}.", 88 | reason=repr(error), 89 | ) 90 | 91 | model = cm.get(fullpath, content=False, type="file", format="text") 92 | self.set_status(201) 93 | self.finish(json.dumps(model, default=date_default)) 94 | 95 | 96 | class ProjectsHandler(APIHandler): 97 | """Handler for project requests.""" 98 | 99 | def initialize(self, template: ProjectTemplate = None): 100 | """Initialize request handler 101 | 102 | Args: 103 | template (ProjectTemplate): Project template object. 104 | """ 105 | self.template = template 106 | 107 | def _get_realpath(self, path: str) -> Path: 108 | """Tranform notebook path to absolute path. 109 | 110 | Args: 111 | path (str): Path to be transformed 112 | 113 | Returns: 114 | Path: Absolute path 115 | """ 116 | return Path(self.contents_manager.root_dir).absolute() / url2path(path) 117 | 118 | @tornado.web.authenticated 119 | async def get(self, path: str = ""): 120 | """Open a specific project or close any open once if path is empty. 121 | 122 | GET /jupyter-project/projects/ 123 | Open the project in the given path 124 | 125 | Answer json body: 126 | { 127 | project: Project configuration file content 128 | } 129 | 130 | GET /jupyter-project/projects 131 | Close any opened project 132 | 133 | Answer json body: 134 | { 135 | project: null 136 | } 137 | """ 138 | if self.template is None: 139 | raise tornado.web.HTTPError( 140 | 404, reason="Project cookiecutter template not found." 141 | ) 142 | 143 | configuration = None 144 | if len(path) != 0: 145 | configuration = dict() 146 | fullpath = self._get_realpath(path) 147 | # Check that the path is a project 148 | current_loop = tornado.ioloop.IOLoop.current() 149 | try: 150 | configuration = await current_loop.run_in_executor( 151 | None, self.template.get_configuration, fullpath 152 | ) 153 | except (ValidationError, ValueError): 154 | raise tornado.web.HTTPError( 155 | 404, reason=f"Path {path} is not a valid project" 156 | ) 157 | else: 158 | configuration["path"] = path 159 | 160 | if self.template.conda_pkgs is not None and self.template.filter_kernel: 161 | if len(path) == 0: 162 | # Close the current open project 163 | self.log.debug(f"[jupyter-project] Clear Kernel whitelist") 164 | self.kernel_spec_manager.whitelist = set() 165 | elif "environment" in configuration: 166 | # Trick nb_conda_kernels to for refreshing the spec 167 | try: 168 | self.kernel_spec_manager._conda_kernels_cache_expiry = None 169 | self.kernel_spec_manager._conda_info_cache_expiry = None 170 | except AttributeError: 171 | pass 172 | kernelspecs = self.kernel_spec_manager.get_all_specs() 173 | kernels = {n for n, s in kernelspecs.items() if s["spec"]["metadata"].get("conda_env_name") == configuration["environment"]} 174 | self.log.debug(f"[jupyter-project] Set Kernel whitelist to {kernels}") 175 | self.kernel_spec_manager.whitelist = kernels 176 | 177 | self.finish(json.dumps({"project": configuration})) 178 | 179 | @tornado.web.authenticated 180 | async def post(self, path: str = ""): 181 | """Create a new project in the provided path. 182 | 183 | POST /jupyter-project/projects/ 184 | Create a new project in the given path 185 | 186 | Request json body: 187 | Parameters dictionary for the cookiecutter template 188 | 189 | Answer json body: 190 | { 191 | project: Project configuration file content 192 | } 193 | """ 194 | if self.template is None: 195 | raise tornado.web.HTTPError( 196 | 404, reason="Project cookiecutter template not found." 197 | ) 198 | 199 | params = self.get_json_body() 200 | 201 | realpath = self._get_realpath(path) 202 | if not realpath.parent.exists(): 203 | realpath.parent.mkdir(parents=True) 204 | 205 | current_loop = tornado.ioloop.IOLoop.current() 206 | try: 207 | folder_name, configuration = await current_loop.run_in_executor( 208 | None, self.template.render, params, realpath 209 | ) 210 | except (CookiecutterException, OSError, ValueError) as error: 211 | raise tornado.web.HTTPError( 212 | 500, 213 | log_message=f"Fail to generate the project from the cookiecutter template.", 214 | reason=repr(error), 215 | ) 216 | except ValidationError as error: 217 | raise tornado.web.HTTPError( 218 | 500, 219 | log_message=f"Invalid default project configuration file.", 220 | reason=repr(error), 221 | ) 222 | else: 223 | configuration["path"] = url_path_join(path, folder_name) 224 | 225 | self.set_status(201) 226 | self.finish(json.dumps({"project": configuration})) 227 | 228 | @tornado.web.authenticated 229 | async def delete(self, path: str = ""): 230 | """Delete the project at the given path. 231 | 232 | DELETE /jupyter-project/projects/ 233 | Delete the project 234 | """ 235 | if self.template is None: 236 | raise tornado.web.HTTPError( 237 | 404, reason="Project cookiecutter template not found." 238 | ) 239 | 240 | if len(path) == 0: 241 | self.finish(b"{}") 242 | return 243 | 244 | fullpath = self._get_realpath(path) 245 | # Check that the path is a project 246 | current_loop = tornado.ioloop.IOLoop.current() 247 | try: 248 | await current_loop.run_in_executor( 249 | None, self.template.get_configuration, fullpath 250 | ) 251 | except (ValidationError, ValueError): 252 | raise tornado.web.HTTPError( 253 | 404, reason=f"Path {path} is not a valid project" 254 | ) 255 | 256 | rmtree(fullpath, ignore_errors=True) 257 | 258 | self.set_status(204) 259 | 260 | 261 | class SettingsHandler(APIHandler): 262 | """Handler to get the extension server configuration.""" 263 | 264 | def initialize(self, project_settings: Dict[str, Any] = None): 265 | self.project_settings = project_settings or {} 266 | 267 | @tornado.web.authenticated 268 | def get(self): 269 | """Get the server extension settings. 270 | 271 | Return body: 272 | { 273 | fileTemplates: [ 274 | { 275 | destination: str | null, 276 | endpoint: str, 277 | icon: str | null, 278 | name: str, 279 | schema: JSONschema | null 280 | } 281 | ], 282 | projectTemplate: { 283 | configurationFilename: str, 284 | defaultCondaPackages: str | null, 285 | defaultPath: str | null, 286 | editableInstall: bool, 287 | schema: JSONschema | null, 288 | withGit: bool 289 | } 290 | } 291 | """ 292 | self.finish(json.dumps(self.project_settings)) 293 | 294 | 295 | def setup_handlers( 296 | web_app: "NotebookWebApplication", config: JupyterProject, logger: logging.Logger 297 | ): 298 | 299 | host_pattern = ".*$" 300 | 301 | base_url = url_path_join(web_app.settings["base_url"], NAMESPACE) 302 | handlers = list() 303 | 304 | # File templates 305 | list_templates = config.file_templates 306 | ## Create the loaders 307 | templates = dict() 308 | for template in list_templates: 309 | name = template.name 310 | if name in templates: 311 | logger.warning(f"Template '{name}' already exists; it will be ignored.") 312 | continue 313 | else: 314 | new_template = { 315 | "loader": None, 316 | "files": template.files, 317 | } 318 | location = Path(template.location) 319 | if location.exists() and location.is_dir(): 320 | new_template["loader"] = FileSystemLoader(str(location)) 321 | elif len(template.module) > 0: 322 | try: 323 | new_template["loader"] = PackageLoader( 324 | template.module, package_path=str(location) 325 | ) 326 | except ModuleNotFoundError: 327 | logger.warning(f"Unable to find module '{template.module}'") 328 | 329 | if new_template["loader"] is None: 330 | logger.warning(f"Unable to load templates '{name}'.") 331 | continue 332 | 333 | templates[name] = new_template 334 | 335 | env = Environment( 336 | loader=PrefixLoader({name: t["loader"] for name, t in templates.items()}), 337 | extensions=jinja2_extensions, 338 | ) 339 | 340 | ## Create the handlers 341 | file_settings = list() 342 | for name, template in templates.items(): 343 | filenames = set() 344 | for file in template["files"]: 345 | pfile = Path(file.template) 346 | suffixes = "".join(pfile.suffixes) 347 | short_name = pfile.as_posix()[: -(len(suffixes))] 348 | if short_name in filenames: 349 | logger.warning( 350 | f"Template '{name}/{pfile.as_posix()}' skipped as it has the same name than another template." 351 | ) 352 | continue 353 | filenames.add(short_name) 354 | 355 | endpoint = quote("/".join((name, short_name)), safe="") 356 | handlers.append( 357 | ( 358 | url_path_join( 359 | base_url, r"files/{:s}{:s}".format(endpoint, path_regex), 360 | ), 361 | FileTemplatesHandler, 362 | { 363 | "default_name": file.default_name, 364 | "template": env.get_template(f"{name}/{pfile.as_posix()}"), 365 | }, 366 | ) 367 | ) 368 | 369 | destination = ( 370 | None if file.destination == Path("") else file.destination.as_posix() 371 | ) 372 | 373 | file_settings.append( 374 | { 375 | "name": file.template_name or endpoint, 376 | "endpoint": endpoint, 377 | "destination": destination, 378 | "icon": file.icon, 379 | "schema": file.schema if len(file.schema) else None, 380 | } 381 | ) 382 | 383 | project_template = config.project_template 384 | if project_template is None or project_template.template is None: 385 | project_settings = None 386 | else: 387 | handlers.append( 388 | ( 389 | url_path_join(base_url, r"projects{:s}".format(path_regex)), 390 | ProjectsHandler, 391 | {"template": project_template}, 392 | ) 393 | ) 394 | 395 | default_path = ( 396 | None 397 | if project_template.default_path == Path("") 398 | else project_template.default_path.as_posix() 399 | ) 400 | project_settings = { 401 | "configurationFilename": project_template.configuration_filename, 402 | "defaultCondaPackages": project_template.conda_pkgs, 403 | "defaultPath": default_path, 404 | "editableInstall": project_template.editable_install, 405 | "schema": ( 406 | project_template.schema if len(project_template.schema) else None 407 | ), 408 | "withGit": True, # TODO make it configurable 409 | } 410 | 411 | handlers.append( 412 | ( 413 | url_path_join(base_url, "settings"), 414 | SettingsHandler, 415 | { 416 | "project_settings": { 417 | "fileTemplates": file_settings, 418 | "projectTemplate": project_settings, 419 | } 420 | }, 421 | ), 422 | ) 423 | 424 | web_app.add_handlers(host_pattern, handlers) 425 | -------------------------------------------------------------------------------- /jupyter_project/jinja2.py: -------------------------------------------------------------------------------- 1 | try: 2 | import jinja2_time 3 | except ImportError: 4 | jinja2_time = None # noqa 5 | 6 | jinja2_extensions = list() 7 | if jinja2_time is not None: 8 | jinja2_extensions.append("jinja2_time.TimeExtension") 9 | -------------------------------------------------------------------------------- /jupyter_project/project.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import json 3 | import logging 4 | import pathlib 5 | from typing import Any, Dict, NoReturn, Tuple 6 | 7 | from jinja2 import ( 8 | Template, 9 | TemplateError, 10 | ) 11 | import jsonschema 12 | from cookiecutter.main import cookiecutter 13 | from traitlets import Bool, HasTraits, TraitError, TraitType, Unicode, validate 14 | from traitlets.utils.bunch import Bunch 15 | 16 | from .jinja2 import jinja2_extensions 17 | from .traits import JSONSchema, Path 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class ProjectTemplate(HasTraits): 23 | """Jinja2 template project class.""" 24 | 25 | configuration_filename = Unicode( 26 | default_value="jupyter-project.json", 27 | help="Name of the project configuration JSON file [optional]", 28 | config=True, 29 | ) 30 | configuration_schema = JSONSchema( 31 | default_value={ 32 | "type": "object", 33 | "properties": {"name": {"type": "string"}}, 34 | "required": ["name"], 35 | }, 36 | help="JSON schema describing the project configuration file [optional]", 37 | config=True, 38 | ) 39 | conda_pkgs = Unicode( 40 | default_value=None, 41 | allow_none=True, 42 | help="Type of conda environment or space separated list of conda packages (requires `jupyter_conda`) [optional]", 43 | config=True 44 | ) 45 | default_path = Path( 46 | help="Default file or folder to open; relative to the project root [optional]", 47 | config=True, 48 | ) 49 | editable_install = Bool( 50 | default_value=True, 51 | help="Should the project be installed in pip editable mode in the conda environment?", 52 | config=True, 53 | ) 54 | filter_kernel = Bool( 55 | default_value=True, 56 | help="Should the kernel be filtered to match only the conda environment?", 57 | config=True, 58 | ) 59 | folder_name = Unicode( 60 | default_value="{{ name|lower|replace(' ', '_') }}", 61 | help="Project name (support Jinja2 templating using the schema parameters) [optional]", 62 | config=True, 63 | ) 64 | module = Unicode( 65 | help="Python package containing the template [optional]", config=True, 66 | ) 67 | schema = JSONSchema( 68 | default_value={ 69 | "type": "object", 70 | "properties": {"name": {"type": "string", "pattern": "^[a-zA-Z_]\\w*$"}}, 71 | "required": ["name"], 72 | }, 73 | help="JSON schema describing the template parameters [optional]", 74 | config=True, 75 | ) 76 | template = Unicode( 77 | default_value=None, 78 | allow_none=True, 79 | help="Cookiecutter template source", 80 | config=True, 81 | ) 82 | 83 | def __init__(self, *args, **kwargs): 84 | super().__init__(*args, **kwargs) 85 | # Force checking the default value as they are not valid 86 | self._valid_template({"value": self.template}) 87 | self._folder_name = Template(self.folder_name, extensions=jinja2_extensions) 88 | 89 | def __eq__(self, other: "ProjectTemplate") -> bool: 90 | if self is other: 91 | return True 92 | 93 | if other is None: # The first test passes if self == other == None 94 | return False 95 | 96 | for attr in ( 97 | "configuration_filename", 98 | "configuration_schema", 99 | "default_path", 100 | "editable_install", 101 | "filter_kernel", 102 | "folder_name", 103 | "module", 104 | "schema", 105 | "template", 106 | ): 107 | if getattr(self, attr) != getattr(other, attr): 108 | return False 109 | return True 110 | 111 | @validate("folder_name") 112 | def _valid_folder_name(self, proposal: Bunch) -> str: 113 | if len(proposal["value"]) == 0: 114 | raise TraitError("'folder_name' cannot be empty.") 115 | self._folder_name = Template(proposal["value"], extensions=jinja2_extensions) 116 | return proposal["value"] 117 | 118 | @validate("template") 119 | def _valid_template(self, proposal: Bunch) -> str: 120 | value = proposal["value"] 121 | if value is not None and len(value) == 0: 122 | raise TraitError("'template' cannot be empty.") 123 | return value 124 | 125 | def get_configuration(self, path: pathlib.Path) -> Dict: 126 | """Get and validate the project configuration in path. 127 | 128 | Args: 129 | path (pathlib.Path): Project folder 130 | 131 | Returns: 132 | dict: project configuration 133 | 134 | Raises: 135 | ValueError: if the project configuration file does not exists 136 | """ 137 | if len(self.configuration_filename) == 0 or self.template is None: 138 | return dict() 139 | 140 | configuration_file = path / self.configuration_filename 141 | if not configuration_file.exists(): 142 | raise ValueError("Configuration file does not exists.") 143 | configuration = json.loads(configuration_file.read_text()) 144 | if len(self.configuration_schema) > 0: 145 | jsonschema.validate(configuration, self.configuration_schema) 146 | 147 | return configuration 148 | 149 | def render(self, params: Dict, path: pathlib.Path) -> Tuple[str, Dict]: 150 | """Render the cookiecutter template. 151 | 152 | Args: 153 | params (Dict): Cookiecutter template parameters 154 | path (pathlib.Path): Path in which the project will be created 155 | 156 | Returns: 157 | Tuple[str, Dict]: (Project folder name, Project configuration) 158 | """ 159 | if self.template is None: 160 | return None, dict() 161 | 162 | try: 163 | folder_name = self._folder_name.render(**params) 164 | except TemplateError as error: 165 | raise ValueError("Project 'folder_name' cannot be rendered.") 166 | 167 | project_name = folder_name.replace("_", " ").capitalize() 168 | 169 | if len(self.module): 170 | module = importlib.import_module(self.module) 171 | template = str(pathlib.Path(module.__path__[0]) / self.template) 172 | else: 173 | template = self.template 174 | 175 | cookiecutter( 176 | template, no_input=True, extra_context=params, output_dir=str(path), 177 | ) 178 | 179 | content = {"name": project_name} 180 | if len(self.configuration_filename) > 0: 181 | configuration_file = path / folder_name / self.configuration_filename 182 | if configuration_file.exists(): 183 | try: 184 | content = json.loads(configuration_file.read_text()) 185 | except json.JSONDecodeError as error: 186 | logger.debug( 187 | f"Unable to load configuration file {configuration_file!s}:\n{error!s}" 188 | ) 189 | else: 190 | content["name"] = project_name 191 | configuration_file.parent.mkdir(parents=True, exist_ok=True) 192 | configuration_file.write_text(json.dumps(content)) 193 | 194 | content = self.get_configuration(configuration_file.parent) 195 | 196 | return folder_name, content 197 | -------------------------------------------------------------------------------- /jupyter_project/tests/test_autoinstance.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from traitlets import CInt, Int, HasTraits, List, default 4 | from traitlets.config import Config, Configurable 5 | 6 | from jupyter_project.autoinstance import AutoInstance 7 | 8 | 9 | class C(Configurable): 10 | i = CInt(config=True) 11 | 12 | class A(Configurable): 13 | a = List(AutoInstance(C), config=True) 14 | 15 | 16 | def test_smoke(): 17 | cfg = Config({"A": {"a": [{"i": 1}, {}, {"i": 2}]}}) 18 | a = A(config=cfg) 19 | assert [e.i for e in a.a] == [1, 0, 2] 20 | 21 | 22 | simple_nesting = [ 23 | ## Empties ignore defaults in parents. 24 | ({"A": {"a": []}}, None), 25 | ({"A": {"a": [], "C": {"i": -2}}}, None), 26 | ({"A": {"a": []}, "C": {"i": -1}}, None), 27 | ({"A": {"a": [], "C": {"i": -2}}, "C": {"i": -1}}, None), 28 | ## Defaults in parents. 29 | ({"A": {"a": [{}], "C": {"i": -2}}}, -2), 30 | ({"A": {"a": [{}]}, "C": {"i": -1}}, -1), 31 | ({"A": {"a": [{}], "C": {"i": -2}}, "C": {"i": -1}}, -2), 32 | ({"A": {"a": [{}, {}], "C": {"i": -2}}}, [-2, -2]), 33 | ({"A": {"a": [{}, {}]}, "C": {"i": -1}}, [-1, -1]), 34 | ({"A": {"a": [{}, {}], "C": {"i": -2}}, "C": {"i": -1}}, [-2, -2]), 35 | ## Elements override defaults 36 | ({"A": {"a": [{"i": 2}]}}, 2), 37 | ({"A": {"a": [{"i": 2}], "C": {"i": -2}}}, 2), 38 | ({"A": {"a": [{"i": 2}]}, "C": {"i": -1}}, 2), 39 | ({"A": {"a": [{"i": 2}], "C": {"i": -2}}, "C": {"i": -1}}, 2), 40 | ( 41 | {"A": {"a": [{"i": 2}, {"i": 3}, {"i": 1}], "C": {"i": -2}}, "C": {"i": -1}}, 42 | [2, 3, 1], 43 | ), 44 | ## Multiple elements + defaults 45 | ({"A": {"a": [{}, {"i": 1}]}}, [0, 1]), 46 | ({"A": {"a": [{}, {"i": 1}], "C": {"i": -2}}}, [-2, 1]), 47 | ({"A": {"a": [{}, {"i": 1}]}, "C": {"i": -1}}, [-1, 1]), 48 | ({"A": {"a": [{}, {"i": 1}, {}]}}, [0, 1, 0]), 49 | ({"A": {"a": [{}, {"i": 1}, {}], "C": {"i": -2}}}, [-2, 1, -2]), 50 | ({"A": {"a": [{}, {"i": 1}, {"i": 5}]}, "C": {"i": -1}}, [-1, 1, 5]), 51 | ] 52 | 53 | 54 | @pytest.mark.parametrize("cfg, exp", simple_nesting) 55 | def test_simple_merge(cfg, exp): 56 | a = A(config=Config(cfg)) 57 | 58 | if exp is None: 59 | assert len(a.a) == 0 60 | elif isinstance(exp, list): 61 | assert exp == [e.i for e in a.a] 62 | else: 63 | assert a.a[0].i == exp 64 | 65 | class CnoConfig(HasTraits): 66 | i = CInt(config=True) 67 | 68 | class B(HasTraits): 69 | b = AutoInstance(CnoConfig, kw={"i": -1}, config=True) 70 | bb = AutoInstance(CnoConfig, kw={"i": -2}, config=True) 71 | 72 | 73 | class AA(Configurable): 74 | aa = List(AutoInstance(B), config=True) 75 | 76 | 77 | def test_recursive_merge(): 78 | # Exception a bit confusing... 79 | cfg = { 80 | "AA": { 81 | "aa": [ 82 | {"b": {"i": 1}, "bb": {"i": 2}}, 83 | {"b": {"i": 11}}, 84 | {"bb": {"i": 22}}, 85 | {}, 86 | ] 87 | }, 88 | } 89 | a = AA(config=Config(cfg)) 90 | assert [el.b.i for el in a.aa] == [1, 11, -1, -1] 91 | assert [el.bb.i for el in a.aa] == [2, -2, 22, -2] 92 | 93 | 94 | def test_default_value(): 95 | class B(HasTraits): 96 | i = Int(config=True) 97 | 98 | class A(Configurable): 99 | b = AutoInstance(B, kw={"i": 2}, config=True) 100 | 101 | assert A().b.i == 2 102 | 103 | assert A(config=Config({"A": {"b": {"i": 3}}})).b.i == 3 104 | 105 | 106 | def test_dynamic_default(): 107 | class B(HasTraits): 108 | i = Int(config=True) 109 | 110 | class A(Configurable): 111 | b = AutoInstance(B, config=True) 112 | 113 | @default("b") 114 | def _get_i(self): 115 | return dict({"i": 2}) 116 | 117 | assert A().b.i == 2 118 | 119 | assert A(config=Config({"A": {"b": {"i": 3}}})).b.i == 3 120 | -------------------------------------------------------------------------------- /jupyter_project/tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from traitlets import TraitError 3 | from traitlets.config import Config 4 | 5 | from jupyter_project.config import FileTemplateLoader, JupyterProject, ProjectTemplate 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "config, files, ptemplate", 10 | [ 11 | ({}, [], None,), 12 | ( 13 | { 14 | "file_templates": [ 15 | { 16 | "name": "template1", 17 | "location": "/dummy/file_templates", 18 | "files": [{"template": "template1.py"}], 19 | } 20 | ], 21 | }, 22 | [ 23 | dict( 24 | name="template1", 25 | location="/dummy/file_templates", 26 | files=[dict(template="template1.py",)], 27 | ) 28 | ], 29 | None, 30 | ), 31 | ( 32 | { 33 | "file_templates": [ 34 | { 35 | "location": "/dummy/file_templates", 36 | "files": [{"template": "template1.py"}], 37 | } 38 | ], 39 | }, 40 | TraitError, 41 | None, 42 | ), 43 | ( 44 | { 45 | "file_templates": [ 46 | {"name": "template1", "files": [{"template": "template1.py"}]} 47 | ], 48 | }, 49 | TraitError, 50 | None, 51 | ), 52 | ( 53 | { 54 | "file_templates": [ 55 | {"name": "template1", "location": "/dummy/file_templates"} 56 | ], 57 | }, 58 | TraitError, 59 | None, 60 | ), 61 | ( 62 | { 63 | "file_templates": [ 64 | { 65 | "name": "template1", 66 | "location": "/dummy/file_templates", 67 | "files": [], 68 | } 69 | ], 70 | }, 71 | TraitError, 72 | None, 73 | ), 74 | ( 75 | { 76 | "file_templates": [ 77 | { 78 | "name": "template1", 79 | "location": "/dummy/file_templates", 80 | "files": [{"template": ""}], 81 | } 82 | ], 83 | }, 84 | TraitError, 85 | None, 86 | ), 87 | ( 88 | { 89 | "file_templates": [ 90 | { 91 | "name": "template1", 92 | "location": "/dummy/file_templates", 93 | "files": [{"default_name": "", "template": "template1.py"}], 94 | } 95 | ], 96 | }, 97 | TraitError, 98 | None, 99 | ), 100 | ( 101 | { 102 | "file_templates": [ 103 | { 104 | "name": "template1", 105 | "location": "/dummy/file_templates", 106 | "files": [ 107 | {"template": "template1.py", "template_name": 24} 108 | ], 109 | } 110 | ], 111 | }, 112 | TraitError, 113 | None, 114 | ), 115 | ( 116 | { 117 | "file_templates": [ 118 | { 119 | "name": "template1", 120 | "location": "/dummy/file_templates", 121 | "files": [ 122 | {"template": "template1.py", "icon": ""} 123 | ], 124 | } 125 | ], 126 | }, 127 | TraitError, 128 | None, 129 | ), 130 | ( 131 | { 132 | "file_templates": [ 133 | { 134 | "name": "template1", 135 | "location": "/dummy/file_templates", 136 | "files": [ 137 | { 138 | "template": "template1.py", 139 | "template_name": "My beautiful template", 140 | "schema": dict( 141 | title="schema", description="empty schema" 142 | ), 143 | "default_name": "new_file", 144 | "destination": "star_folder", 145 | "icon": '', 146 | } 147 | ], 148 | } 149 | ], 150 | }, 151 | [ 152 | dict( 153 | name="template1", 154 | location="/dummy/file_templates", 155 | files=[ 156 | dict( 157 | template="template1.py", 158 | template_name="My beautiful template", 159 | schema=dict(title="schema", description="empty schema"), 160 | default_name="new_file", 161 | destination="star_folder", 162 | icon='', 163 | ) 164 | ], 165 | ) 166 | ], 167 | None, 168 | ), 169 | ( 170 | { 171 | "project_template": { 172 | "template": "my_magic.package", 173 | "configuration_filename": "my-project.json", 174 | } 175 | }, 176 | [], 177 | dict( 178 | template="my_magic.package", 179 | configuration_filename="my-project.json", 180 | conda_pkgs=None, 181 | editable_install=True, 182 | filter_kernel=True, 183 | ), 184 | ), 185 | ( 186 | { 187 | "project_template": { 188 | "template": "my_magic.package", 189 | "configuration_schema": dict( 190 | title="schema", description="empty schema" 191 | ), 192 | } 193 | }, 194 | [], 195 | dict( 196 | template="my_magic.package", 197 | configuration_schema=dict(title="schema", description="empty schema"), 198 | ), 199 | ), 200 | ( 201 | { 202 | "project_template": { 203 | "template": "my_magic.package", 204 | "default_path": "my-project.json", 205 | } 206 | }, 207 | [], 208 | dict(template="my_magic.package", default_path="my-project.json",), 209 | ), 210 | ( 211 | { 212 | "project_template": { 213 | "template": "my_magic.package", 214 | "schema": dict(title="schema", description="empty schema"), 215 | } 216 | }, 217 | [], 218 | dict( 219 | template="my_magic.package", 220 | schema=dict(title="schema", description="empty schema"), 221 | ), 222 | ), 223 | ( 224 | {"project_template": {"template": "my_magic.package",},}, 225 | [], 226 | dict(template="my_magic.package",), 227 | ), 228 | ({"project_template": {"template": "",},}, TraitError, None,), 229 | ({"project_template": {"template": "my_magic.package", "folder_name": ""},}, TraitError, None,), 230 | ( 231 | { 232 | "project_template": { 233 | "configuration_filename": "my-project.json", 234 | "configuration_schema": dict( 235 | title="Project configuration", 236 | description="My project configuration structure", 237 | ), 238 | "conda_pkgs": "Python 3", 239 | "default_path": "my_workspace", 240 | "editable_install": False, 241 | "filter_kernel": False, 242 | "folder_name": "banana", 243 | "template": "my_magic.package", 244 | "schema": dict( 245 | title="Project parameters", 246 | description="My project template parameters", 247 | ), 248 | }, 249 | }, 250 | [], 251 | dict( 252 | configuration_filename="my-project.json", 253 | configuration_schema=dict( 254 | title="Project configuration", 255 | description="My project configuration structure", 256 | ), 257 | conda_pkgs="Python 3", 258 | default_path="my_workspace", 259 | editable_install=False, 260 | filter_kernel=False, 261 | folder_name="banana", 262 | template="my_magic.package", 263 | schema=dict( 264 | title="Project parameters", 265 | description="My project template parameters", 266 | ), 267 | ), 268 | ), 269 | ], 270 | ) 271 | def test_JupyterProject(config, files, ptemplate): 272 | if isinstance(files, type) and issubclass(files, Exception): 273 | with pytest.raises(files): 274 | jp = JupyterProject( 275 | config=Config( 276 | { 277 | "NotebookApp": { 278 | "nbserver_extensions": {"jupyter_project": True} 279 | }, 280 | "JupyterProject": config, 281 | } 282 | ) 283 | ) 284 | else: 285 | jp = JupyterProject( 286 | config=Config( 287 | { 288 | "NotebookApp": {"nbserver_extensions": {"jupyter_project": True}}, 289 | "JupyterProject": config, 290 | } 291 | ) 292 | ) 293 | 294 | assert jp.file_templates == [FileTemplateLoader(**kw) for kw in files] 295 | if ptemplate is None: 296 | assert jp.project_template is None 297 | else: 298 | assert jp.project_template == ProjectTemplate(**ptemplate) 299 | -------------------------------------------------------------------------------- /jupyter_project/tests/test_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import tempfile 5 | import uuid 6 | from pathlib import Path 7 | from unittest import mock 8 | from urllib.parse import quote 9 | 10 | import jinja2 11 | import pytest 12 | import tornado 13 | from traitlets.config import Config 14 | 15 | from jupyter_project.handlers import FileTemplatesHandler 16 | 17 | from utils import ServerTest, assert_http_error, url_path_join, generate_path 18 | 19 | template_folder = tempfile.TemporaryDirectory(suffix="files") 20 | 21 | 22 | class TestPathFileTemplate(ServerTest): 23 | 24 | config = Config( 25 | { 26 | "NotebookApp": {"nbserver_extensions": {"jupyter_project": True}}, 27 | "JupyterProject": { 28 | "file_templates": [ 29 | { 30 | "name": "template1", 31 | "location": str(Path(template_folder.name) / "file_templates"), 32 | "files": [{"template": "file1.py"}, {"template": "file2.html"}], 33 | }, 34 | { 35 | "name": "template2", 36 | "module": "my_package", 37 | "location": "my_templates", 38 | "files": [{"template": "file1.py"}], 39 | }, 40 | { 41 | "name": "template3", 42 | "module": "my_package.sub", 43 | "location": "templates", 44 | "files": [{"template": "file1.py"}], 45 | }, 46 | ] 47 | }, 48 | } 49 | ) 50 | 51 | @classmethod 52 | def setup_class(cls): 53 | # Given 54 | folder = Path(template_folder.name) / "file_templates" 55 | folder.mkdir(exist_ok=True, parents=True) 56 | file1 = folder / "file1.py" 57 | file1.write_text("def add(a, b):\n return a + b\n") 58 | file2 = folder / "file2.html" 59 | file2.write_text( 60 | """ 61 | 62 | 63 | HTML 64 | 65 | 66 | 67 | """ 68 | ) 69 | 70 | folder = Path(template_folder.name) / "file_templates" / "my_package" 71 | folder.mkdir(exist_ok=True, parents=True) 72 | sys.path.insert(0, str(folder.parent)) 73 | folder1 = folder / "my_templates" 74 | folder2 = folder / "sub" / "templates" 75 | 76 | for folder in (folder1, folder2): 77 | folder.mkdir(exist_ok=True, parents=True) 78 | init = folder.parent / "__init__.py" 79 | init.write_bytes(b"") 80 | file1 = folder / "file1.py" 81 | file1.write_text("def add(a, b):\n return a + b\n") 82 | super().setup_class() 83 | 84 | @classmethod 85 | def teardown_class(cls): 86 | super().teardown_class() 87 | sys.path.remove(str(Path(template_folder.name) / "file_templates")) 88 | template_folder.cleanup() 89 | 90 | @mock.patch("jupyter_project.handlers.Template") 91 | @mock.patch("jinja2.Template.render") 92 | def test_template1_file1(self, renderer, default_name): 93 | instance = default_name.return_value 94 | name = str(uuid.uuid4()) 95 | instance.render.return_value = name 96 | renderer.return_value = "dummy content" 97 | path = generate_path() 98 | body = dict(dummy="hello", smart="world") 99 | 100 | answer = self.api_tester.post( 101 | ["files", quote("template1/file1", safe=""), path], body=body 102 | ) 103 | assert answer.status_code == 201 104 | 105 | instance.render.assert_called_with(**body) 106 | renderer.assert_called_with(**body) 107 | model = answer.json() 108 | assert model["content"] is None 109 | assert model["name"] == name + ".py" 110 | assert model["path"] == url_path_join(path, name + ".py") 111 | 112 | @mock.patch("jupyter_project.handlers.Template") 113 | @mock.patch("jinja2.Template.render") 114 | def test_template1_file2(self, renderer, default_name): 115 | instance = default_name.return_value 116 | name = str(uuid.uuid4()) 117 | instance.render.return_value = name 118 | renderer.return_value = "dummy content" 119 | path = generate_path() 120 | body = dict(dummy="hello", smart="world") 121 | 122 | answer = self.api_tester.post( 123 | ["files", quote("template1/file2", safe=""), path], body=body 124 | ) 125 | assert answer.status_code == 201 126 | 127 | instance.render.assert_called_with(**body) 128 | renderer.assert_called_with(**body) 129 | model = answer.json() 130 | assert model["content"] is None 131 | assert model["name"] == name + ".html" 132 | assert model["path"] == url_path_join(path, name + ".html") 133 | 134 | @mock.patch("jupyter_project.handlers.Template") 135 | @mock.patch("jinja2.Template.render") 136 | def test_template2_file1(self, renderer, default_name): 137 | instance = default_name.return_value 138 | name = str(uuid.uuid4()) 139 | instance.render.return_value = name 140 | renderer.return_value = "dummy content" 141 | path = generate_path() 142 | body = dict(dummy="hello", smart="world") 143 | 144 | answer = self.api_tester.post( 145 | ["files", quote("template2/file1", safe=""), path], body=body 146 | ) 147 | assert answer.status_code == 201 148 | 149 | instance.render.assert_called_with(**body) 150 | renderer.assert_called_with(**body) 151 | model = answer.json() 152 | assert model["content"] is None 153 | assert model["name"] == name + ".py" 154 | assert model["path"] == url_path_join(path, name + ".py") 155 | 156 | @mock.patch("jupyter_project.handlers.Template") 157 | @mock.patch("jinja2.Template.render") 158 | def test_template3_file1(self, renderer, default_name): 159 | instance = default_name.return_value 160 | name = str(uuid.uuid4()) 161 | instance.render.return_value = name 162 | renderer.return_value = "dummy content" 163 | path = generate_path() 164 | body = dict(dummy="hello", smart="world") 165 | 166 | answer = self.api_tester.post( 167 | ["files", quote("template3/file1", safe=""), path], body=body 168 | ) 169 | assert answer.status_code == 201 170 | 171 | instance.render.assert_called_with(**body) 172 | renderer.assert_called_with(**body) 173 | model = answer.json() 174 | assert model["content"] is None 175 | assert model["name"] == name + ".py" 176 | assert model["path"] == url_path_join(path, name + ".py") 177 | 178 | def test_missing_endpoint(self): 179 | with assert_http_error(404): 180 | self.api_tester.post(["files", quote("template4/file", safe="")], body={}) 181 | 182 | def test_missing_body(self): 183 | with assert_http_error(500): 184 | self.api_tester.post(["files", quote("template3/file1", safe="")]) 185 | 186 | @mock.patch("jupyter_project.handlers.Template") 187 | @mock.patch("jinja2.Template.render") 188 | def test_fail_name_rendering(self, renderer, default_name): 189 | instance = default_name.return_value 190 | instance.render.side_effect = jinja2.TemplateError 191 | renderer.return_value = "dummy content" 192 | path = generate_path() 193 | body = dict(dummy="hello", smart="world") 194 | 195 | answer = self.api_tester.post( 196 | ["files", quote("template1/file1", safe=""), path], body=body 197 | ) 198 | assert answer.status_code == 201 199 | 200 | instance.render.assert_called_with(**body) 201 | renderer.assert_called_with(**body) 202 | model = answer.json() 203 | assert model["content"] is None 204 | print(model["name"]) 205 | assert re.match(r"untitled\d*\.py", model["name"]) is not None 206 | assert re.match(path + r"/untitled\d*\.py", model["path"]) is not None 207 | 208 | @mock.patch("jupyter_project.handlers.Template") 209 | @mock.patch("jinja2.Template.render") 210 | def test_fail_template_rendering(self, renderer, default_name): 211 | instance = default_name.return_value 212 | name = str(uuid.uuid4()) 213 | instance.render.return_value = name 214 | renderer.side_effect = jinja2.TemplateError 215 | path = generate_path() 216 | body = dict(dummy="hello", smart="world") 217 | 218 | with assert_http_error(500): 219 | self.api_tester.post( 220 | ["files", quote("template1/file1", safe=""), path], body=body 221 | ) 222 | 223 | -------------------------------------------------------------------------------- /jupyter_project/tests/test_project.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import re 5 | import sys 6 | import tempfile 7 | import uuid 8 | from pathlib import Path 9 | from unittest import mock 10 | from urllib.parse import quote 11 | 12 | import jsonschema 13 | import pytest 14 | import tornado 15 | from cookiecutter.exceptions import CookiecutterException 16 | from jupyter_client.kernelspec import KernelSpecManager 17 | from traitlets import TraitError 18 | from traitlets.config import Config 19 | 20 | from jupyter_project.project import ProjectTemplate 21 | from utils import ServerTest, assert_http_error, url_path_join, generate_path 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "kwargs, exception", 26 | [(dict(template=""), TraitError), (dict(folder_name=""), TraitError),], 27 | ) 28 | def test_ProjectTemplate_constructor(kwargs, exception): 29 | with pytest.raises(exception): 30 | ProjectTemplate(**kwargs) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "kwargs, configuration, exception", 35 | ( 36 | (dict(), dict(), None), 37 | (dict(template="https://github.com/me/my-template",), dict(), ValueError), 38 | ( 39 | dict(template="https://github.com/me/my-template",), 40 | dict(key1=1, key2="banana", name="my_project"), 41 | None, 42 | ), 43 | ( 44 | dict( 45 | template="https://github.com/me/my-template", 46 | configuration_schema={ 47 | "type": "object", 48 | "properties": { 49 | "key1": {"type": "number"}, 50 | "key2": {"type": "string", "minLength": 3}, 51 | }, 52 | }, 53 | ), 54 | dict(key1=1, key2="banana"), 55 | None, 56 | ), 57 | ( 58 | dict( 59 | template="https://github.com/me/my-template", 60 | configuration_schema={ 61 | "type": "object", 62 | "properties": { 63 | "key1": {"type": "number"}, 64 | "key2": {"type": "string", "minLength": 8}, 65 | }, 66 | }, 67 | ), 68 | dict(key1=1, key2="banana"), 69 | jsonschema.ValidationError, 70 | ), 71 | ), 72 | ) 73 | def test_ProjectTemplate_get_configuration(tmp_path, kwargs, configuration, exception): 74 | tpl = ProjectTemplate(**kwargs) 75 | if configuration: 76 | (tmp_path / tpl.configuration_filename).write_text(json.dumps(configuration)) 77 | 78 | if exception is None: 79 | assert configuration == tpl.get_configuration(tmp_path) 80 | else: 81 | with pytest.raises(exception): 82 | tpl.get_configuration(tmp_path) 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "kwargs, nfolder", 87 | [ 88 | (dict(), None), 89 | (dict(template=None), None), 90 | (dict(template="https://github.com/me/my-template"), None), 91 | ( 92 | dict( 93 | template="https://github.com/me/my-template", configuration_filename="" 94 | ), 95 | None, 96 | ), 97 | (dict(module="my_magic.package", template="my-project-template"), None), 98 | ( 99 | dict( 100 | folder_name="answer_is_42", template="https://github.com/me/my-template" 101 | ), 102 | "Answer Is 42", 103 | ), 104 | ( 105 | dict( 106 | folder_name="project_{{ key1 }}", 107 | template="https://github.com/me/my-template", 108 | ), 109 | "Project 22", 110 | ), 111 | ], 112 | ) 113 | def test_ProjectTemplate_render(tmp_path, kwargs, nfolder): 114 | if "module" in kwargs: 115 | folder = ( 116 | tmp_path / "project_template" / "my_magic" / "package" / kwargs["template"] 117 | ) 118 | folder.mkdir(exist_ok=True, parents=True) 119 | parent = folder.parent 120 | for _ in range(2): 121 | init = parent / "__init__.py" 122 | init.write_bytes(b"") 123 | parent = parent.parent 124 | 125 | sys.path.insert(0, str(parent)) 126 | template_uri = str(folder) 127 | else: 128 | parent = None 129 | template_uri = kwargs.get("template", None) 130 | 131 | try: 132 | tpl = ProjectTemplate(**kwargs) 133 | params = dict(key1=22, key2="hello darling", name="My Project") 134 | with mock.patch("jupyter_project.project.cookiecutter") as cookiecutter: 135 | directory, configuration = tpl.render(params, tmp_path) 136 | 137 | if template_uri is not None: 138 | assert directory == (nfolder or params["name"]).lower().replace(" ", "_") 139 | assert configuration == dict(name=(nfolder or params["name"]).capitalize()) 140 | 141 | cookiecutter.assert_called_once_with( 142 | template_uri, 143 | no_input=True, 144 | extra_context=params, 145 | output_dir=str(tmp_path), 146 | ) 147 | 148 | if len(tpl.configuration_filename) > 0: 149 | (tmp_path / tpl.configuration_filename).exists() 150 | 151 | else: 152 | assert directory is None 153 | assert configuration == dict() 154 | cookiecutter.assert_not_called() 155 | assert not (tmp_path / tpl.configuration_filename).exists() 156 | 157 | finally: 158 | if parent is not None: 159 | sys.path.remove(str(parent)) 160 | 161 | 162 | @pytest.mark.parametrize( 163 | "content", ["", "{}", '{"name": "Answer Is 42", "dummy": "key"}',] 164 | ) 165 | def test_ProjectTemplate_with_config_file(tmp_path, caplog, content): 166 | name = "answer_is_42" 167 | tpl = ProjectTemplate(template="https://github.com/me/my-template") 168 | conf = tmp_path / name / tpl.configuration_filename 169 | conf.parent.mkdir(parents=True, exist_ok=True) 170 | conf.write_text(content) 171 | 172 | params = dict(key1=22, key2="hello darling", name=name) 173 | 174 | caplog.clear() 175 | with caplog.at_level(logging.DEBUG): 176 | with mock.patch("jupyter_project.project.cookiecutter") as cookiecutter: 177 | directory, configuration = tpl.render(params, tmp_path) 178 | 179 | assert directory == name 180 | assert configuration["name"] == "Answer is 42" 181 | 182 | if len(caplog.records) > 0: 183 | assert len(configuration) == 1 184 | record = list(filter(lambda r: r.levelno == logging.DEBUG, caplog.records)) 185 | assert len(record) == 1 186 | assert ( 187 | re.match(r"Unable to load configuration file", record[0].message) 188 | is not None 189 | ) 190 | else: 191 | if len(content) > 2: 192 | assert configuration["dummy"] == "key" 193 | 194 | cookiecutter.assert_called_once() 195 | 196 | assert conf.exists() 197 | 198 | 199 | class TestProjectTemplate(ServerTest): 200 | 201 | config = Config( 202 | { 203 | "NotebookApp": {"nbserver_extensions": {"jupyter_project": True}}, 204 | "JupyterProject": { 205 | "project_template": { 206 | "configuration_filename": "my-project.json", 207 | "conda_pkgs": "ipykernel", 208 | "module": "my_magic.package", 209 | "template": "my-project-template", 210 | "schema": { 211 | "title": "My Project", 212 | "description": "Project template description", 213 | "type": "object", 214 | "properties": {"count": {"type": "number"}}, 215 | }, 216 | }, 217 | }, 218 | } 219 | ) 220 | 221 | def test_project_get(self): 222 | path = generate_path() 223 | configuration = dict(key1=22, key2="hello darling") 224 | 225 | with mock.patch( 226 | "jupyter_project.handlers.ProjectTemplate.get_configuration" 227 | ) as mock_configuration: 228 | mock_configuration.return_value = configuration 229 | 230 | answer = self.api_tester.get(["projects", path]) 231 | assert answer.status_code == 200 232 | conf = answer.json() 233 | assert conf == {"project": configuration} 234 | 235 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 236 | 237 | def test_project_get_no_path(self): 238 | self.notebook.kernel_spec_manager.whitelist = {"tic", "tac"} 239 | answer = self.api_tester.get(["projects",]) 240 | assert answer.status_code == 200 241 | conf = answer.json() 242 | assert conf == {"project": None} 243 | assert self.notebook.kernel_spec_manager.whitelist == set() 244 | 245 | def test_project_get_no_configuration(self): 246 | path = generate_path() 247 | 248 | with mock.patch( 249 | "jupyter_project.handlers.ProjectTemplate.get_configuration" 250 | ) as mock_configuration: 251 | mock_configuration.side_effect = ValueError 252 | 253 | with assert_http_error(404): 254 | self.api_tester.get(["projects", path]) 255 | 256 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 257 | 258 | def test_project_get_invalid_configuration(self): 259 | path = generate_path() 260 | 261 | with mock.patch( 262 | "jupyter_project.handlers.ProjectTemplate.get_configuration" 263 | ) as mock_configuration: 264 | mock_configuration.side_effect = jsonschema.ValidationError( 265 | message="failure" 266 | ) 267 | 268 | with assert_http_error(404): 269 | self.api_tester.get(["projects", path]) 270 | 271 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 272 | 273 | def test_project_get_set_whitelist(self): 274 | path = generate_path() 275 | env_name = "banana" 276 | kernel_name = "conda-kernel-myname" 277 | self.notebook.kernel_spec_manager.whitelist = {"tic", "tac"} 278 | 279 | with mock.patch( 280 | "jupyter_project.handlers.ProjectTemplate.get_configuration" 281 | ) as mock_configuration: 282 | mock_configuration.return_value = {"environment": env_name} 283 | with mock.patch( 284 | "jupyter_project.handlers.ProjectsHandler.kernel_spec_manager" 285 | ) as mocked_specs: 286 | mocked_specs.configure_mock( 287 | **{ 288 | "get_all_specs.return_value": { 289 | kernel_name: { 290 | "spec": {"metadata": {"conda_env_name": env_name}} 291 | } 292 | } 293 | } 294 | ) 295 | answer = self.api_tester.get(["projects", path]) 296 | assert answer.status_code == 200 297 | 298 | mocked_specs.get_all_specs.assert_called_once() 299 | assert mocked_specs.whitelist == { 300 | kernel_name, 301 | } 302 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 303 | 304 | def test_project_get_no_conda(self): 305 | path = generate_path() 306 | self.notebook.kernel_spec_manager.whitelist = {"tic", "tac"} 307 | with mock.patch("jupyter_project.handlers.ProjectTemplate.conda_pkgs", None): 308 | answer = self.api_tester.get(["projects",]) 309 | assert answer.status_code == 200 310 | conf = answer.json() 311 | assert conf == {"project": None} 312 | assert self.notebook.kernel_spec_manager.whitelist == {"tic", "tac"} 313 | 314 | with mock.patch( 315 | "jupyter_project.handlers.ProjectTemplate.get_configuration" 316 | ) as mock_configuration: 317 | mock_configuration.return_value = dict() 318 | answer = self.api_tester.get(["projects", path]) 319 | assert answer.status_code == 200 320 | conf = answer.json() 321 | 322 | assert self.notebook.kernel_spec_manager.whitelist == {"tic", "tac"} 323 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 324 | 325 | def test_project_get_no_filter(self): 326 | path = generate_path() 327 | self.notebook.kernel_spec_manager.whitelist = {"tic", "tac"} 328 | with mock.patch( 329 | "jupyter_project.handlers.ProjectTemplate.filter_kernel", False 330 | ): 331 | answer = self.api_tester.get(["projects",]) 332 | assert answer.status_code == 200 333 | conf = answer.json() 334 | assert conf == {"project": None} 335 | assert self.notebook.kernel_spec_manager.whitelist == {"tic", "tac"} 336 | 337 | with mock.patch( 338 | "jupyter_project.handlers.ProjectTemplate.get_configuration" 339 | ) as mock_configuration: 340 | mock_configuration.return_value = dict() 341 | answer = self.api_tester.get(["projects", path]) 342 | assert answer.status_code == 200 343 | conf = answer.json() 344 | 345 | assert self.notebook.kernel_spec_manager.whitelist == {"tic", "tac"} 346 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 347 | 348 | def test_project_get_no_configuration_environment(self): 349 | path = generate_path() 350 | self.notebook.kernel_spec_manager.whitelist = {"tic", "tac"} 351 | 352 | with mock.patch( 353 | "jupyter_project.handlers.ProjectTemplate.get_configuration" 354 | ) as mock_configuration: 355 | mock_configuration.return_value = dict() 356 | answer = self.api_tester.get(["projects", path]) 357 | assert answer.status_code == 200 358 | conf = answer.json() 359 | 360 | assert self.notebook.kernel_spec_manager.whitelist == {"tic", "tac"} 361 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 362 | 363 | answer = self.api_tester.get(["projects",]) 364 | assert answer.status_code == 200 365 | conf = answer.json() 366 | assert conf == {"project": None} 367 | assert self.notebook.kernel_spec_manager.whitelist == set() 368 | 369 | def test_project_post(self): 370 | path = generate_path() 371 | body = dict(dummy="hello", smart="world", name="Project Name") 372 | configuration = dict(key1=22, key2="hello darling") 373 | 374 | with mock.patch( 375 | "jupyter_project.handlers.ProjectTemplate.render" 376 | ) as mock_render: 377 | mock_render.return_value = ("project_name", configuration) 378 | answer = self.api_tester.post(["projects", path], body=body) 379 | assert answer.status_code == 201 380 | 381 | config = answer.json() 382 | assert config == {"project": configuration} 383 | 384 | mock_render.assert_called_once_with(body, Path(self.notebook_dir) / path) 385 | 386 | def test_project_post_cookiecutter_failure(self): 387 | path = generate_path() 388 | body = dict(dummy="hello", smart="world") 389 | 390 | with mock.patch( 391 | "jupyter_project.handlers.ProjectTemplate.render" 392 | ) as mock_render: 393 | mock_render.side_effect = CookiecutterException 394 | 395 | with assert_http_error(500): 396 | self.api_tester.post(["projects", path], body=body) 397 | 398 | mock_render.assert_called_once_with(body, Path(self.notebook_dir) / path) 399 | 400 | def test_project_post_invalid_configuration(self): 401 | path = generate_path() 402 | body = dict(dummy="hello", smart="world") 403 | 404 | with mock.patch( 405 | "jupyter_project.project.ProjectTemplate.render" 406 | ) as mock_render: 407 | mock_render.side_effect = jsonschema.ValidationError(message="failure") 408 | 409 | with assert_http_error(500): 410 | self.api_tester.post(["projects", path], body=body) 411 | 412 | mock_render.assert_called_once_with(body, Path(self.notebook_dir) / path) 413 | 414 | def test_project_delete(self): 415 | path = generate_path() 416 | 417 | with mock.patch("jupyter_project.project.ProjectTemplate.get_configuration"): 418 | with mock.patch("jupyter_project.handlers.rmtree") as mock_rmtree: 419 | answer = self.api_tester.delete(["projects", path]) 420 | assert answer.status_code == 204 421 | assert answer.text == "" 422 | 423 | mock_rmtree.assert_called_once_with( 424 | Path(self.notebook_dir) / path, ignore_errors=True 425 | ) 426 | 427 | def test_project_delete_empty_path(self): 428 | answer = self.api_tester.delete(["projects",]) 429 | assert answer.status_code == 200 430 | assert answer.text == "{}" 431 | 432 | def test_project_delete_no_configuration(self): 433 | path = generate_path() 434 | 435 | with mock.patch( 436 | "jupyter_project.project.ProjectTemplate.get_configuration" 437 | ) as mock_configuration: 438 | mock_configuration.side_effect = ValueError 439 | 440 | with assert_http_error(404): 441 | self.api_tester.delete(["projects", path]) 442 | 443 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 444 | 445 | def test_project_delete_invalid_configuration(self): 446 | path = generate_path() 447 | 448 | with mock.patch( 449 | "jupyter_project.project.ProjectTemplate.get_configuration" 450 | ) as mock_configuration: 451 | mock_configuration.side_effect = jsonschema.ValidationError( 452 | message="failure" 453 | ) 454 | 455 | with assert_http_error(404): 456 | self.api_tester.delete(["projects", path]) 457 | 458 | mock_configuration.assert_called_once_with(Path(self.notebook_dir) / path) 459 | -------------------------------------------------------------------------------- /jupyter_project/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tempfile 3 | from pathlib import Path 4 | from unittest import mock 5 | from urllib.parse import quote 6 | 7 | import pytest 8 | import requests 9 | import tornado 10 | from traitlets.config import Config 11 | 12 | from utils import ServerTest, assert_http_error, url_path_join 13 | 14 | 15 | template_folder = tempfile.TemporaryDirectory(suffix="settings") 16 | 17 | 18 | class TestSettings(ServerTest): 19 | 20 | config = Config( 21 | { 22 | "NotebookApp": {"nbserver_extensions": {"jupyter_project": True}}, 23 | "JupyterProject": { 24 | "file_templates": [ 25 | { 26 | "name": "template1", 27 | "location": str(Path(template_folder.name) / "file_templates"), 28 | "files": [ 29 | {"template": "file1.py"}, 30 | { 31 | "default_name": "documentation", 32 | "destination": "docs", 33 | "icon": '', 34 | "template": "file2.html", 35 | "template_name": "Doc file", 36 | "schema": {"properties": {"name": {"type": "string"}}}, 37 | }, 38 | ], 39 | }, 40 | { 41 | "name": "template2", 42 | "module": "my_package_settings", 43 | "location": "my_templates", 44 | "files": [ 45 | { 46 | "template": "file1.py", 47 | "schema": {"properties": {"count": {"type": "number"}}}, 48 | } 49 | ], 50 | }, 51 | ], 52 | "project_template": { 53 | "configuration_filename": "my-project.json", 54 | "configuration_schema": { 55 | "title": "Project configuration schema", 56 | "description": "Project configuration", 57 | "properties": {"n_notebooks": {"type": "number"}}, 58 | }, 59 | "conda_pkgs": "python=3 ipykernel", 60 | "default_path": "notebooks", 61 | "editable_install": False, 62 | "filter_kernel": False, 63 | "schema": { 64 | "title": "My Project", 65 | "description": "Project template description", 66 | "properties": {"count": {"type": "number"}}, 67 | }, 68 | "template": "my_magic.package", 69 | }, 70 | }, 71 | } 72 | ) 73 | 74 | @classmethod 75 | def setup_class(cls): 76 | # Given 77 | folder = Path(template_folder.name) / "file_templates" 78 | folder.mkdir(exist_ok=True, parents=True) 79 | file1 = folder / "file1.py" 80 | file1.write_text("def add(a, b):\n return a + b\n") 81 | file2 = folder / "file2.html" 82 | file2.write_text( 83 | """ 84 | 85 | 86 | HTML 87 | 88 | 89 | 90 | """ 91 | ) 92 | 93 | folder = Path(template_folder.name) / "file_templates" / "my_package_settings" 94 | folder.mkdir(exist_ok=True, parents=True) 95 | sys.path.insert(0, str(folder.parent)) 96 | folder = folder / "my_templates" 97 | 98 | folder.mkdir(exist_ok=True, parents=True) 99 | init = folder.parent / "__init__.py" 100 | init.write_bytes(b"") 101 | file1 = folder / "file1.py" 102 | file1.write_text("def add(a, b):\n return a + b\n") 103 | super().setup_class() 104 | 105 | @classmethod 106 | def teardown_class(cls): 107 | super().teardown_class() 108 | sys.path.remove(str(Path(template_folder.name) / "file_templates")) 109 | template_folder.cleanup() 110 | 111 | def test_get_settings(self): 112 | answer = self.api_tester.get(["settings",]) 113 | assert answer.status_code == 200 114 | settings = answer.json() 115 | assert settings == { 116 | "fileTemplates": [ 117 | { 118 | "endpoint": quote("/".join(("template1", "file1")), safe=""), 119 | "destination": None, 120 | "icon": None, 121 | "name": quote("/".join(("template1", "file1")), safe=""), 122 | "schema": None, 123 | }, 124 | { 125 | "endpoint": quote("/".join(("template1", "file2")), safe=""), 126 | "destination": "docs", 127 | "icon": '', 128 | "name": "Doc file", 129 | "schema": {"properties": {"name": {"type": "string"}}}, 130 | }, 131 | { 132 | "endpoint": quote("/".join(("template2", "file1")), safe=""), 133 | "destination": None, 134 | "icon": None, 135 | "name": quote("/".join(("template2", "file1")), safe=""), 136 | "schema": {"properties": {"count": {"type": "number"}}}, 137 | }, 138 | ], 139 | "projectTemplate": { 140 | "configurationFilename": "my-project.json", 141 | "defaultCondaPackages": "python=3 ipykernel", 142 | "defaultPath": "notebooks", 143 | "editableInstall": False, 144 | "schema": { 145 | "title": "My Project", 146 | "description": "Project template description", 147 | "properties": {"count": {"type": "number"}}, 148 | }, 149 | "withGit": True, 150 | }, 151 | } 152 | 153 | 154 | class TestEmptySettings(ServerTest): 155 | 156 | config = Config({"NotebookApp": {"nbserver_extensions": {"jupyter_project": True}}}) 157 | 158 | def test_get_empty_settings(self): 159 | answer = self.api_tester.get(["settings",]) 160 | assert answer.status_code == 200 161 | settings = answer.json() 162 | assert settings == {"fileTemplates": [], "projectTemplate": None} 163 | 164 | def test_no_project_endpoint(self): 165 | with assert_http_error(404): 166 | self.api_tester.get(["projects"]) 167 | 168 | with assert_http_error(404): 169 | self.api_tester.post(["projects"]) 170 | 171 | with assert_http_error(404): 172 | self.api_tester.delete(["projects"]) 173 | -------------------------------------------------------------------------------- /jupyter_project/tests/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | from typing import List 4 | 5 | from traitlets.config import Config 6 | 7 | # Shim for notebook server or jupyter_server 8 | # 9 | # Provides: 10 | # - ServerTestBase 11 | # - assert_http_error 12 | # - url_path_join 13 | 14 | try: 15 | from notebook.tests.launchnotebook import ( 16 | assert_http_error, 17 | NotebookTestBase as ServerTestBase, 18 | ) 19 | from notebook.utils import url_path_join 20 | except ImportError: 21 | from jupyter_server.tests.launchnotebook import assert_http_error # noqa 22 | from jupyter_server.tests.launchserver import ServerTestBase # noqa 23 | from jupyter_server.utils import url_path_join # noqa 24 | 25 | from jupyter_project.handlers import NAMESPACE 26 | 27 | 28 | class APITester(object): 29 | """Wrapper for REST API requests""" 30 | 31 | url = f"/{NAMESPACE}" 32 | 33 | def __init__(self, request): 34 | self.request = request 35 | 36 | def _req(self, verb: str, path: List[str], body=None, params=None): 37 | if body is not None: 38 | body = json.dumps(body) 39 | response = self.request( 40 | verb, url_path_join(self.url, *path), data=body, params=params 41 | ) 42 | 43 | if 400 <= response.status_code < 600: 44 | try: 45 | response.reason = response.json()["message"] 46 | except Exception: 47 | pass 48 | response.raise_for_status() 49 | 50 | return response 51 | 52 | def delete(self, path: List[str], body=None, params=None): 53 | return self._req("DELETE", path, body, params) 54 | 55 | def get(self, path: List[str], body=None, params=None): 56 | return self._req("GET", path, body, params) 57 | 58 | def patch(self, path: List[str], body=None, params=None): 59 | return self._req("PATCH", path, body, params) 60 | 61 | def post(self, path: List[str], body=None, params=None): 62 | return self._req("POST", path, body, params) 63 | 64 | 65 | class ServerTest(ServerTestBase): 66 | 67 | # Force extension enabling - Disabled by parent class otherwise 68 | config = Config({"NotebookApp": {"nbserver_extensions": {"jupyter_project": True}}}) 69 | 70 | def setUp(self): 71 | super(ServerTest, self).setUp() 72 | self.api_tester = APITester(self.request) 73 | 74 | 75 | def generate_path(): 76 | return url_path_join(*str(uuid.uuid4()).split("-")) 77 | -------------------------------------------------------------------------------- /jupyter_project/traits.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path as PyPath 3 | 4 | import jsonschema 5 | from traitlets import TraitType, validate 6 | 7 | 8 | class JSONSchema(TraitType): 9 | """A JSON schema trait""" 10 | 11 | default_value = dict() 12 | info_text = "a JSON schema (defined as string or dictionary)" 13 | 14 | def validate(self, obj, value): 15 | try: 16 | if isinstance(value, str): 17 | value = json.loads(value) 18 | 19 | validator = jsonschema.validators.validator_for(value) 20 | validator.check_schema(value) 21 | return value 22 | except: 23 | self.error(obj, value) 24 | 25 | 26 | class Path(TraitType): 27 | """A path string trait""" 28 | 29 | default_value = "." 30 | info_text = "a path string" 31 | 32 | def validate(self, obj, value): 33 | try: 34 | return PyPath(value) 35 | except: 36 | self.error(obj, value) 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyter-project", 3 | "version": "2.0.0-rc.1", 4 | "description": "An JupyterLab extension to handle project and files template.", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension", 9 | "template" 10 | ], 11 | "homepage": "https://github.com/fcollonval/jupyter-project", 12 | "bugs": { 13 | "url": "https://github.com/fcollonval/jupyter-project/issues" 14 | }, 15 | "license": "BSD-3-Clause", 16 | "author": "Frederic Collonval", 17 | "files": [ 18 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 19 | "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}" 20 | ], 21 | "main": "lib/index.js", 22 | "types": "lib/index.d.ts", 23 | "style": "style/index.css", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/fcollonval/jupyter-project.git" 27 | }, 28 | "scripts": { 29 | "build": "jlpm run build:lib", 30 | "build:labextension": "cd jupyter_project && rimraf labextension && mkdirp labextension && cd labextension && npm pack ../..", 31 | "build:lib": "tsc", 32 | "build:all": "jlpm run build:labextension", 33 | "clean": "jlpm run clean:lib", 34 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 35 | "clean:labextension": "rimraf jupyter_project/labextension", 36 | "clean:all": "jlpm run clean:lib && jlpm run clean:labextension", 37 | "lint": "eslint . --ext .ts,.tsx --fix && jlpm run embedme README.md", 38 | "lint:check": "eslint . --ext .ts,.tsx && jlpm run embedme --verify README.md", 39 | "prepare": "jlpm run clean && jlpm run build", 40 | "test": "jest --coverage", 41 | "watch": "tsc -w" 42 | }, 43 | "dependencies": { 44 | "@jupyterlab/application": "^2.0.0", 45 | "@jupyterlab/apputils": "^2.0.0", 46 | "@jupyterlab/coreutils": "^4.0.0", 47 | "@jupyterlab/filebrowser": "^2.0.0", 48 | "@jupyterlab/git": "^0.20.0", 49 | "@jupyterlab/launcher": "^2.0.0", 50 | "@jupyterlab/mainmenu": "^2.0.0", 51 | "@jupyterlab/services": "^5.0.0", 52 | "@jupyterlab/statedb": "^2.1.0", 53 | "@jupyterlab/statusbar": "^2.0.0", 54 | "@jupyterlab/ui-components": "^2.0.0", 55 | "@lumino/signaling": "^1.3.0", 56 | "@lumino/widgets": "^1.9.0", 57 | "@material-ui/core": "^4.9.13", 58 | "ajv": "^6.12.2", 59 | "jupyterlab_conda": "^2.1.2", 60 | "jupyterlab_toastify": "^4.0.0", 61 | "uniforms": "^2.6.7", 62 | "uniforms-bridge-json-schema": "^2.6.7", 63 | "uniforms-material": "^2.6.7", 64 | "yaml": "^1.10.0" 65 | }, 66 | "devDependencies": { 67 | "@babel/core": "^7.9.6", 68 | "@babel/preset-env": "^7.9.6", 69 | "@jupyterlab/testutils": "^2.0.0", 70 | "@types/jest": "^25.2.3", 71 | "@types/react": "~16.9.16", 72 | "@typescript-eslint/eslint-plugin": "^2.25.0", 73 | "@typescript-eslint/parser": "^2.25.0", 74 | "embedme": "^1.21.0", 75 | "eslint": "^6.8.0", 76 | "eslint-config-prettier": "^6.10.1", 77 | "eslint-plugin-prettier": "^3.1.2", 78 | "eslint-plugin-react": "^7.19.0", 79 | "jest": "^25.0.0", 80 | "mkdirp": "^1.0.3", 81 | "prettier": "^1.16.4", 82 | "react": "~16.9.0", 83 | "rimraf": "^3.0.2", 84 | "ts-jest": "^25.0.0", 85 | "typescript": "~3.7.0" 86 | }, 87 | "sideEffects": [ 88 | "style/*.css" 89 | ], 90 | "jupyterlab": { 91 | "discovery": { 92 | "server": { 93 | "managers": [ 94 | "pip", 95 | "conda" 96 | ], 97 | "base": { 98 | "name": "jupyter-project" 99 | } 100 | } 101 | }, 102 | "extension": true 103 | }, 104 | "resolutions": { 105 | "@types/react": "~16.9.0" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["jupyter_packaging~=0.4.0", "jupyterlab~=1.2", "setuptools>=40.8.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.coverage.run] 6 | omit = ["*/tests/*", ] 7 | source = ["jupyter_project", ] 8 | 9 | [tool.coverage.report] 10 | exclude_lines = ["pragma: no cover", "noqa", "except ImportError:"] 11 | show_missing = true 12 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from jupyter_packaging import get_version 5 | from packaging.version import parse 6 | 7 | 8 | def assert_equal_version(): 9 | cdir = Path(__file__).parent 10 | server_version = parse(get_version(str(cdir / "jupyter_project" / "_version.py"))) 11 | package_json = cdir / "package.json" 12 | package_config = json.loads(package_json.read_text()) 13 | jlab_version = parse(package_config.get("version", "0")) 14 | assert server_version == jlab_version, f"Frontend ({jlab_version}) and server ({server_version}) versions are not matching." 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup Module to setup Python Handlers for the jupyter-project extension. 3 | """ 4 | import os 5 | 6 | from jupyter_packaging import ( 7 | create_cmdclass, 8 | install_npm, 9 | ensure_targets, 10 | combine_commands, 11 | ensure_python, 12 | get_version, 13 | ) 14 | import setuptools 15 | 16 | HERE = os.path.abspath(os.path.dirname(__file__)) 17 | 18 | # The name of the project 19 | name = "jupyter_project" 20 | 21 | # Ensure a valid python version 22 | ensure_python(">=3.6") 23 | 24 | # Get our version 25 | version = get_version(os.path.join(name, "_version.py")) 26 | 27 | lab_path = os.path.join(HERE, name, "labextension") 28 | 29 | # Representative files that should exist after a successful build 30 | jstargets = [ 31 | os.path.join(HERE, "lib", "jupyter-project.js"), 32 | ] 33 | 34 | package_data_spec = {name: ["*"]} 35 | 36 | data_files_spec = [ 37 | ("share/jupyter/lab/extensions", lab_path, "*.tgz"), 38 | ("etc/jupyter/jupyter_notebook_config.d", "jupyter-config", "jupyter_project.json"), 39 | ] 40 | 41 | cmdclass = create_cmdclass( 42 | "jsdeps", package_data_spec=package_data_spec, data_files_spec=data_files_spec 43 | ) 44 | 45 | cmdclass["jsdeps"] = combine_commands( 46 | install_npm(HERE, build_cmd="build:all", npm=["jlpm"]), ensure_targets(jstargets), 47 | ) 48 | 49 | with open("README.md", "r") as fh: 50 | long_description = fh.read() 51 | 52 | setup_args = dict( 53 | name=name, 54 | version=version, 55 | url="https://github.com/fcollonval/jupyter-project", 56 | author="Frederic Collonval", 57 | description="An JupyterLab extension to handle project and files templates.", 58 | long_description=long_description, 59 | long_description_content_type="text/markdown", 60 | cmdclass=cmdclass, 61 | packages=setuptools.find_packages(), 62 | install_requires=[ 63 | "cookiecutter", 64 | "jinja2~=2.9", 65 | "jsonschema", 66 | "jupyterlab~=2.0" 67 | ], 68 | extras_require={ 69 | "all": [ 70 | "jupyter_conda~=3.3", 71 | "jupyterlab-git~=0.20" 72 | ], 73 | "test": ["pytest", "pytest-asyncio"], 74 | }, 75 | zip_safe=False, 76 | include_package_data=True, 77 | license="BSD-3-Clause", 78 | platforms="Linux, Mac OS X, Windows", 79 | keywords=["Jupyter", "JupyterLab", "template"], 80 | classifiers=[ 81 | "License :: OSI Approved :: BSD License", 82 | "Programming Language :: Python", 83 | "Programming Language :: Python :: 3", 84 | "Programming Language :: Python :: 3.6", 85 | "Programming Language :: Python :: 3.7", 86 | "Programming Language :: Python :: 3.8", 87 | "Framework :: Jupyter", 88 | ], 89 | ) 90 | 91 | 92 | if __name__ == "__main__": 93 | setuptools.setup(**setup_args) 94 | -------------------------------------------------------------------------------- /src/__tests__/project.spec.ts: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | 3 | jest.mock('@jupyterlab/services', () => { 4 | return { 5 | __esModule: true, 6 | ServerConnection: { 7 | makeRequest: jest.fn() 8 | } 9 | }; 10 | }); 11 | 12 | describe('jupyter-project/project', () => { 13 | afterEach(() => { 14 | jest.resetAllMocks(); 15 | }); 16 | 17 | describe('ProjectManager', () => { 18 | describe('constructor()', () => { 19 | it('should', () => { 20 | expect(1 + 2).toEqual(3); 21 | }); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import { renderStringTemplate } from '../utils'; 3 | import { Project } from '../tokens'; 4 | 5 | describe('jupyter-project/utils', () => { 6 | describe('renderStringTemplate', () => { 7 | it('should render a templated string with project', () => { 8 | // Given 9 | const project: Project.IModel = { 10 | name: 'banana', 11 | path: '/path/to/project' 12 | }; 13 | // When 14 | const template = 15 | 'dummy_{{jproject.namE }}_{{ jproject.path}}_{{jproject.name}}'; 16 | const final = renderStringTemplate(template, project); 17 | 18 | // Then 19 | expect(final).toEqual('dummy_banana_/path/to/project_banana'); 20 | }); 21 | }); 22 | it('should not change a string without template', () => { 23 | // Given 24 | const project: Project.IModel = { 25 | name: 'banana', 26 | path: '/path/to/project' 27 | }; 28 | // When 29 | const template = 'dummy_template'; 30 | const final = renderStringTemplate(template, project); 31 | 32 | // Then 33 | expect(final).toEqual(template); 34 | }); 35 | it('should return the templated string if no project is provided', () => { 36 | // Given 37 | const project: Project.IModel | null = null; 38 | // When 39 | const template = 40 | 'dummy_{{jproject.namE }}_{{ jproject.path}}_{{jproject.name}}'; 41 | const final = renderStringTemplate(template, project); 42 | 43 | // Then 44 | expect(final).toEqual(template); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/filetemplates.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ICommandPalette, 3 | InputDialog, 4 | showErrorMessage 5 | } from '@jupyterlab/apputils'; 6 | import { URLExt } from '@jupyterlab/coreutils'; 7 | import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; 8 | import { ILauncher } from '@jupyterlab/launcher'; 9 | import { IMainMenu } from '@jupyterlab/mainmenu'; 10 | import { Contents } from '@jupyterlab/services'; 11 | import { CommandRegistry } from '@lumino/commands'; 12 | import { ReadonlyJSONObject } from '@lumino/coreutils'; 13 | import { JSONSchemaBridge } from 'uniforms-bridge-json-schema'; 14 | import { showForm } from './form'; 15 | import { requestAPI } from './jupyter-project'; 16 | import { getProjectInfo } from './project'; 17 | import { 18 | CommandIDs, 19 | IProjectManager, 20 | PLUGIN_ID, 21 | Project, 22 | Templates 23 | } from './tokens'; 24 | import { ForeignCommandIDs, renderStringTemplate } from './utils'; 25 | import { createValidator } from './validator'; 26 | import { LabIcon } from '@jupyterlab/ui-components'; 27 | import { templateIcon } from './style'; 28 | 29 | /** 30 | * Generator of file from template 31 | */ 32 | class FileGenerator { 33 | /** 34 | * Constructor 35 | * 36 | * @param template File template description 37 | */ 38 | constructor(template: Templates.IFile) { 39 | this._name = template.name; 40 | this._endpoint = template.endpoint; 41 | this._destination = template.destination; 42 | if (template.icon) { 43 | this._icon = new LabIcon({ 44 | name: `${PLUGIN_ID}-${this._endpoint}`, 45 | svgstr: template.icon 46 | }); 47 | } 48 | if (template.schema) { 49 | this._bridge = new JSONSchemaBridge( 50 | template.schema, 51 | createValidator(template.schema) 52 | ); 53 | } 54 | } 55 | 56 | /** 57 | * Default destination within a project folder 58 | */ 59 | get destination(): string | null { 60 | return this._destination; 61 | } 62 | 63 | /** 64 | * Server endpoint to request for generating the file. 65 | */ 66 | get endpoint(): string { 67 | return decodeURIComponent(this._endpoint); 68 | } 69 | 70 | /** 71 | * Icon to display for this template 72 | */ 73 | get icon(): LabIcon | null { 74 | return this._icon; 75 | } 76 | 77 | /** 78 | * User friendly template name 79 | */ 80 | get name(): string { 81 | return decodeURIComponent(this._name); 82 | } 83 | 84 | /** 85 | * Schema to be handled by the form 86 | */ 87 | get schema(): JSONSchemaBridge | null { 88 | return this._bridge; 89 | } 90 | 91 | /** 92 | * Generate a file from the template with the given parameters 93 | * 94 | * @param path Path in which the file should be rendered 95 | * @param params Template parameters 96 | * @param project Project context 97 | */ 98 | async render( 99 | path: string, 100 | params: ReadonlyJSONObject, 101 | project: Project.IModel | null = null 102 | ): Promise { 103 | let fullpath = path; 104 | if (project) { 105 | if (this.destination) { 106 | const destination = renderStringTemplate(this.destination, project); 107 | fullpath = URLExt.join(path, destination); 108 | } 109 | // Add the project properties to the user specified parameters 110 | params = { 111 | ...params, 112 | jproject: getProjectInfo(project) 113 | }; 114 | } 115 | const endpoint = URLExt.join('files', this._endpoint, fullpath); 116 | return requestAPI(endpoint, { 117 | method: 'POST', 118 | body: JSON.stringify(params) 119 | }); 120 | } 121 | 122 | private _bridge: JSONSchemaBridge | null = null; 123 | private _destination: string | null = null; 124 | private _endpoint: string; 125 | private _icon: LabIcon | null = null; 126 | private _name: string; 127 | } 128 | 129 | /** 130 | * Activate the file menu entries to generate new CoSApp files. 131 | * 132 | * Note: this is actually called at the end of the activation function for the project plugin 133 | * 134 | * @param commands Application commands registry 135 | * @param browserFactory File browser factory 136 | * @param manager Project manager 137 | * @param fileSettings File template parameters 138 | * @param palette Commands palette 139 | * @param menu Application menu 140 | * @param launcher Application launcher 141 | */ 142 | export function activateFileGenerator( 143 | commands: CommandRegistry, 144 | browserFactory: IFileBrowserFactory, 145 | fileSettings: Templates.IFile[], 146 | manager: IProjectManager | null, 147 | palette: ICommandPalette, 148 | launcher: ILauncher | null, 149 | menu: IMainMenu | null 150 | ): void { 151 | const paletteCategory = 'Text Editor'; 152 | const launcherCategory = 'Templates'; 153 | 154 | const generators = fileSettings.map(settings => new FileGenerator(settings)); 155 | 156 | commands.addCommand(CommandIDs.newTemplateFile, { 157 | label: args => { 158 | let label = 'Template'; 159 | if (args) { 160 | const isPalette = (args['isPalette'] as boolean) || false; 161 | const name = (args['name'] as string) || label; 162 | label = isPalette ? 'New Template' : name; 163 | } 164 | return label; 165 | }, 166 | caption: args => 167 | args['endpoint'] 168 | ? `Create a new file from a template ${args['endpoint']}.` 169 | : 'Create a new file from a template.', 170 | icon: args => 171 | args['isPalette'] 172 | ? null 173 | : args['icon'] 174 | ? LabIcon.resolve({ icon: args['icon'] as string }) 175 | : templateIcon, 176 | execute: async args => { 177 | // 1. Find the file generator 178 | let endpoint = args['endpoint'] as string; 179 | let generator: FileGenerator; 180 | if (!endpoint) { 181 | // Request the user to select a generator except if there is only one 182 | if (generators.length === 1) { 183 | generator = generators[0]; 184 | } else { 185 | const results = await InputDialog.getItem({ 186 | items: generators.map(generator => generator.name), 187 | title: 'Select a template' 188 | }); 189 | 190 | if (!results.button.accept) { 191 | return; 192 | } 193 | 194 | generator = generators.find( 195 | generator => generator.name === results.value 196 | ); 197 | } 198 | 199 | endpoint = generator.endpoint; 200 | } else { 201 | generator = generators.find( 202 | generator => generator.endpoint === endpoint 203 | ); 204 | } 205 | 206 | // 2. Find where to generate the file 207 | let cwd: string; 208 | if (args['cwd'] && !args['isLauncher']) { 209 | // Launcher add automatically cwd to args - so we ignore that case 210 | // Use the argument path 211 | cwd = args['cwd'] as string; 212 | } else if (manager.project) { 213 | // Use the project path 214 | cwd = manager.project.path; 215 | } else { 216 | // Use the current path 217 | cwd = browserFactory.defaultBrowser.model.path; 218 | } 219 | 220 | // 3. Ask for parameters value 221 | let params = {}; 222 | if (generator.schema) { 223 | const userForm = await showForm({ 224 | schema: generator.schema, 225 | title: `Parameters of ${generator.name}` 226 | }); 227 | if (!userForm.button.accept) { 228 | return; 229 | } 230 | params = userForm.value; 231 | } 232 | 233 | try { 234 | const model = await generator.render(cwd, params, manager.project); 235 | commands.execute(ForeignCommandIDs.documentOpen, { 236 | path: model.path 237 | }); 238 | } catch (error) { 239 | console.error(`Fail to render ${generator.name}:\n${error}`); 240 | showErrorMessage(`Fail to render ${generator.name}`, error); 241 | } 242 | } 243 | }); 244 | 245 | if (launcher) { 246 | generators.forEach(generator => { 247 | launcher.add({ 248 | command: CommandIDs.newTemplateFile, 249 | category: launcherCategory, 250 | args: { 251 | isLauncher: true, 252 | name: generator.name, 253 | endpoint: generator.endpoint, 254 | icon: generator.icon ? generator.icon.name : null 255 | } 256 | }); 257 | }); 258 | } 259 | 260 | if (menu) { 261 | // Add the templates to the File->New submenu 262 | const fileMenu = menu.fileMenu; 263 | fileMenu.newMenu.addGroup( 264 | generators.map(generator => { 265 | return { 266 | command: CommandIDs.newTemplateFile, 267 | args: { 268 | name: generator.name, 269 | endpoint: generator.endpoint, 270 | icon: generator.icon ? generator.icon.name : null 271 | } 272 | }; 273 | }), 274 | -1 275 | ); 276 | } 277 | 278 | // Add the commands to the palette 279 | palette.addItem({ 280 | command: CommandIDs.newTemplateFile, 281 | category: paletteCategory, 282 | args: { isPalette: true } 283 | }); 284 | } 285 | -------------------------------------------------------------------------------- /src/form.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, ReactWidget } from '@jupyterlab/apputils'; 2 | import { ThemeProvider } from '@material-ui/core/styles'; 3 | import { JSONObject, PromiseDelegate } from '@lumino/coreutils'; 4 | import * as React from 'react'; 5 | import { AutoForm, AutoFields, ErrorsField } from 'uniforms-material'; 6 | import { Form } from './tokens'; 7 | import { getMuiTheme } from './theme'; 8 | import { Bridge } from 'uniforms'; 9 | 10 | /** 11 | * Show a form 12 | * 13 | * @param options Form options 14 | */ 15 | export async function showForm( 16 | options: Form.IOptions 17 | ): Promise> { 18 | return new FormDialog(options).launch(); 19 | } 20 | 21 | /** 22 | * Form within a JupyterLab dialog 23 | */ 24 | class FormDialog extends Dialog { 25 | /** 26 | * Form constructor 27 | * 28 | * @param options Form options 29 | */ 30 | constructor(options: Form.IOptions) { 31 | super({ 32 | ...options, 33 | body: new FormWidget(options.schema), 34 | buttons: [ 35 | Dialog.cancelButton({ label: options.cancelLabel }), 36 | Dialog.okButton({ label: options.okLabel }) 37 | ] 38 | }); 39 | 40 | this.addClass('jpproject-Form'); 41 | } 42 | 43 | /** 44 | * Handle the DOM events for the directory listing. 45 | * 46 | * @param event - The DOM event sent to the widget. 47 | * 48 | * #### Notes 49 | * This method implements the DOM `EventListener` interface and is 50 | * called in response to events on the panel's DOM node. It should 51 | * not be called directly by user code. 52 | */ 53 | handleEvent(event: Event): void { 54 | switch (event.type) { 55 | case 'focus': 56 | // Prevent recursion error with Material-UI combobox 57 | event.stopImmediatePropagation(); 58 | break; 59 | default: 60 | break; 61 | } 62 | super.handleEvent(event); 63 | } 64 | 65 | /** 66 | * Resolve the current form if it is valid. 67 | * 68 | * @param index - An optional index to the button to resolve. 69 | * 70 | * #### Notes 71 | * Will default to the defaultIndex. 72 | * Will resolve the current `show()` with the button value. 73 | * Will be a no-op if the dialog is not shown. 74 | */ 75 | resolve(index?: number): void { 76 | if (index === 0) { 77 | // Cancel button clicked 78 | super.resolve(index); 79 | } else { 80 | // index === 1 if ok button is clicked 81 | // index === undefined if Enter is pressed 82 | 83 | // this._body is private... Dialog API is bad for inheritance 84 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 85 | // @ts-ignore 86 | this._body 87 | .submit() 88 | .then(() => { 89 | super.resolve(index); 90 | }) 91 | .catch((reason: any) => { 92 | console.log(`Invalid form field:\n${reason}`); 93 | }); 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * Widget containing a form automatically constructed from 100 | * a uniform.Bridge 101 | */ 102 | class FormWidget extends ReactWidget implements Form.IWidget { 103 | /** 104 | * Form widget constructor 105 | * 106 | * @param schema Schema defining the form 107 | */ 108 | constructor(schema: Bridge) { 109 | super(); 110 | this._schema = schema; 111 | } 112 | 113 | /** 114 | * Get the form value 115 | */ 116 | getValue(): JSONObject | null { 117 | return this._model; 118 | } 119 | 120 | /** 121 | * Render the form 122 | */ 123 | render(): JSX.Element { 124 | const theme = getMuiTheme(); 125 | 126 | return ( 127 | 128 | { 131 | this._formRef = ref; 132 | }} 133 | schema={this._schema} 134 | onSubmit={(model: JSONObject): void => { 135 | this._model = model; 136 | }} 137 | > 138 |
139 | 140 |
141 | 142 |
143 |
144 | ); 145 | } 146 | 147 | /** 148 | * Submit the form 149 | * 150 | * The promise is resolved if the form is valid otherwise it is rejected. 151 | */ 152 | submit(): Promise { 153 | const submitPromise = new PromiseDelegate(); 154 | this._formRef 155 | .submit() 156 | .then(() => { 157 | submitPromise.resolve(); 158 | }) 159 | .catch(submitPromise.reject); 160 | return submitPromise.promise; 161 | } 162 | 163 | protected _formRef: typeof AutoForm; 164 | private _model: JSONObject | null = null; 165 | private _schema: Bridge; 166 | } 167 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { ICommandPalette, IThemeManager } from '@jupyterlab/apputils'; 6 | import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; 7 | import { IGitExtension } from '@jupyterlab/git'; 8 | import { ILauncher } from '@jupyterlab/launcher'; 9 | import { IMainMenu } from '@jupyterlab/mainmenu'; 10 | import { IStateDB } from '@jupyterlab/statedb'; 11 | import { IStatusBar } from '@jupyterlab/statusbar'; 12 | import { IEnvironmentManager } from 'jupyterlab_conda'; 13 | import { activateFileGenerator } from './filetemplates'; 14 | import { requestAPI } from './jupyter-project'; 15 | import { activateProjectManager } from './project'; 16 | import { setCurrentTheme } from './theme'; 17 | import { IProjectManager, PLUGIN_ID, Templates } from './tokens'; 18 | 19 | /** 20 | * Initialization data for the jupyter-project extension. 21 | */ 22 | const extension: JupyterFrontEndPlugin = { 23 | id: PLUGIN_ID, 24 | autoStart: true, 25 | activate: async ( 26 | app: JupyterFrontEnd, 27 | palette: ICommandPalette, 28 | browserFactory: IFileBrowserFactory, 29 | state: IStateDB, 30 | launcher: ILauncher | null, 31 | menu: IMainMenu | null, 32 | statusbar: IStatusBar | null, 33 | themeManager: IThemeManager | null, 34 | condaManager: IEnvironmentManager | null, 35 | git: IGitExtension | null 36 | ): Promise => { 37 | const { commands } = app; 38 | let manager: IProjectManager | null = null; 39 | 40 | try { 41 | const settings = await requestAPI('settings', { 42 | method: 'GET' 43 | }); 44 | 45 | if (settings.projectTemplate) { 46 | manager = activateProjectManager( 47 | app, 48 | state, 49 | browserFactory, 50 | settings.projectTemplate, 51 | palette, 52 | condaManager, 53 | git, 54 | launcher, 55 | menu, 56 | statusbar 57 | ); 58 | } 59 | 60 | if (settings.fileTemplates && settings.fileTemplates.length >= 0) { 61 | activateFileGenerator( 62 | commands, 63 | browserFactory, 64 | settings.fileTemplates, 65 | manager, 66 | palette, 67 | launcher, 68 | menu 69 | ); 70 | } 71 | 72 | console.log('JupyterLab extension jupyter-project is activated!'); 73 | } catch (error) { 74 | console.error(`Fail to activate ${PLUGIN_ID}`, error); 75 | } 76 | 77 | app.restored.then(() => { 78 | themeManager.themeChanged.connect((_, changedTheme) => { 79 | setCurrentTheme(changedTheme.newValue); 80 | }); 81 | setCurrentTheme(themeManager.theme); 82 | }); 83 | 84 | return manager; 85 | }, 86 | requires: [ICommandPalette, IFileBrowserFactory, IStateDB], 87 | optional: [ 88 | ILauncher, 89 | IMainMenu, 90 | IStatusBar, 91 | IThemeManager, 92 | IEnvironmentManager, 93 | IGitExtension 94 | ] 95 | }; 96 | 97 | export default extension; 98 | -------------------------------------------------------------------------------- /src/jupyter-project.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | 3 | import { ServerConnection } from '@jupyterlab/services'; 4 | 5 | /** 6 | * Call the API extension 7 | * 8 | * @param endPoint API REST end point for the extension 9 | * @param init Initial values for the request 10 | * @returns The response body interpreted as JSON 11 | */ 12 | export async function requestAPI( 13 | endPoint = '', 14 | init: RequestInit = {} 15 | ): Promise { 16 | // Make request to Jupyter API 17 | const settings = ServerConnection.makeSettings(); 18 | const requestUrl = URLExt.join( 19 | settings.baseUrl, 20 | 'jupyter-project', // API Namespace 21 | endPoint 22 | ); 23 | 24 | let response: Response; 25 | try { 26 | response = await ServerConnection.makeRequest(requestUrl, init, settings); 27 | } catch (error) { 28 | throw new ServerConnection.NetworkError(error); 29 | } 30 | 31 | let data: any = await response.text(); 32 | 33 | if (data.length > 0) { 34 | try { 35 | data = JSON.parse(data); 36 | } catch (error) { 37 | console.log('Not a JSON response body.', response); 38 | } 39 | } 40 | 41 | if (!response.ok) { 42 | throw new ServerConnection.ResponseError(response, data.message || data); 43 | } 44 | 45 | return data; 46 | } 47 | -------------------------------------------------------------------------------- /src/project.ts: -------------------------------------------------------------------------------- 1 | import { JupyterFrontEnd } from '@jupyterlab/application'; 2 | import { 3 | Dialog, 4 | ICommandPalette, 5 | InputDialog, 6 | showDialog, 7 | showErrorMessage 8 | } from '@jupyterlab/apputils'; 9 | import { PathExt, URLExt } from '@jupyterlab/coreutils'; 10 | import { FileDialog, IFileBrowserFactory } from '@jupyterlab/filebrowser'; 11 | import { IGitExtension } from '@jupyterlab/git'; 12 | import { ILauncher } from '@jupyterlab/launcher'; 13 | import { IMainMenu } from '@jupyterlab/mainmenu'; 14 | import { Contents } from '@jupyterlab/services'; 15 | import { IStateDB } from '@jupyterlab/statedb'; 16 | import { IStatusBar } from '@jupyterlab/statusbar'; 17 | import { CommandRegistry } from '@lumino/commands'; 18 | import { 19 | JSONExt, 20 | PromiseDelegate, 21 | ReadonlyJSONObject 22 | } from '@lumino/coreutils'; 23 | import { Signal, Slot } from '@lumino/signaling'; 24 | import { Menu } from '@lumino/widgets'; 25 | import { Conda, IEnvironmentManager } from 'jupyterlab_conda'; 26 | import { INotification } from 'jupyterlab_toastify'; 27 | import JSONSchemaBridge from 'uniforms-bridge-json-schema'; 28 | import YAML from 'yaml'; 29 | import { showForm } from './form'; 30 | import { requestAPI } from './jupyter-project'; 31 | import { createProjectStatus } from './statusbar'; 32 | import { 33 | CommandIDs, 34 | IProjectManager, 35 | PLUGIN_ID, 36 | Project, 37 | Templates 38 | } from './tokens'; 39 | import { ForeignCommandIDs, renderStringTemplate } from './utils'; 40 | import { createValidator } from './validator'; 41 | import { projectIcon } from './style'; 42 | 43 | /** 44 | * Default conda environment file 45 | */ 46 | const ENVIRONMENT_FILE = 'environment.yml'; 47 | /** 48 | * List of forbidden character in conda environment name 49 | */ 50 | const FORBIDDEN_ENV_CHAR = /[/\s:#]/gi; 51 | /** 52 | * Project manager state ID 53 | */ 54 | const STATE_ID = `${PLUGIN_ID}:project`; 55 | 56 | /** 57 | * Namespace for Conda 58 | */ 59 | namespace CondaEnv { 60 | /** 61 | * Interface for conda environment file specification 62 | */ 63 | export interface IEnvSpecs { 64 | /** 65 | * Channels to use 66 | */ 67 | channels: string[]; 68 | /** 69 | * Packages list 70 | */ 71 | dependencies?: string[]; 72 | /** 73 | * Environment name 74 | */ 75 | name: string; 76 | /** 77 | * Environment prefix 78 | */ 79 | prefix: string; 80 | } 81 | } 82 | 83 | /** 84 | * Project Manager 85 | */ 86 | class ProjectManager implements IProjectManager { 87 | /** 88 | * Project Manager constructor 89 | * 90 | * @param settings Project template settings 91 | * @param state Application state handler 92 | * @param appRestored Promise that resolve when the application is restored 93 | */ 94 | constructor( 95 | settings: Templates.IProject, 96 | state: IStateDB, 97 | appRestored: Promise 98 | ) { 99 | this._configurationFilename = settings.configurationFilename; 100 | this._state = state; 101 | 102 | if (settings.defaultCondaPackages) { 103 | this._defaultCondaPackages = settings.defaultCondaPackages; 104 | } 105 | 106 | if (settings.defaultPath) { 107 | this._defaultPath = settings.defaultPath; 108 | } 109 | 110 | this._editableInstall = settings.editableInstall; 111 | 112 | if (settings.schema) { 113 | this._schema = new JSONSchemaBridge( 114 | settings.schema, 115 | createValidator(settings.schema) 116 | ); 117 | } 118 | 119 | // Restore previously loaded project 120 | appRestored 121 | .then(() => this._state.fetch(STATE_ID)) 122 | .then(project => { 123 | this._setProject(project as any, 'open'); 124 | 125 | if (this.project) { 126 | this.open(this.project.path); 127 | } 128 | this._restored.resolve(); 129 | }) 130 | .catch(error => { 131 | const message = 'Unable to restore saved project.'; 132 | console.error(message, error); 133 | this.reset(); 134 | this._restored.reject(message); 135 | }); 136 | } 137 | 138 | /** 139 | * Name of the project configuration file 140 | */ 141 | get configurationFilename(): string { 142 | return this._configurationFilename; 143 | } 144 | 145 | /** 146 | * Default conda package to install in a new project - if no ENVIRONMENT_FILE found 147 | */ 148 | get defaultCondaPackages(): string | null { 149 | return this._defaultCondaPackages; 150 | } 151 | 152 | /** 153 | * Default path to open in a project 154 | */ 155 | get defaultPath(): string { 156 | let defaultPath = this._defaultPath; 157 | if (this.project) { 158 | defaultPath = renderStringTemplate(defaultPath, this.project); 159 | } 160 | return defaultPath; 161 | } 162 | 163 | /** 164 | * Should the project be installed in pip editable mode 165 | * in the conda environment? 166 | */ 167 | get editableInstall(): boolean { 168 | return this._editableInstall; 169 | } 170 | 171 | /** 172 | * Active project 173 | */ 174 | get project(): Project.IModel | null { 175 | return this._project; 176 | } 177 | 178 | /** 179 | * A signal emitted when the project changes. 180 | */ 181 | get projectChanged(): Signal { 182 | return this._projectChanged; 183 | } 184 | 185 | /** 186 | * A promise resolved when the project state has been restored. 187 | */ 188 | get restored(): Promise { 189 | return this._restored.promise; 190 | } 191 | 192 | /** 193 | * Schema to be handled by the form 194 | */ 195 | get schema(): JSONSchemaBridge | null { 196 | return this._schema; 197 | } 198 | 199 | /** 200 | * Generate a new project in path 201 | * 202 | * @param path Path where to generate the project 203 | * @param options Project template parameters 204 | */ 205 | async create( 206 | path: string, 207 | options: ReadonlyJSONObject 208 | ): Promise { 209 | let endpoint = 'projects'; 210 | if (path.length > 0) { 211 | endpoint = URLExt.join(endpoint, path); 212 | } 213 | 214 | const answer = await requestAPI<{ project: Project.IModel }>(endpoint, { 215 | method: 'POST', 216 | body: JSON.stringify(options) 217 | }); 218 | 219 | this._setProject(answer.project, 'new'); 220 | return this.project; 221 | } 222 | 223 | /** 224 | * Close the current project 225 | * 226 | * @param changeType Type of change; default 'open' 227 | */ 228 | async close(changeType: Project.ChangeType = 'open'): Promise { 229 | await this.open('', changeType); 230 | } 231 | 232 | /** 233 | * Delete the current project 234 | */ 235 | async delete(): Promise { 236 | let endpoint = 'projects'; 237 | if (this.project.path.length > 0) { 238 | endpoint = URLExt.join(endpoint, this.project.path); 239 | } 240 | 241 | // Close the project before requesting its deletion 242 | await this.close('delete'); 243 | 244 | return requestAPI(endpoint, { 245 | method: 'DELETE' 246 | }); 247 | } 248 | 249 | /** 250 | * Open the folder path as the active project 251 | * 252 | * If path is empty, close the active project. 253 | * 254 | * @param path Project folder path 255 | * @param changeType Type of change; default 'open' 256 | * @returns The opened project model 257 | */ 258 | async open( 259 | path: string, 260 | changeType: Project.ChangeType = 'open' 261 | ): Promise { 262 | let endpoint = 'projects'; 263 | if (path.length > 0) { 264 | endpoint = URLExt.join(endpoint, path); 265 | } 266 | 267 | const answer = await requestAPI<{ project: Project.IModel }>(endpoint, { 268 | method: 'GET' 269 | }); 270 | 271 | this._setProject(answer.project, changeType); 272 | return this.project; 273 | } 274 | 275 | /** 276 | * Reset current state 277 | */ 278 | reset(): void { 279 | this._setProject(null, 'open'); 280 | } 281 | 282 | /** 283 | * Set the active project 284 | * 285 | * null = no active project 286 | * 287 | * @param newProject Project model 288 | * @param changeType Type of change 289 | */ 290 | protected _setProject( 291 | newProject: Project.IModel | null, 292 | changeType: Project.ChangeType 293 | ): void { 294 | let changed = this._project !== newProject; 295 | if (!changed && !this._project && !newProject) { 296 | changed = !JSONExt.deepEqual(newProject as any, this._project as any); 297 | } 298 | 299 | if (changed) { 300 | const oldProject = { ...this._project }; 301 | this._project = newProject; 302 | this._state.save(STATE_ID, this._project as any); 303 | this._projectChanged.emit({ 304 | type: changeType, 305 | newValue: this._project, 306 | oldValue: oldProject 307 | }); 308 | } 309 | } 310 | 311 | // Private attributes 312 | private _configurationFilename: string; 313 | private _defaultCondaPackages: string | null = null; 314 | private _defaultPath: string | null = null; 315 | private _editableInstall = true; 316 | private _project: Project.IModel | null = null; 317 | private _projectChanged = new Signal(this); 318 | private _restored = new PromiseDelegate(); 319 | private _schema: JSONSchemaBridge | null = null; 320 | private _state: IStateDB; 321 | } 322 | 323 | /** 324 | * Build the project info from project model 325 | * @param project The project model 326 | * @returns The project info 327 | */ 328 | export function getProjectInfo(project: Project.IModel): Project.IInfo { 329 | return { 330 | ...project, 331 | dirname: PathExt.dirname(project.path) 332 | }; 333 | } 334 | 335 | /** 336 | * Reset the current application workspace: 337 | * - Save all opened files 338 | * - Close all opened files 339 | * - Go to the root path 340 | * 341 | * @param commands Commands registry 342 | */ 343 | async function resetWorkspace(commands: CommandRegistry): Promise { 344 | await commands.execute(ForeignCommandIDs.saveAll); 345 | await commands.execute(ForeignCommandIDs.closeAll); 346 | 347 | await commands.execute(ForeignCommandIDs.goTo, { 348 | path: '/' 349 | }); 350 | } 351 | 352 | /** 353 | * Activate the project manager plugin 354 | * 355 | * @param app The application object 356 | * @param state The application state handler 357 | * @param browserFactory The file browser factory 358 | * @param settings The project template settings 359 | * @param palette The command palette 360 | * @param condaManager The Conda extension service 361 | * @param git The Git extension service 362 | * @param launcher The application launcher 363 | * @param menu The application menu 364 | * @param statusbar The application status bar 365 | */ 366 | export function activateProjectManager( 367 | app: JupyterFrontEnd, 368 | state: IStateDB, 369 | browserFactory: IFileBrowserFactory, 370 | settings: Templates.IProject, 371 | palette: ICommandPalette, 372 | condaManager: IEnvironmentManager | null, 373 | git: IGitExtension | null, 374 | launcher: ILauncher | null, 375 | menu: IMainMenu | null, 376 | statusbar: IStatusBar | null 377 | ): IProjectManager { 378 | const { commands, serviceManager } = app; 379 | const filebrowser = browserFactory.defaultBrowser.model; 380 | const category = 'Project'; 381 | 382 | if (!settings.defaultCondaPackages) { 383 | condaManager = null; 384 | } 385 | if (!settings.withGit) { 386 | git = null; 387 | } 388 | 389 | // Cannot blocking wait for the application otherwise this will bock 390 | // the all application at launch time 391 | const manager = new ProjectManager(settings, state, app.restored); 392 | 393 | // Update the conda environment description when closing a project 394 | // or if the associated environment changes. 395 | const condaSlot: Slot = ( 396 | _, 397 | change 398 | ) => { 399 | if (manager.project && change.environment === manager.project.environment) { 400 | Private.updateEnvironmentSpec( 401 | manager.project, 402 | condaManager, 403 | serviceManager.contents, 404 | commands 405 | ).catch(error => { 406 | console.error( 407 | `Fail to update environment '${change.environment} specifications.`, 408 | error 409 | ); 410 | }); 411 | } 412 | }; 413 | if (condaManager) { 414 | manager.projectChanged.connect((_, change) => { 415 | if ( 416 | change.type !== 'delete' && 417 | change.oldValue && 418 | change.oldValue.environment 419 | ) { 420 | Private.updateEnvironmentSpec( 421 | change.oldValue, 422 | condaManager, 423 | serviceManager.contents, 424 | commands 425 | ).catch(error => { 426 | console.error( 427 | `Fail to update environment '${change.oldValue.environment} specifications.`, 428 | error 429 | ); 430 | }); 431 | } 432 | }); 433 | } 434 | 435 | // Update the conda environment when git HEAD changes 436 | const gitSlot: Slot = git => { 437 | if ( 438 | condaManager && 439 | manager.project && 440 | manager.project.environment && 441 | // TODO git path handling is not consistent with jupyter ecosystem... 442 | '/' + git.getRelativeFilePath() === manager.project.path 443 | ) { 444 | const envName = manager.project.environment; 445 | const branch = git.currentBranch ? git.currentBranch.name : 'unknown'; 446 | const errorMsg = `Fail to update conda environment after git HEAD changed on branch ${branch}`; 447 | let toastId: React.ReactText = null; 448 | Private.compareSpecification( 449 | condaManager, 450 | manager.project, 451 | serviceManager.contents 452 | ) 453 | .then(async ({ isIdentical, file, notInFile }) => { 454 | if (!isIdentical && file) { 455 | toastId = await INotification.inProgress( 456 | `Updating environment for branch ${branch}...` 457 | ); 458 | condaManager 459 | .getPackageManager() 460 | .packageChanged.disconnect(condaSlot); 461 | try { 462 | toastId = await Private.updateEnvironment( 463 | envName, 464 | file, 465 | notInFile, 466 | condaManager, 467 | toastId 468 | ); 469 | } finally { 470 | condaManager 471 | .getPackageManager() 472 | .packageChanged.connect(condaSlot); 473 | } 474 | if (toastId) { 475 | return INotification.update({ 476 | toastId, 477 | message: `Environment ${envName} updated for branch ${branch}`, 478 | type: 'success', 479 | autoClose: 5000 480 | }); 481 | } 482 | } 483 | }) 484 | .catch(error => { 485 | console.error(errorMsg, error); 486 | if (toastId) { 487 | INotification.update({ 488 | toastId, 489 | message: errorMsg, 490 | type: 'error' 491 | }); 492 | } 493 | }); 494 | } 495 | }; 496 | 497 | manager.restored.then(() => { 498 | if (manager.project && condaManager) { 499 | // Apply kernel whitelist 500 | serviceManager.kernelspecs.refreshSpecs(); 501 | 502 | condaManager.getPackageManager().packageChanged.connect(condaSlot); 503 | if (git) { 504 | git.headChanged.connect(gitSlot); 505 | } 506 | } 507 | }); 508 | 509 | commands.addCommand(CommandIDs.newProject, { 510 | caption: 'Create a new project.', 511 | execute: async args => { 512 | const cwd: string = (args['cwd'] as string) || filebrowser.path; 513 | let toastId = args['toastId'] as React.ReactText; 514 | const cleanToast = toastId === undefined; 515 | 516 | let params = {}; 517 | if (manager.schema) { 518 | const userForm = await showForm({ 519 | schema: manager.schema, 520 | title: 'Project Parameters' 521 | }); 522 | if (!userForm.button.accept) { 523 | return; 524 | } 525 | params = userForm.value; 526 | } 527 | 528 | try { 529 | const message = 'Creating project...'; 530 | if (toastId) { 531 | INotification.update({ 532 | toastId, 533 | message 534 | }); 535 | } else { 536 | toastId = await INotification.inProgress(message); 537 | } 538 | const model = await manager.create(cwd, params); 539 | 540 | // Initialize as Git repository 541 | if (git) { 542 | try { 543 | INotification.update({ 544 | toastId, 545 | message: 'Initializing Git...' 546 | }); 547 | // TODO @jupyterlab/git does not respect frontend path... 548 | // await git.init(model.path); 549 | await filebrowser.cd(model.path); 550 | await commands.execute(ForeignCommandIDs.gitInit); 551 | } catch (error) { 552 | console.error( 553 | 'Fail to initialize the project as Git repository.', 554 | error 555 | ); 556 | } 557 | } 558 | 559 | await commands.execute(CommandIDs.openProject, { 560 | path: model.path, 561 | toastId 562 | }); 563 | 564 | if (git) { 565 | try { 566 | // Add all files and commit 567 | await git.addAllUntracked(); 568 | await git.commit(`Initialize project ${model.name}`); 569 | } catch (error) { 570 | console.error('Fail to commit the project files.', error); 571 | } 572 | } 573 | 574 | if (cleanToast) { 575 | INotification.update({ 576 | toastId, 577 | message: `Project '${model.name}' successfully created.`, 578 | type: 'success', 579 | autoClose: 5000 580 | }); 581 | } 582 | } catch (error) { 583 | const message = 'Fail to create the project'; 584 | await manager.close(); 585 | console.error(message, error); 586 | 587 | INotification.update({ 588 | toastId, 589 | message, 590 | type: 'error' 591 | }); 592 | } 593 | }, 594 | icon: args => 595 | args['isPalette'] || !args['isLauncher'] ? null : projectIcon, 596 | label: args => (!args['isLauncher'] ? 'New Project' : 'New') 597 | }); 598 | 599 | commands.addCommand(CommandIDs.importProject, { 600 | caption: 'Import a project by cloning a Git repository', 601 | execute: async args => { 602 | if (!git) { 603 | showErrorMessage( 604 | 'Git extension not available', 605 | 'The `@jupyterlab/git` extension is not installed.' 606 | ); 607 | return; 608 | } 609 | 610 | const path: string = (args['cwd'] as string) || filebrowser.path; 611 | let toastId = args['toastId'] as React.ReactText; 612 | const cleanToast = toastId === undefined; 613 | 614 | let url = args['url'] as string; 615 | if (!url) { 616 | const answer = await InputDialog.getText({ 617 | title: 'URL of the Git repository to import', 618 | placeholder: 'https://my.git.server/project/repository.git' 619 | }); 620 | 621 | if (!answer.button.accept) { 622 | return; 623 | } 624 | 625 | url = answer.value; 626 | } 627 | 628 | try { 629 | const message = 'Importing project...'; 630 | if (toastId) { 631 | INotification.update({ 632 | toastId, 633 | message 634 | }); 635 | } else { 636 | toastId = await INotification.inProgress(message); 637 | } 638 | 639 | await git.clone(path, url); 640 | 641 | const folderName = PathExt.basename(url, 'git'); 642 | await commands.execute(CommandIDs.openProject, { 643 | path: PathExt.join(path, folderName), 644 | toastId 645 | }); 646 | 647 | if (cleanToast) { 648 | INotification.update({ 649 | toastId, 650 | message: `Project successfully import from '${url}'.`, 651 | type: 'success', 652 | autoClose: 5000 653 | }); 654 | } 655 | } catch (error) { 656 | const message = 'Fail to import the project'; 657 | await manager.close(); 658 | console.error(message, error); 659 | 660 | INotification.update({ 661 | toastId, 662 | message, 663 | type: 'error' 664 | }); 665 | } 666 | }, 667 | icon: args => (args['isLauncher'] ? projectIcon : null), 668 | isVisible: () => git !== null, 669 | label: args => (!args['isLauncher'] ? 'Import Project' : 'Import') 670 | }); 671 | 672 | commands.addCommand(CommandIDs.openProject, { 673 | label: args => (!args['isLauncher'] ? 'Open Project' : 'Open'), 674 | caption: 'Open a project', 675 | iconClass: args => 676 | args['isLauncher'] ? 'jp-JupyterProjectIcon fa fa-folder-open fa-4x' : '', 677 | execute: async args => { 678 | // 1. Get the configuration file 679 | const path = args['path'] as string; 680 | let toastId = args['toastId'] as React.ReactText; 681 | const cleanToast = toastId === undefined; 682 | 683 | let configurationFile: Contents.IModel; 684 | if (path) { 685 | // From commands arguments 686 | const filePath = PathExt.join(path, manager.configurationFilename); 687 | try { 688 | configurationFile = await app.serviceManager.contents.get(filePath, { 689 | content: false 690 | }); 691 | } catch (reason) { 692 | console.error(reason); 693 | throw new Error(`Unable to get the configuration file ${filePath}.`); 694 | } 695 | } else { 696 | // From the user through an open file dialog 697 | const result = await FileDialog.getOpenFiles({ 698 | filter: value => value.name === manager.configurationFilename, 699 | manager: filebrowser.manager, 700 | title: 'Select the project file' 701 | }); 702 | 703 | if (result.button.accept) { 704 | configurationFile = result.value[0]; // Return the current directory if nothing is selected 705 | } 706 | } 707 | 708 | if (!configurationFile || configurationFile.type === 'directory') { 709 | return; // Bail early 710 | } 711 | 712 | // 2. Open the project 713 | try { 714 | const message = 'Opening project...'; 715 | if (toastId) { 716 | INotification.update({ 717 | toastId, 718 | message 719 | }); 720 | } else { 721 | toastId = await INotification.inProgress(message); 722 | } 723 | 724 | const model = await manager.open( 725 | PathExt.dirname(configurationFile.path) 726 | ); 727 | 728 | if (manager.defaultPath) { 729 | await commands.execute(ForeignCommandIDs.openPath, { 730 | path: PathExt.join(model.path, manager.defaultPath) 731 | }); 732 | } 733 | 734 | if (condaManager) { 735 | condaManager.getPackageManager().packageChanged.disconnect(condaSlot); 736 | if (git) { 737 | git.headChanged.disconnect(gitSlot); 738 | } 739 | toastId = await Private.openProject( 740 | manager, 741 | serviceManager.contents, 742 | condaManager, 743 | commands, 744 | toastId 745 | ); 746 | // Re-open to set the kernel whitelist 747 | if (manager.project.environment) { 748 | await manager.open(manager.project.path); 749 | } 750 | condaManager.getPackageManager().packageChanged.connect(condaSlot); 751 | if (git) { 752 | git.headChanged.connect(gitSlot); 753 | } 754 | 755 | // Force refreshing session to take into account the new environment 756 | serviceManager.kernelspecs.refreshSpecs(); 757 | } 758 | 759 | if (cleanToast) { 760 | if (toastId) { 761 | INotification.update({ 762 | toastId, 763 | message: `Project '${model.name}' is ready.`, 764 | type: 'success', 765 | autoClose: 5000 766 | }); 767 | } 768 | } else { 769 | if (toastId === null) { 770 | throw new Error('Fail to open conda environment'); 771 | } 772 | } 773 | } catch (error) { 774 | const message = 'Fail to open project'; 775 | console.error(message, error); 776 | 777 | INotification.update({ 778 | toastId, 779 | message, 780 | type: 'error' 781 | }); 782 | } 783 | } 784 | }); 785 | 786 | commands.addCommand(CommandIDs.closeProject, { 787 | label: 'Close Project', 788 | caption: 'Close the current CoSApp project', 789 | isEnabled: () => manager.project !== null, 790 | execute: async () => { 791 | try { 792 | await resetWorkspace(commands); 793 | await manager.close(); 794 | if (condaManager) { 795 | // Force refreshing session to take into account the whitelist suppression 796 | serviceManager.kernelspecs.refreshSpecs(); 797 | 798 | condaManager.getPackageManager().packageChanged.disconnect(condaSlot); 799 | 800 | if (git) { 801 | git.headChanged.disconnect(gitSlot); 802 | } 803 | } 804 | } catch (error) { 805 | showErrorMessage('Failed to close the current project', error); 806 | } 807 | } 808 | }); 809 | 810 | commands.addCommand(CommandIDs.deleteProject, { 811 | label: 'Delete Project', 812 | caption: 'Delete a Project', 813 | isEnabled: () => manager.project !== null, 814 | execute: async () => { 815 | const condaEnvironment = manager.project.environment; 816 | const projectName = manager.project.name; 817 | 818 | const userChoice = await showDialog({ 819 | title: 'Delete', 820 | // eslint-disable-next-line prettier/prettier 821 | body: `Are you sure you want to permanently delete the project '${manager.project.name}' in ${manager.project.path}?`, 822 | buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'DELETE' })] 823 | }); 824 | if (!userChoice.button.accept) { 825 | return; 826 | } 827 | 828 | let toastId = await INotification.inProgress( 829 | `Removing project '${projectName}'...` 830 | ); 831 | // 1. Remove asynchronously the folder 832 | await resetWorkspace(commands); 833 | try { 834 | await manager.delete(); 835 | } catch (error) { 836 | const message = 'Failed to remove the project folder'; 837 | console.error(message, error); 838 | INotification.update({ toastId, message, type: 'error' }); 839 | toastId = null; 840 | } 841 | if (condaEnvironment && condaManager) { 842 | // Force refreshing session to take into account the whitelist suppression 843 | serviceManager.kernelspecs.refreshSpecs(); 844 | 845 | // 2. Remove associated conda environment 846 | const message = `Removing conda environment '${condaEnvironment}'...`; 847 | if (toastId) { 848 | INotification.update({ toastId, message }); 849 | } else { 850 | toastId = await INotification.inProgress(message); 851 | } 852 | 853 | condaManager.getPackageManager().packageChanged.disconnect(condaSlot); 854 | if (git) { 855 | git.headChanged.disconnect(gitSlot); 856 | } 857 | try { 858 | await condaManager.remove(condaEnvironment); 859 | 860 | // Force refreshing session to take into account the removed environment 861 | serviceManager.kernelspecs.refreshSpecs(); 862 | } catch (error) { 863 | const message = `Failed to remove the project environment ${condaEnvironment}`; 864 | console.error(message, error); 865 | 866 | INotification.update({ toastId, message, type: 'error' }); 867 | toastId = null; 868 | } 869 | } 870 | if (toastId) { 871 | INotification.update({ 872 | toastId, 873 | message: `Project '${projectName}' removed.`, 874 | type: 'success', 875 | autoClose: 5000 876 | }); 877 | } 878 | } 879 | }); 880 | 881 | if (launcher) { 882 | // Add Project Cards 883 | [ 884 | CommandIDs.newProject, 885 | CommandIDs.openProject, 886 | CommandIDs.importProject 887 | ].forEach(command => { 888 | launcher.add({ 889 | command, 890 | args: { isLauncher: true }, 891 | category 892 | }); 893 | }); 894 | } 895 | 896 | const projectCommands = [ 897 | CommandIDs.newProject, 898 | CommandIDs.importProject, 899 | CommandIDs.openProject, 900 | CommandIDs.closeProject, 901 | CommandIDs.deleteProject 902 | ]; 903 | 904 | if (menu) { 905 | const submenu = new Menu({ commands }); 906 | submenu.title.label = 'Project'; 907 | projectCommands.forEach(command => { 908 | submenu.addItem({ 909 | command 910 | }); 911 | }); 912 | // Add `Project` entries as submenu of `File` 913 | menu.fileMenu.addGroup( 914 | [ 915 | { 916 | submenu, 917 | type: 'submenu' 918 | }, 919 | { 920 | type: 'separator' 921 | } 922 | ], 923 | 0 924 | ); 925 | } 926 | 927 | projectCommands.forEach(command => { 928 | palette.addItem({ 929 | command, 930 | category, 931 | args: { isPalette: true } 932 | }); 933 | }); 934 | 935 | if (statusbar) { 936 | statusbar.registerStatusItem(`${PLUGIN_ID}:project-status`, { 937 | align: 'left', 938 | item: createProjectStatus({ manager }), 939 | rank: -3 940 | }); 941 | } 942 | 943 | return manager; 944 | } 945 | 946 | /* eslint-disable no-inner-declarations */ 947 | namespace Private { 948 | /** 949 | * Open a project folder from its configuration file. 950 | * If no conda environment exists, creates one if requested. 951 | * 952 | * @param manager Project manager 953 | * @param contentService JupyterLab content service 954 | * @param conda Conda environment manager 955 | * @param toastId Toast ID to be updated with user information 956 | * 957 | * @returns The toast ID to be updated or null if it is dismissed 958 | */ 959 | export async function openProject( 960 | manager: ProjectManager, 961 | contentService: Contents.IManager, 962 | conda: IEnvironmentManager, 963 | commands: CommandRegistry, 964 | toastId: React.ReactText 965 | ): Promise { 966 | const model = manager.project; 967 | let environmentName = ( 968 | model.environment || model.name.replace(FORBIDDEN_ENV_CHAR, '_') 969 | ).toLocaleLowerCase(); 970 | 971 | const foundEnvironment = (await conda.environments).find( 972 | value => value.name.toLocaleLowerCase() === environmentName 973 | ); 974 | 975 | const { isIdentical, file, notInFile } = await Private.compareSpecification( 976 | conda, 977 | model, 978 | contentService 979 | ); 980 | 981 | if (foundEnvironment) { 982 | environmentName = foundEnvironment.name; 983 | 984 | if (!isIdentical && file) { 985 | toastId = await updateEnvironment( 986 | environmentName, 987 | file, 988 | notInFile, 989 | conda, 990 | toastId 991 | ); 992 | } 993 | } else { 994 | // Create the environment from the requirements 995 | INotification.update({ 996 | toastId, 997 | message: `Creating conda environment ${environmentName}... Please wait` 998 | }); 999 | 1000 | try { 1001 | if (file) { 1002 | // Import an environment 1003 | await conda.import(environmentName, file, ENVIRONMENT_FILE); 1004 | } else { 1005 | // Create an environment 1006 | await conda.create(environmentName, manager.defaultCondaPackages); 1007 | await updateEnvironmentSpec( 1008 | { ...model, environment: environmentName }, 1009 | conda, 1010 | contentService, 1011 | commands 1012 | ); 1013 | } 1014 | if (manager.editableInstall) { 1015 | await conda.getPackageManager(environmentName).develop(model.path); 1016 | } 1017 | } catch (error) { 1018 | const message = `Fail to create the environment for ${model.name}`; 1019 | console.error(message, error); 1020 | INotification.update({ toastId, message, type: 'error' }); 1021 | return null; 1022 | } 1023 | } 1024 | 1025 | // Communicate through the project environment change. 1026 | if (model.environment !== environmentName) { 1027 | const oldEnvironment = model.environment; 1028 | model.environment = environmentName; 1029 | manager.projectChanged.emit({ 1030 | type: 'open', 1031 | oldValue: { 1032 | ...model, 1033 | environment: oldEnvironment 1034 | }, 1035 | newValue: model 1036 | }); 1037 | } 1038 | 1039 | // Update the config file 1040 | const filePath = PathExt.join(model.path, manager.configurationFilename); 1041 | // Remove `path` if it exists - as it is user specific 1042 | const toSave = { ...model }; 1043 | delete toSave.path; 1044 | await contentService.save(filePath, { 1045 | type: 'file', 1046 | format: 'text', 1047 | content: JSON.stringify(toSave) 1048 | }); 1049 | 1050 | return toastId; 1051 | } 1052 | 1053 | export async function updateEnvironment( 1054 | environmentName: string, 1055 | file: string, 1056 | notInFile: Set, 1057 | conda: IEnvironmentManager, 1058 | toastId: React.ReactText 1059 | ): Promise { 1060 | INotification.update({ 1061 | toastId, 1062 | message: `Updating conda environment ${environmentName}... Please wait` 1063 | }); 1064 | 1065 | try { 1066 | // 1. Remove package not in the environment specification file 1067 | if (notInFile && notInFile.size > 0) { 1068 | await conda.getPackageManager().remove([...notInFile], environmentName); 1069 | } 1070 | // 2. Update the environment according to the file 1071 | await conda.update(environmentName, file, ENVIRONMENT_FILE); 1072 | } catch (error) { 1073 | const message = `Fail to update environment ${environmentName}`; 1074 | console.error(message, error); 1075 | INotification.update({ toastId, message, type: 'error' }); 1076 | toastId = null; 1077 | } 1078 | return toastId; 1079 | } 1080 | 1081 | export async function updateEnvironmentSpec( 1082 | project: Project.IModel | null, 1083 | condaManager: IEnvironmentManager | null, 1084 | contents: Contents.IManager, 1085 | commands: CommandRegistry 1086 | ): Promise { 1087 | if (project) { 1088 | const { isIdentical, conda } = await compareSpecification( 1089 | condaManager, 1090 | project, 1091 | contents 1092 | ); 1093 | 1094 | if (!isIdentical && conda) { 1095 | const specPath = PathExt.join(project.path, ENVIRONMENT_FILE); 1096 | await contents.save(specPath, { 1097 | type: 'file', 1098 | format: 'text', 1099 | content: conda 1100 | }); 1101 | 1102 | INotification.info( 1103 | `Environment '${project.environment}' specifications updated.`, 1104 | { 1105 | autoClose: 5000, 1106 | buttons: [ 1107 | { 1108 | label: 'Open file', 1109 | callback: (): void => { 1110 | commands.execute(ForeignCommandIDs.openPath, { 1111 | path: specPath 1112 | }); 1113 | } 1114 | } 1115 | ] 1116 | } 1117 | ); 1118 | } 1119 | } 1120 | } 1121 | 1122 | const PACKAGE_NAME = /^([A-z][\w-]*)/; 1123 | 1124 | /** 1125 | * Compare and returns the conda environment specifications from the conda 1126 | * command and the environment file. 1127 | * 1128 | * @param condaManager Conda environment manager 1129 | * @param project Active project 1130 | * @param contents Content service 1131 | * @returns [comparison, conda specification, file specification] 1132 | */ 1133 | export async function compareSpecification( 1134 | condaManager: IEnvironmentManager | null, 1135 | project: Project.IModel | null, 1136 | contents: Contents.IManager 1137 | ): Promise<{ 1138 | isIdentical: boolean; 1139 | conda?: string; 1140 | file?: string; 1141 | notInFile: Set; 1142 | }> { 1143 | let conda: string; 1144 | let condaPkgs: Set; 1145 | if (condaManager && project && project.environment) { 1146 | try { 1147 | // Does not raise any error if the environment does not exist, but dependencies will be absent. 1148 | const description = await condaManager.export( 1149 | project.environment, 1150 | true 1151 | ); 1152 | const specification = YAML.parse( 1153 | await description.text() 1154 | ) as CondaEnv.IEnvSpecs; 1155 | if (specification.dependencies) { 1156 | condaPkgs = new Set( 1157 | specification.dependencies 1158 | .sort() 1159 | .map(name => PACKAGE_NAME.exec(name)[0]) 1160 | ); 1161 | } 1162 | // Clean the specification from environment name and prefix 1163 | delete specification.name; 1164 | delete specification.prefix; 1165 | conda = YAML.stringify(specification); 1166 | } catch (error) { 1167 | console.debug( 1168 | `Fail to list the packages for conda environment ${project.environment}`, 1169 | error 1170 | ); 1171 | } 1172 | } 1173 | 1174 | const specPath = PathExt.join(project.path, ENVIRONMENT_FILE); 1175 | let file: string; 1176 | let filePkgs: Set; 1177 | try { 1178 | const m = await contents.get(specPath, { 1179 | content: true, 1180 | format: 'text', 1181 | type: 'file' 1182 | }); 1183 | const specification = YAML.parse(m.content) as CondaEnv.IEnvSpecs; 1184 | if (specification.dependencies) { 1185 | filePkgs = new Set( 1186 | specification.dependencies 1187 | .sort() 1188 | .map(name => PACKAGE_NAME.exec(name)[0]) 1189 | ); 1190 | } 1191 | if (specification.name) { 1192 | delete specification.name; 1193 | } 1194 | if (specification.prefix) { 1195 | delete specification.prefix; 1196 | } 1197 | file = YAML.stringify(specification); 1198 | } catch (error) { 1199 | console.debug('No environment file', error); 1200 | } 1201 | 1202 | const isIdentical = conda === file; 1203 | 1204 | let notInFile = new Set(); 1205 | if (!isIdentical && condaPkgs) { 1206 | if (filePkgs) { 1207 | notInFile = new Set([...condaPkgs].filter(pkg => !filePkgs.has(pkg))); 1208 | } else { 1209 | notInFile = condaPkgs; 1210 | } 1211 | } 1212 | 1213 | return { isIdentical, conda, file, notInFile }; 1214 | } 1215 | } 1216 | /* eslint-enable no-inner-declarations */ 1217 | -------------------------------------------------------------------------------- /src/statusbar.tsx: -------------------------------------------------------------------------------- 1 | import { ReactWidget, UseSignal } from '@jupyterlab/apputils'; 2 | import { TextItem } from '@jupyterlab/statusbar'; 3 | import { Widget } from '@lumino/widgets'; 4 | import * as React from 'react'; 5 | import { IProjectManager } from './tokens'; 6 | 7 | export interface IProjectStatusProps { 8 | manager: IProjectManager; 9 | } 10 | 11 | const ProjectComponent: React.FunctionComponent = ( 12 | props: IProjectStatusProps 13 | ) => { 14 | return ( 15 | 19 | {(_, change): JSX.Element => 20 | change.newValue ? ( 21 | 25 | ) : null 26 | } 27 | 28 | ); 29 | }; 30 | 31 | export function createProjectStatus(props: IProjectStatusProps): Widget { 32 | return ReactWidget.create(); 33 | } 34 | -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | 3 | // icon svg import statements 4 | import projectSvg from '../style/icons/project.svg'; 5 | import templateSvg from '../style/icons/template.svg'; 6 | import { PLUGIN_ID } from './tokens'; 7 | 8 | export const projectIcon = new LabIcon({ 9 | name: `${PLUGIN_ID}-project`, 10 | svgstr: projectSvg 11 | }); 12 | 13 | export const templateIcon = new LabIcon({ 14 | name: `${PLUGIN_ID}-template`, 15 | svgstr: templateSvg 16 | }); 17 | -------------------------------------------------------------------------------- /src/svg.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | // including this file in a package allows for the use of import statements 5 | // with svg files. Example: `import xSvg from 'path/xSvg.svg'` 6 | 7 | // for use with raw-loader in Webpack. 8 | // The svg will be imported as a raw string 9 | 10 | declare module '*.svg' { 11 | const value: string; 12 | export default value; 13 | } 14 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme, Theme } from '@material-ui/core/styles'; 2 | 3 | /** 4 | * Return the current material-ui theme fitting the JupyterLab theme 5 | */ 6 | export function getMuiTheme(): Theme { 7 | let theme: Theme; 8 | if (Private.currentTheme) { 9 | theme = Private.THEMES[Private.currentTheme]; 10 | } else { 11 | theme = Private.generateMuiThemeFromJPVar(); 12 | } 13 | 14 | return theme; 15 | } 16 | 17 | /** 18 | * Set the current JupyterLab theme 19 | * 20 | * @param name JupyterLab theme 21 | */ 22 | export function setCurrentTheme(name: string | null): void { 23 | Private.currentTheme = name; 24 | if (name) { 25 | if (!Private.THEMES[name]) { 26 | Private.THEMES[name] = Private.generateMuiThemeFromJPVar(); 27 | } 28 | } 29 | } 30 | 31 | /* eslint-disable no-inner-declarations */ 32 | namespace Private { 33 | /** 34 | * Cache of material-ui themes generated from JupyterLab themes. 35 | */ 36 | export const THEMES: { [name: string]: Theme } = {}; 37 | 38 | // eslint-disable-next-line prefer-const 39 | export let currentTheme: string | null = null; 40 | 41 | /** 42 | * Get the value of a CSS variable 43 | * 44 | * @param name CSS variable name 45 | * @returns The CSS variable value 46 | */ 47 | function getCSSVar(name: string): string { 48 | return getComputedStyle(document.documentElement) 49 | .getPropertyValue(name) 50 | .trim(); 51 | } 52 | 53 | /** 54 | * Create a material-ui theme from the current JupyterLab theme 55 | * 56 | * @returns The material-ui theme 57 | */ 58 | export function generateMuiThemeFromJPVar(): Theme { 59 | return createMuiTheme({ 60 | palette: { 61 | primary: { 62 | main: getCSSVar('--jp-brand-color1'), 63 | contrastText: getCSSVar('--jp-ui-inverse-font-color1') 64 | }, 65 | secondary: { 66 | main: getCSSVar('--jp-accent-color1'), 67 | contrastText: getCSSVar('--jp-ui-inverse-font-color1') 68 | }, 69 | error: { 70 | main: getCSSVar('--jp-error-color1'), 71 | contrastText: getCSSVar('--jp-ui-inverse-font-color1') 72 | }, 73 | warning: { 74 | main: getCSSVar('--jp-warn-color1'), 75 | contrastText: getCSSVar('--jp-ui-inverse-font-color1') 76 | }, 77 | info: { 78 | main: getCSSVar('--jp-info-color1') 79 | }, 80 | success: { 81 | main: getCSSVar('--jp-success-color1') 82 | }, 83 | text: { 84 | primary: getCSSVar('--jp-ui-font-color1'), 85 | secondary: getCSSVar('--jp-ui-font-color2'), 86 | disabled: getCSSVar('--jp-ui-font-color3') 87 | }, 88 | background: { 89 | default: getCSSVar('--jp-layout-color1'), 90 | paper: getCSSVar('--jp-layout-color1') 91 | } 92 | }, 93 | typography: { 94 | fontFamily: getCSSVar('--jp-ui-font-family') 95 | } 96 | }); 97 | } 98 | } 99 | /* eslint-enable no-inner-declarations */ 100 | -------------------------------------------------------------------------------- /src/tokens.ts: -------------------------------------------------------------------------------- 1 | import { Dialog } from '@jupyterlab/apputils'; 2 | import { JSONObject, Token } from '@lumino/coreutils'; 3 | import { Signal } from '@lumino/signaling'; 4 | import { Bridge } from 'uniforms'; 5 | 6 | /** 7 | * Plugin ID 8 | */ 9 | export const PLUGIN_ID = 'jupyter-project'; 10 | 11 | /** 12 | * Project Manager Plugin Token 13 | */ 14 | export const IProjectManager = new Token( 15 | `${PLUGIN_ID}:IProjectManager` 16 | ); 17 | 18 | /** 19 | * Command IDs 20 | */ 21 | export namespace CommandIDs { 22 | /** 23 | * Close current project command 24 | */ 25 | export const closeProject = 'jupyter-project:project-close'; 26 | /** 27 | * Delete current project command 28 | */ 29 | export const deleteProject = 'jupyter-project:project-delete'; 30 | /** 31 | * Import a project by cloning a Git repository 32 | */ 33 | export const importProject = 'jupyter-project:project-import'; 34 | /** 35 | * Create new project command 36 | */ 37 | export const newProject = 'jupyter-project:project-create'; 38 | /** 39 | * Open project command 40 | */ 41 | export const openProject = 'jupyter-project:project-open'; 42 | /** 43 | * Create new file from template command 44 | */ 45 | export const newTemplateFile = 'jupyter-project:file-template'; 46 | } 47 | 48 | /** 49 | * Form namespace 50 | */ 51 | export namespace Form { 52 | /** 53 | * Form widget interface 54 | */ 55 | export interface IWidget extends Dialog.IBodyWidget { 56 | /** 57 | * Submit the form 58 | * 59 | * Returns a promise that resolves if the form is valid otherwise 60 | * the promise is rejected. 61 | */ 62 | submit: () => Promise; 63 | } 64 | 65 | /** 66 | * Constructor options for forms 67 | */ 68 | export interface IOptions { 69 | /** 70 | * uniforms.Bridge schema defining the forms 71 | */ 72 | schema: Bridge; 73 | /** 74 | * The top level text for the dialog. Defaults to an empty string. 75 | */ 76 | title: Dialog.Header; 77 | /** 78 | * Label for cancel button. 79 | */ 80 | cancelLabel?: string; 81 | /** 82 | * The host element for the dialog. Defaults to `document.body`. 83 | */ 84 | host?: HTMLElement; 85 | /** 86 | * Label for ok button. 87 | */ 88 | okLabel?: string; 89 | /** 90 | * An optional renderer for dialog items. Defaults to a shared 91 | * default renderer. 92 | */ 93 | renderer?: Dialog.IRenderer; 94 | } 95 | } 96 | 97 | /** 98 | * Project namespace 99 | */ 100 | export namespace Project { 101 | /** Type of change project reason */ 102 | export type ChangeType = 'delete' | 'new' | 'open'; 103 | /** 104 | * Project model interface 105 | */ 106 | export interface IModel { 107 | /** Project name */ 108 | name: string; 109 | /** Current project path */ 110 | path: string; 111 | /** Conda environment associated to the project */ 112 | environment?: string; 113 | /** Other keys from the project configuration file */ 114 | [key: string]: any; 115 | } 116 | /** 117 | * Project change interface 118 | */ 119 | export interface IChangedArgs { 120 | /** New project model */ 121 | newValue: IModel | null; 122 | /** Previous project model */ 123 | oldValue: IModel | null; 124 | /** Type of change */ 125 | type: ChangeType; 126 | } 127 | 128 | export interface IInfo extends IModel { 129 | /** Project directory name */ 130 | dirname: string; 131 | } 132 | } 133 | export interface IProjectManager { 134 | /** 135 | * Current project properties 136 | */ 137 | project: Project.IModel | null; 138 | /** 139 | * Signal emitted when project changes 140 | */ 141 | projectChanged: Signal; 142 | } 143 | 144 | /** 145 | * Templates namespace 146 | */ 147 | export namespace Templates { 148 | /** 149 | * File template members 150 | */ 151 | export interface IFile { 152 | /** 153 | * File template name 154 | */ 155 | name: string; 156 | /** 157 | * Server endpoint to request 158 | */ 159 | endpoint: string; 160 | /** 161 | * Destination folder of the template within the project directory 162 | */ 163 | destination?: string; 164 | /** 165 | * Icon to display for this template 166 | */ 167 | icon?: string; 168 | /** 169 | * JSON schema of the template parameters 170 | */ 171 | schema?: JSONObject; 172 | } 173 | /** 174 | * Project template members 175 | */ 176 | export interface IProject { 177 | /** 178 | * Project configuration file name 179 | */ 180 | configurationFilename: string; 181 | /** 182 | * Synchronize a conda environment with the project 183 | */ 184 | defaultCondaPackages?: string; 185 | /** 186 | * Default path to open when a project is created 187 | */ 188 | defaultPath?: string; 189 | /** 190 | * Should the project be installed in pip editable mode 191 | * in the conda environment? 192 | */ 193 | editableInstall: boolean; 194 | /** 195 | * JSON schema of the template parameter 196 | */ 197 | schema?: JSONObject; 198 | /** 199 | * Is the project connected to @jupyterlab/git? 200 | */ 201 | withGit: boolean; 202 | } 203 | /** 204 | * Jupyter project settings 205 | */ 206 | export interface ISettings { 207 | /** 208 | * List of defined file templates 209 | */ 210 | fileTemplates: IFile[]; 211 | /** 212 | * Project template configuration 213 | */ 214 | projectTemplate: IProject | null; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { getProjectInfo } from './project'; 2 | import { Project } from './tokens'; 3 | 4 | /** 5 | * Namespace of foreign command IDs used 6 | */ 7 | export namespace ForeignCommandIDs { 8 | export const closeAll = 'application:close-all'; 9 | export const documentOpen = 'docmanager:open'; 10 | export const gitInit = 'git:init'; 11 | export const goTo = 'filebrowser:go-to-path'; 12 | export const openPath = 'filebrowser:open-path'; 13 | export const saveAll = 'docmanager:save-all'; 14 | } 15 | 16 | /** 17 | * Rendered a templated string with project info 18 | * 19 | * The template key must be {{ jproject.property }}. And 20 | * the properties available are those of Project.IInfo 21 | * 22 | * @param template Templated string 23 | * @param project Project information 24 | * @returns The rendered string 25 | */ 26 | export function renderStringTemplate( 27 | template: string, 28 | project: Project.IModel | null 29 | ): string { 30 | if (!project) { 31 | return template; // Bail early 32 | } 33 | const values = getProjectInfo(project); 34 | 35 | for (const key in values) { 36 | const regex = new RegExp(`{{\\s*jproject\\.${key}\\s*}}`, 'gi'); 37 | const value = values[key as keyof typeof values]; 38 | 39 | template = template.replace(regex, value); 40 | } 41 | 42 | return template; 43 | } 44 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | 3 | /** 4 | * Initialize JSON schema validation with ajv 5 | */ 6 | const ajv = new Ajv({ allErrors: true, useDefaults: true }); 7 | 8 | /** 9 | * Create a validator function for JSON schema in uniform 10 | * 11 | * @param schema JSON schema to validate 12 | */ 13 | export function createValidator( 14 | schema: boolean | object 15 | ): (model: object) => void { 16 | const validator = ajv.compile(schema); 17 | return (model: object): void => { 18 | validator(model); 19 | if (validator.errors && validator.errors.length) { 20 | throw { details: validator.errors }; 21 | } 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /style/icons/project.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 27 | 33 | 34 | -------------------------------------------------------------------------------- /style/icons/template.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --jp-project-template: url(./icons/template.svg); 3 | --jp-project: url(./icons/project.svg); 4 | } 5 | 6 | .jp-JupyterProjectProjectIcon { 7 | background-image: var(--jp-project); 8 | } 9 | 10 | .jp-JupyterProjectTemplateIcon { 11 | background-image: var(--jp-project-template); 12 | } 13 | 14 | /* Color for FontAwesome icons */ 15 | .jp-JupyterProjectIcon.fa { 16 | color: var(--jp-inverse-layout-color3); 17 | } 18 | 19 | .p-Widget.jp-Dialog.jpproject-Form { 20 | z-index: 999; /* Need to lower z-index to display select options */ 21 | } 22 | 23 | .jp-project-form { 24 | display: flex; 25 | flex-direction: column; 26 | height: 100%; 27 | } 28 | 29 | .jp-project-form-fields { 30 | flex: 1 1 auto; 31 | overflow: auto; 32 | } 33 | 34 | .jp-project-form-errors { 35 | flex: 0 0 auto; 36 | min-height: 20px; 37 | max-height: 60px; 38 | overflow: auto; 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "inlineSources": true, 9 | "jsx": "react", 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noEmitOnError": true, 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "preserveWatchOutput": true, 16 | "resolveJsonModule": true, 17 | "outDir": "lib", 18 | "rootDir": "src", 19 | "sourceMap": true, 20 | "sourceRoot": "./jupyter-project/src", 21 | "strict": true, 22 | "strictNullChecks": false, 23 | "target": "es2017", 24 | "types": ["jest"] 25 | }, 26 | "include": ["src/*"] 27 | } 28 | --------------------------------------------------------------------------------