├── .yarnrc.yml ├── style ├── index.js ├── index.css └── base.css ├── setup.py ├── imgs ├── auth.png ├── demo.gif ├── login.png └── diagram.png ├── CHANGELOG.md ├── .prettierignore ├── jupyter_copilot ├── dist │ ├── crypt32.node │ ├── tree-sitter.wasm │ ├── tree-sitter-go.wasm │ ├── tree-sitter-ruby.wasm │ ├── tree-sitter-tsx.wasm │ ├── tree-sitter-python.wasm │ ├── tree-sitter-javascript.wasm │ ├── tree-sitter-typescript.wasm │ └── compiled │ │ ├── linux │ │ ├── x64 │ │ │ └── kerberos.node │ │ └── arm64 │ │ │ └── kerberos.node │ │ ├── win32 │ │ └── x64 │ │ │ └── kerberos.node │ │ └── darwin │ │ ├── x64 │ │ └── kerberos.node │ │ └── arm64 │ │ └── kerberos.node ├── __init__.py ├── lsp.py └── handlers.py ├── jupyter-config └── server-config │ └── jupyter_copilot.json ├── install.json ├── .github └── workflows │ ├── binder-on-pr.yml │ ├── enforce-label.yml │ ├── check-release.yml │ ├── prep-release.yml │ ├── publish-release.yml │ └── build.yml ├── .copier-answers.yml ├── binder ├── environment.yml └── postBuild ├── schema └── plugin.json ├── tsconfig.json ├── src ├── utils.ts ├── lsp.ts ├── commands │ └── authentication.ts └── index.ts ├── LICENSE ├── .gitignore ├── RELEASE.md ├── pyproject.toml ├── package.json └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | -------------------------------------------------------------------------------- /imgs/auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/imgs/auth.png -------------------------------------------------------------------------------- /imgs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/imgs/demo.gif -------------------------------------------------------------------------------- /imgs/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/imgs/login.png -------------------------------------------------------------------------------- /imgs/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/imgs/diagram.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyter_copilot 7 | -------------------------------------------------------------------------------- /jupyter_copilot/dist/crypt32.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/crypt32.node -------------------------------------------------------------------------------- /jupyter_copilot/dist/tree-sitter.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/tree-sitter.wasm -------------------------------------------------------------------------------- /jupyter_copilot/dist/tree-sitter-go.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/tree-sitter-go.wasm -------------------------------------------------------------------------------- /jupyter_copilot/dist/tree-sitter-ruby.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/tree-sitter-ruby.wasm -------------------------------------------------------------------------------- /jupyter_copilot/dist/tree-sitter-tsx.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/tree-sitter-tsx.wasm -------------------------------------------------------------------------------- /jupyter_copilot/dist/tree-sitter-python.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/tree-sitter-python.wasm -------------------------------------------------------------------------------- /jupyter_copilot/dist/tree-sitter-javascript.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/tree-sitter-javascript.wasm -------------------------------------------------------------------------------- /jupyter_copilot/dist/tree-sitter-typescript.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/tree-sitter-typescript.wasm -------------------------------------------------------------------------------- /jupyter-config/server-config/jupyter_copilot.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyter_copilot": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter_copilot/dist/compiled/linux/x64/kerberos.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/compiled/linux/x64/kerberos.node -------------------------------------------------------------------------------- /jupyter_copilot/dist/compiled/win32/x64/kerberos.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/compiled/win32/x64/kerberos.node -------------------------------------------------------------------------------- /jupyter_copilot/dist/compiled/darwin/x64/kerberos.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/compiled/darwin/x64/kerberos.node -------------------------------------------------------------------------------- /jupyter_copilot/dist/compiled/linux/arm64/kerberos.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/compiled/linux/arm64/kerberos.node -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | See the JupyterLab Developer Guide for useful CSS Patterns: 3 | 4 | https://jupyterlab.readthedocs.io/en/stable/developer/css.html 5 | */ 6 | -------------------------------------------------------------------------------- /jupyter_copilot/dist/compiled/darwin/arm64/kerberos.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baolong281/jupyter-copilot/HEAD/jupyter_copilot/dist/compiled/darwin/arm64/kerberos.node -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyter_copilot", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyter_copilot" 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Binder Badge 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | jobs: 7 | binder: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: jupyterlab/maintainer-tools/.github/actions/binder-link@v1 13 | with: 14 | github_token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v4.3.4 3 | _src_path: https://github.com/jupyterlab/extension-template 4 | author_email: dylanhuyn@gmail.com 5 | author_name: jupyter_copilot 6 | has_binder: true 7 | has_settings: true 8 | kind: server 9 | labextension_name: jupyter_copilot 10 | project_short_description: GitHub Copilot for Jupyter 11 | python_name: jupyter_copilot 12 | repository: '' 13 | test: false 14 | 15 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | # a mybinder.org-ready environment for demoing jupyter_copilot 2 | # this environment may also be used locally on Linux/MacOS/Windows, e.g. 3 | # 4 | # conda env update --file binder/environment.yml 5 | # conda activate jupyter-copilot-demo 6 | # 7 | name: jupyter-copilot-demo 8 | 9 | channels: 10 | - conda-forge 11 | 12 | dependencies: 13 | # runtime dependencies 14 | - python >=3.10,<3.11.0a0 15 | - jupyterlab >=4.0.0,<5 16 | # labextension build dependencies 17 | - nodejs >=18,<19 18 | - pip 19 | - wheel 20 | # additional packages for demos 21 | # - ipywidgets 22 | -------------------------------------------------------------------------------- /schema/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.shortcuts": [], 3 | "title": "Copilot", 4 | "description": "Settings for Jupyter Copilot", 5 | "type": "object", 6 | "additionalProperties": false, 7 | "properties": { 8 | "flag": { 9 | "type": "boolean", 10 | "title": "Enable Copilot", 11 | "description": "Enable or Disable Copilot Completions", 12 | "default": true 13 | }, 14 | "keybind": { 15 | "type": "string", 16 | "title": "Accept Completion Keybind", 17 | "description": "Keybinding to trigger Copilot Completions", 18 | "default": "Ctrl J" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2018" 21 | }, 22 | "include": ["src/**/*"] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["*"] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | check_release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Base Setup 19 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 20 | - name: Check Release 21 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 22 | with: 23 | 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Upload Distributions 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: jupyter_copilot-releaser-dist-${{ github.run_number }} 30 | path: .jupyter_releaser_checkout/dist 31 | -------------------------------------------------------------------------------- /jupyter_copilot/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._version import __version__ 3 | except ImportError: 4 | # Fallback when using the package in dev mode without installing 5 | # in editable mode with pip. It is highly recommended to install 6 | # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs 7 | import warnings 8 | warnings.warn("Importing 'jupyter_copilot' outside a proper installation.") 9 | __version__ = "dev" 10 | from .handlers import setup_handlers 11 | 12 | 13 | def _jupyter_labextension_paths(): 14 | return [{ 15 | "src": "labextension", 16 | "dest": "jupyter_copilot" 17 | }] 18 | 19 | 20 | def _jupyter_server_extension_points(): 21 | return [{ 22 | "module": "jupyter_copilot" 23 | }] 24 | 25 | 26 | def _load_jupyter_server_extension(server_app): 27 | """Registers the API handler to receive HTTP requests from the frontend extension. 28 | 29 | Parameters 30 | ---------- 31 | server_app: jupyterlab.labapp.LabApp 32 | JupyterLab application instance 33 | """ 34 | server_app.log.info("jupyter_copilot | Loading server extension") 35 | setup_handlers(server_app) 36 | server_app.log.info("jupyter_copilot | Server extension loaded") 37 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | import { ServerConnection } from '@jupyterlab/services'; 3 | 4 | // takes in the route and body of the request (json object) 5 | export const makePostRequest = async (route: string, body: object) => { 6 | try { 7 | const settings = ServerConnection.makeSettings(); 8 | const requestUrl = URLExt.join(settings.baseUrl, 'jupyter-copilot', route); 9 | 10 | console.debug('requestUrl:', requestUrl); 11 | 12 | const init: RequestInit = { 13 | method: 'POST', 14 | body: JSON.stringify(body), 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | Authorization: `token ${settings.token}` 18 | } 19 | }; 20 | 21 | const response = await ServerConnection.makeRequest( 22 | requestUrl, 23 | init, 24 | settings 25 | ); 26 | 27 | if (!response.ok) { 28 | console.error('Response not OK:', response.status, response.statusText); 29 | const errorData = await response.text(); 30 | console.error('Error data:', errorData); 31 | throw new Error(`HTTP error! status: ${response.status}`); 32 | } 33 | 34 | const data = await response.text(); 35 | return data; 36 | } catch (reason) { 37 | console.error( 38 | `The jupyter_copilot server extension appears to be missing or the request failed.\n${reason}` 39 | ); 40 | throw reason; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 jupyter_copilot 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE.HE 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. 20 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of jupyter_copilot 3 | 4 | On Binder, this will run _after_ the environment has been fully created from 5 | the environment.yml in this directory. 6 | 7 | This script should also run locally on Linux/MacOS/Windows: 8 | 9 | python3 binder/postBuild 10 | """ 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | 15 | 16 | ROOT = Path.cwd() 17 | 18 | def _(*args, **kwargs): 19 | """ Run a command, echoing the args 20 | 21 | fails hard if something goes wrong 22 | """ 23 | print("\n\t", " ".join(args), "\n") 24 | return_code = subprocess.call(args, **kwargs) 25 | if return_code != 0: 26 | print("\nERROR", return_code, " ".join(args)) 27 | sys.exit(return_code) 28 | 29 | # verify the environment is self-consistent before even starting 30 | _(sys.executable, "-m", "pip", "check") 31 | 32 | # install the labextension 33 | _(sys.executable, "-m", "pip", "install", "-e", ".") 34 | _(sys.executable, "-m", "jupyter", "labextension", "develop", "--overwrite", ".") 35 | _( 36 | sys.executable, 37 | "-m", 38 | "jupyter", 39 | "server", 40 | "extension", 41 | "enable", 42 | "jupyter_copilot", 43 | ) 44 | 45 | # verify the environment the extension didn't break anything 46 | _(sys.executable, "-m", "pip", "check") 47 | 48 | # list the extensions 49 | _("jupyter", "server", "extension", "list") 50 | 51 | # initially list installed extensions to determine if there are any surprises 52 | _("jupyter", "labextension", "list") 53 | 54 | 55 | print("JupyterLab with jupyter_copilot is ready to run with:\n") 56 | print("\tjupyter lab\n") 57 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | # silent: 16 | # description: "Set a placeholder in the changelog and don't publish the release." 17 | # required: false 18 | # type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | # silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | branch: ${{ github.event.inputs.branch }} 43 | since: ${{ github.event.inputs.since }} 44 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 45 | 46 | - name: "** Next Step **" 47 | run: | 48 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 49 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 44 | with: 45 | token: ${{ steps.app-token.outputs.token }} 46 | release_url: ${{ steps.populate-release.outputs.release_url }} 47 | 48 | - name: "** Next Step **" 49 | if: ${{ success() }} 50 | run: | 51 | echo "Verify the final release" 52 | echo ${{ steps.finalize-release.outputs.release_url }} 53 | 54 | - name: "** Failure Message **" 55 | if: ${{ failure() }} 56 | run: | 57 | echo "Failed to Publish the Draft Release Url:" 58 | echo ${{ steps.populate-release.outputs.release_url }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyter_copilot/labextension 11 | # Version file is handled by hatchling 12 | jupyter_copilot/_version.py 13 | 14 | # Created by https://www.gitignore.io/api/python 15 | # Edit at https://www.gitignore.io/?templates=python 16 | 17 | ### Python ### 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | pip-wheel-metadata/ 41 | share/python-wheels/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage/ 65 | coverage.xml 66 | *.cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # Mr Developer 100 | .mr.developer.cfg 101 | .project 102 | .pydevproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | .dmypy.json 110 | dmypy.json 111 | 112 | # Pyre type checker 113 | .pyre/ 114 | 115 | # End of https://www.gitignore.io/api/python 116 | 117 | # OSX files 118 | .DS_Store 119 | 120 | # Yarn cache 121 | .yarn/ 122 | .copilot_token 123 | notebooks 124 | notebooks/* 125 | 126 | !jupyter_copilot/dist 127 | .venv 128 | *.ipynb 129 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupyter_copilot 2 | 3 | The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser). 4 | 5 | ## Manual release 6 | 7 | ### Python package 8 | 9 | This extension can be distributed as Python packages. All of the Python 10 | packaging instructions are in the `pyproject.toml` file to wrap your extension in a 11 | Python package. Before generating a package, you first need to install some tools: 12 | 13 | ```bash 14 | pip install build twine hatch 15 | ``` 16 | 17 | Bump the version using `hatch`. By default this will create a tag. 18 | See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 19 | 20 | ```bash 21 | hatch version 22 | ``` 23 | 24 | Make sure to clean up all the development files before building the package: 25 | 26 | ```bash 27 | jlpm clean:all 28 | ``` 29 | 30 | You could also clean up the local git repository: 31 | 32 | ```bash 33 | git clean -dfX 34 | ``` 35 | 36 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 37 | 38 | ```bash 39 | python -m build 40 | ``` 41 | 42 | > `python setup.py sdist bdist_wheel` is deprecated and will not work for this package. 43 | 44 | Then to upload the package to PyPI, do: 45 | 46 | ```bash 47 | twine upload dist/* 48 | ``` 49 | 50 | ### NPM package 51 | 52 | To publish the frontend part of the extension as a NPM package, do: 53 | 54 | ```bash 55 | npm login 56 | npm publish --access public 57 | ``` 58 | 59 | ## Automated releases with the Jupyter Releaser 60 | 61 | The extension repository should already be compatible with the Jupyter Releaser. But 62 | the GitHub repository and the package managers need to be properly set up. Please 63 | follow the instructions of the Jupyter Releaser [checklist](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_repo.html). 64 | 65 | Here is a summary of the steps to cut a new release: 66 | 67 | - Go to the Actions panel 68 | - Run the "Step 1: Prep Release" workflow 69 | - Check the draft changelog 70 | - Run the "Step 2: Publish Release" workflow 71 | 72 | > [!NOTE] 73 | > Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) 74 | > for more information. 75 | 76 | ## Publishing to `conda-forge` 77 | 78 | If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html 79 | 80 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 81 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Base Setup 22 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - name: Install dependencies 25 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 26 | 27 | - name: Lint the extension 28 | run: | 29 | set -eux 30 | jlpm 31 | jlpm run lint:check 32 | 33 | - name: Build the extension 34 | run: | 35 | set -eux 36 | python -m pip install .[test] 37 | 38 | jupyter server extension list 39 | jupyter server extension list 2>&1 | grep -ie "jupyter_copilot.*OK" 40 | 41 | jupyter labextension list 42 | jupyter labextension list 2>&1 | grep -ie "jupyter_copilot.*OK" 43 | python -m jupyterlab.browser_check 44 | 45 | - name: Package the extension 46 | run: | 47 | set -eux 48 | 49 | pip install build 50 | python -m build 51 | pip uninstall -y "jupyter_copilot" jupyterlab 52 | 53 | - name: Upload extension packages 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: extension-artifacts 57 | path: dist/jupyter_copilot* 58 | if-no-files-found: error 59 | 60 | test_isolated: 61 | needs: build 62 | runs-on: ubuntu-latest 63 | 64 | steps: 65 | - name: Install Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: '3.9' 69 | architecture: 'x64' 70 | - uses: actions/download-artifact@v4 71 | with: 72 | name: extension-artifacts 73 | - name: Install and Test 74 | run: | 75 | set -eux 76 | # Remove NodeJS, twice to take care of system and locally installed node versions. 77 | sudo rm -rf $(which node) 78 | sudo rm -rf $(which node) 79 | 80 | pip install "jupyterlab>=4.0.0,<5" jupyter_copilot*.whl 81 | 82 | 83 | jupyter server extension list 84 | jupyter server extension list 2>&1 | grep -ie "jupyter_copilot.*OK" 85 | 86 | jupyter labextension list 87 | jupyter labextension list 2>&1 | grep -ie "jupyter_copilot.*OK" 88 | python -m jupyterlab.browser_check --no-browser-test 89 | 90 | 91 | check_links: 92 | name: Check Links 93 | runs-on: ubuntu-latest 94 | timeout-minutes: 15 95 | steps: 96 | - uses: actions/checkout@v4 97 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 98 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 99 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyter_copilot" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.8" 10 | requires-nodejs = ">=18" 11 | classifiers = [ 12 | "Framework :: Jupyter", 13 | "Framework :: Jupyter :: JupyterLab", 14 | "Framework :: Jupyter :: JupyterLab :: 4", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions", 16 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | ] 26 | dependencies = [ 27 | "jupyter_server>=2.0.1,<3" 28 | ] 29 | dynamic = ["version", "description", "authors", "keywords", "urls"] 30 | 31 | [tool.hatch.version] 32 | source = "nodejs" 33 | 34 | [tool.hatch.metadata.hooks.nodejs] 35 | fields = ["description", "authors", "urls"] 36 | 37 | [tool.hatch.build.targets.sdist] 38 | artifacts = ["jupyter_copilot/labextension", "jupyter_copilot/dist"] 39 | exclude = [".github", "binder"] 40 | 41 | [tool.hatch.build.targets.wheel.shared-data] 42 | "jupyter_copilot/labextension" = "share/jupyter/labextensions/jupyter_copilot" 43 | "install.json" = "share/jupyter/labextensions/jupyter_copilot/install.json" 44 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 45 | 46 | [tool.hatch.build.targets.wheel.force-include] 47 | "jupyter_copilot/dist" = "jupyter_copilot/dist" 48 | 49 | [tool.hatch.build.hooks.version] 50 | path = "jupyter_copilot/_version.py" 51 | 52 | [tool.hatch.build.hooks.jupyter-builder] 53 | dependencies = ["hatch-jupyter-builder>=0.5"] 54 | build-function = "hatch_jupyter_builder.npm_builder" 55 | ensured-targets = [ 56 | "jupyter_copilot/labextension/static/style.js", 57 | "jupyter_copilot/labextension/package.json", 58 | ] 59 | skip-if-exists = ["jupyter_copilot/labextension/static/style.js"] 60 | 61 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 62 | build_cmd = "build:prod" 63 | npm = ["jlpm"] 64 | 65 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 66 | build_cmd = "install:extension" 67 | npm = ["jlpm"] 68 | source_dir = "src" 69 | build_dir = "jupyter_copilot/labextension" 70 | 71 | [tool.jupyter-releaser.options] 72 | version_cmd = "hatch version" 73 | 74 | [tool.jupyter-releaser.hooks] 75 | before-build-npm = [ 76 | "python -m pip install 'jupyterlab>=4.0.0,<5'", 77 | "jlpm", 78 | "jlpm build:prod" 79 | ] 80 | before-build-python = ["jlpm clean:all"] 81 | 82 | [tool.check-wheel-contents] 83 | ignore = ["W002"] 84 | -------------------------------------------------------------------------------- /src/lsp.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | /* 3 | This class is responsible for communicating with the LSP server and 4 | the notebook frontend. It establishes a WebSocket connection with the 5 | LSP server and listens for messages. It also sends messages to the LSP 6 | server when a cell is updated in the notebook frontend. 7 | */ 8 | 9 | interface Completion { 10 | displayText: string; 11 | docVersion: number; 12 | position: { line: number; character: number }; 13 | range: { 14 | start: { line: number; character: number }; 15 | end: { line: number; character: number }; 16 | }; 17 | text: string; 18 | uuid: string; 19 | } 20 | 21 | class NotebookLSPClient { 22 | private socket: WebSocket | undefined; 23 | private pendingCompletions: Map< 24 | string, 25 | { resolve: (value: any) => void; reject: (reason?: any) => void } 26 | > = new Map(); 27 | private wsUrl: string; 28 | private isReconnecting: boolean = false; 29 | 30 | constructor(notebookPath: string, wsUrl: string) { 31 | this.wsUrl = `${wsUrl}?path=${encodeURIComponent(notebookPath)}`; 32 | this.initializeWebSocket(); 33 | } 34 | 35 | private initializeWebSocket() { 36 | this.socket = new WebSocket(this.wsUrl); 37 | this.setupSocketEventHandlers(); 38 | } 39 | 40 | private setupSocketEventHandlers() { 41 | if (!this.socket) { 42 | return; 43 | } 44 | 45 | this.socket.onmessage = this.handleMessage.bind(this); 46 | this.socket.onopen = () => this.sendMessage('sync_request', {}); 47 | this.socket.onclose = this.handleSocketClose; 48 | } 49 | 50 | private handleSocketClose = () => { 51 | if (this.isReconnecting) { 52 | return; 53 | } 54 | this.isReconnecting = true; 55 | this.initializeWebSocket(); 56 | console.debug('Socket closed, reconnecting...'); 57 | setTimeout(() => { 58 | this.isReconnecting = false; 59 | }, 4000); 60 | }; 61 | 62 | // Handle messages from the extension server 63 | private handleMessage(event: MessageEvent) { 64 | const data = JSON.parse(event.data); 65 | switch (data.type) { 66 | case 'sync_response': 67 | break; 68 | case 'completion': 69 | { 70 | const pendingCompletion = this.pendingCompletions.get(data.req_id); 71 | if (pendingCompletion) { 72 | pendingCompletion.resolve(data.completions); 73 | this.pendingCompletions.delete(data.req_id); 74 | } 75 | } 76 | break; 77 | case 'connection_established': 78 | console.debug('Copilot connected to extension server...'); 79 | break; 80 | default: 81 | console.error('Unknown message type:', data); 82 | } 83 | } 84 | 85 | // Send a message to the LSP server to update the cell content 86 | // we don't want to update the entire file every time something is changed 87 | // so we specify a cell id and the now content so we can modify just that single cell 88 | public sendCellUpdate(cellId: number, content: string) { 89 | this.sendMessage('cell_update', { cell_id: cellId, content: content }); 90 | } 91 | 92 | public sendCellDelete(cellID: number) { 93 | this.sendMessage('cell_delete', { cell_id: cellID }); 94 | } 95 | 96 | public sendCellAdd(cellID: number, content: string) { 97 | this.sendMessage('cell_add', { cell_id: cellID, content: content }); 98 | } 99 | 100 | // sends a message to the server which will then send the updated code to the lsp server 101 | public sendUpdateLSPVersion() { 102 | this.sendMessage('update_lsp_version', {}); 103 | } 104 | 105 | public async getCopilotCompletion( 106 | cell: number, 107 | line: number, 108 | character: number 109 | ): Promise { 110 | return new Promise((resolve, reject) => { 111 | const requestId = `${cell}-${line}-${character}-${Date.now()}`; 112 | this.pendingCompletions.set(requestId, { resolve, reject }); 113 | 114 | this.sendMessage('get_completion', { 115 | req_id: requestId, 116 | cell_id: cell, 117 | line: line, 118 | character: character 119 | }); 120 | 121 | // add a timeout to reject the promise if no response is received 122 | setTimeout(() => { 123 | if (this.pendingCompletions.has(requestId)) { 124 | this.pendingCompletions.delete(requestId); 125 | reject(new Error('Completion request timed out')); 126 | } 127 | }, 10000); // 10 seconds timeout 128 | }); 129 | } 130 | 131 | private sendMessage(type: string, payload: any) { 132 | this.socket?.send(JSON.stringify({ type, ...payload })); 133 | } 134 | 135 | public sendPathChange(newPath: string) { 136 | this.sendMessage('change_path', { new_path: newPath }); 137 | } 138 | 139 | public setNotebookLanguage(language: string) { 140 | this.sendMessage('set_language', { language: language }); 141 | } 142 | 143 | // cleans up the socket connection 144 | public dispose() { 145 | this.socket?.close(); 146 | console.debug('socket connection closed'); 147 | } 148 | } 149 | 150 | export { NotebookLSPClient }; 151 | -------------------------------------------------------------------------------- /src/commands/authentication.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { JupyterFrontEnd } from '@jupyterlab/application'; 3 | import { makePostRequest } from '../utils'; 4 | import { Widget } from '@lumino/widgets'; 5 | import { MainAreaWidget } from '@jupyterlab/apputils'; 6 | import { GLOBAL_SETTINGS } from '../index'; 7 | 8 | interface AlreadySignedInResponse { 9 | status: 'AlreadySignedIn'; 10 | user: string; 11 | } 12 | 13 | interface PendingLoginResponse { 14 | status: 'PendingLogin'; 15 | user?: string; 16 | userCode: string; 17 | verificationUri: string; 18 | expiresIn: number; 19 | interval: number; 20 | } 21 | 22 | interface SignOutResponse { 23 | status: string; 24 | } 25 | type LoginResponse = AlreadySignedInResponse | PendingLoginResponse; 26 | 27 | const defaultWidgetCSS = ` 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: center; 31 | align-items: center; 32 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 33 | color: #333; 34 | background-color: #fff; 35 | padding: 30px; 36 | max-width: 400px; 37 | margin: 0 auto; 38 | border-radius: 8px; 39 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 40 | text-align: center; 41 | `; 42 | 43 | const signWidget = (authData: PendingLoginResponse) => { 44 | const content = new Widget(); 45 | 46 | const messageElement = document.createElement('div'); 47 | 48 | messageElement.style.cssText = defaultWidgetCSS; 49 | 50 | messageElement.innerHTML = ` 51 |

GitHub Copilot Authentication

52 |

Enter this code on GitHub:

53 |
${authData.userCode}
54 |

Go to: ${authData.verificationUri}

55 |

This code will expire in ${authData.expiresIn} seconds.

56 | `; 57 | content.node.appendChild(messageElement); 58 | const widget = new MainAreaWidget({ content }); 59 | widget.id = 'apod-jupyterlab'; 60 | widget.title.label = 'Sign In'; 61 | widget.title.closable = true; 62 | return widget; 63 | }; 64 | 65 | const alreadySignedInWidget = (username: string) => { 66 | const content = new Widget(); 67 | 68 | const messageElement = document.createElement('div'); 69 | 70 | messageElement.style.cssText = defaultWidgetCSS; 71 | 72 | messageElement.innerHTML = ` 73 |

Copilot already signed in as: ${username}

74 | `; 75 | 76 | content.node.appendChild(messageElement); 77 | const widget = new MainAreaWidget({ content }); 78 | widget.id = 'apod-jupyterlab'; 79 | widget.title.label = 'Already Signed In'; 80 | widget.title.closable = true; 81 | return widget; 82 | }; 83 | 84 | const SignedOutWidget = () => { 85 | const content = new Widget(); 86 | 87 | const messageElement = document.createElement('div'); 88 | 89 | messageElement.style.cssText = defaultWidgetCSS; 90 | 91 | messageElement.innerHTML = ` 92 |

Successfully signed out with GitHub!

93 | `; 94 | 95 | content.node.appendChild(messageElement); 96 | const widget = new MainAreaWidget({ content }); 97 | widget.id = 'apod-jupyterlab'; 98 | widget.title.label = 'Sign Out Successful'; 99 | widget.title.closable = true; 100 | return widget; 101 | }; 102 | 103 | // function to execute whenever the login command is called 104 | export const LoginExecute = (app: JupyterFrontEnd): void => { 105 | makePostRequest('login', {}).then(data => { 106 | // data is a string turned into a json object 107 | const res = JSON.parse(data) as LoginResponse; 108 | 109 | // handle this branch later 110 | if (res.status === 'AlreadySignedIn') { 111 | let widget = alreadySignedInWidget(res.user); 112 | if (!widget.isDisposed) { 113 | widget.dispose(); 114 | widget = alreadySignedInWidget(res.user); 115 | } 116 | if (!widget.isAttached) { 117 | app.shell.add(widget, 'main'); 118 | } 119 | return; 120 | } 121 | 122 | // user may not have actually logged in yet 123 | // good enough for now 124 | GLOBAL_SETTINGS.setAuthenticated(true); 125 | 126 | let widget = signWidget(res); 127 | if (!widget.isDisposed) { 128 | widget.dispose(); 129 | widget = signWidget(res); 130 | } 131 | if (!widget.isAttached) { 132 | app.shell.add(widget, 'main'); 133 | } 134 | 135 | // countdown timer for expires in the this code will expire in {expiresin seconds} 136 | let timeRemaining = res.expiresIn; 137 | const interval = setInterval(() => { 138 | if (timeRemaining <= 0) { 139 | clearInterval(interval); 140 | widget.dispose(); 141 | return; 142 | } 143 | const timerElement = widget.node.querySelector('#timer'); 144 | if (timerElement) { 145 | timerElement.textContent = timeRemaining.toString(); 146 | } 147 | timeRemaining--; 148 | }, 1000); 149 | app.shell.activateById(widget.id); 150 | }); 151 | }; 152 | 153 | // function to execute when the signout command is called 154 | export const SignOutExecute = (app: JupyterFrontEnd): void => { 155 | makePostRequest('signout', {}).then(data => { 156 | const res = JSON.parse(data) as SignOutResponse; 157 | 158 | if (res.status === 'NotSignedIn') { 159 | let widget = SignedOutWidget(); 160 | GLOBAL_SETTINGS.setAuthenticated(false); 161 | if (!widget.isDisposed) { 162 | widget.dispose(); 163 | widget = SignedOutWidget(); 164 | } 165 | if (!widget.isAttached) { 166 | app.shell.add(widget, 'main'); 167 | } 168 | } 169 | }); 170 | }; 171 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyter_copilot", 3 | "version": "0.1.1", 4 | "description": "GitHub Copilot for Jupyter", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/baolong281/jupyter-copilot", 11 | "bugs": { 12 | "url": "https://github.com/baolong281/jupyter-copilot/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": { 16 | "name": "jupyter_copilot", 17 | "email": "dylanhuyn@gmail.com" 18 | }, 19 | "files": [ 20 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 21 | "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", 22 | "src/**/*.{ts,tsx}", 23 | "schema/*.json" 24 | ], 25 | "main": "lib/index.js", 26 | "types": "lib/index.d.ts", 27 | "style": "style/index.css", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/baolong281/jupyter-copilot.git" 31 | }, 32 | "scripts": { 33 | "build": "jlpm build:lib && jlpm build:labextension:dev", 34 | "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", 35 | "build:labextension": "jupyter labextension build .", 36 | "build:labextension:dev": "jupyter labextension build --development True .", 37 | "build:lib": "tsc --sourceMap", 38 | "build:lib:prod": "tsc", 39 | "clean": "jlpm clean:lib", 40 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 41 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 42 | "clean:labextension": "rimraf jupyter_copilot/labextension jupyter_copilot/_version.py", 43 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 44 | "eslint": "jlpm eslint:check --fix", 45 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 46 | "install:extension": "jlpm build", 47 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 48 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 49 | "prettier": "jlpm prettier:base --write --list-different", 50 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 51 | "prettier:check": "jlpm prettier:base --check", 52 | "stylelint": "jlpm stylelint:check --fix", 53 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 54 | "watch": "run-p watch:src watch:labextension", 55 | "watch:src": "tsc -w --sourceMap", 56 | "watch:labextension": "jupyter labextension watch ." 57 | }, 58 | "dependencies": { 59 | "@jupyterlab/application": "^4.2.4", 60 | "@jupyterlab/apputils": "^4.3.4", 61 | "@jupyterlab/codemirror": "^4.2.4", 62 | "@jupyterlab/completer": "^4.2.4", 63 | "@jupyterlab/coreutils": "^6.0.0", 64 | "@jupyterlab/fileeditor": "^4.2.4", 65 | "@jupyterlab/notebook": "^4.2.4", 66 | "@jupyterlab/services": "^7.0.0", 67 | "@jupyterlab/settingregistry": "^4.0.0", 68 | "@lumino/widgets": "^2.4.0" 69 | }, 70 | "devDependencies": { 71 | "@jupyterlab/builder": "^4.0.0", 72 | "@types/json-schema": "^7.0.11", 73 | "@types/react": "^18.0.26", 74 | "@types/react-addons-linked-state-mixin": "^0.14.22", 75 | "@typescript-eslint/eslint-plugin": "^6.1.0", 76 | "@typescript-eslint/parser": "^6.1.0", 77 | "css-loader": "^6.7.1", 78 | "eslint": "^8.36.0", 79 | "eslint-config-prettier": "^8.8.0", 80 | "eslint-plugin-prettier": "^5.0.0", 81 | "mkdirp": "^1.0.3", 82 | "npm-run-all": "^4.1.5", 83 | "prettier": "^3.0.0", 84 | "rimraf": "^5.0.1", 85 | "source-map-loader": "^1.0.2", 86 | "style-loader": "^3.3.1", 87 | "stylelint": "^15.10.1", 88 | "stylelint-config-recommended": "^13.0.0", 89 | "stylelint-config-standard": "^34.0.0", 90 | "stylelint-csstree-validator": "^3.0.0", 91 | "stylelint-prettier": "^4.0.0", 92 | "typescript": "~5.0.2", 93 | "yjs": "^13.5.0" 94 | }, 95 | "sideEffects": [ 96 | "style/*.css", 97 | "style/index.js" 98 | ], 99 | "styleModule": "style/index.js", 100 | "publishConfig": { 101 | "access": "public" 102 | }, 103 | "jupyterlab": { 104 | "discovery": { 105 | "server": { 106 | "managers": [ 107 | "pip" 108 | ], 109 | "base": { 110 | "name": "jupyter_copilot" 111 | } 112 | } 113 | }, 114 | "extension": true, 115 | "outputDir": "jupyter_copilot/labextension", 116 | "schemaDir": "schema" 117 | }, 118 | "eslintIgnore": [ 119 | "node_modules", 120 | "dist", 121 | "coverage", 122 | "**/*.d.ts" 123 | ], 124 | "eslintConfig": { 125 | "extends": [ 126 | "eslint:recommended", 127 | "plugin:@typescript-eslint/eslint-recommended", 128 | "plugin:@typescript-eslint/recommended", 129 | "plugin:prettier/recommended" 130 | ], 131 | "parser": "@typescript-eslint/parser", 132 | "parserOptions": { 133 | "project": "tsconfig.json", 134 | "sourceType": "module" 135 | }, 136 | "plugins": [ 137 | "@typescript-eslint" 138 | ], 139 | "rules": { 140 | "@typescript-eslint/naming-convention": [ 141 | "error", 142 | { 143 | "selector": "interface", 144 | "format": [ 145 | "PascalCase" 146 | ], 147 | "custom": { 148 | "regex": "^I[A-Z]", 149 | "match": true 150 | } 151 | } 152 | ], 153 | "@typescript-eslint/no-unused-vars": [ 154 | "warn", 155 | { 156 | "args": "none" 157 | } 158 | ], 159 | "@typescript-eslint/no-explicit-any": "off", 160 | "@typescript-eslint/no-namespace": "off", 161 | "@typescript-eslint/no-use-before-define": "off", 162 | "@typescript-eslint/quotes": [ 163 | "error", 164 | "single", 165 | { 166 | "avoidEscape": true, 167 | "allowTemplateLiterals": false 168 | } 169 | ], 170 | "curly": [ 171 | "error", 172 | "all" 173 | ], 174 | "eqeqeq": "error", 175 | "prefer-arrow-callback": "error" 176 | } 177 | }, 178 | "prettier": { 179 | "singleQuote": true, 180 | "trailingComma": "none", 181 | "arrowParens": "avoid", 182 | "endOfLine": "auto", 183 | "overrides": [ 184 | { 185 | "files": "package.json", 186 | "options": { 187 | "tabWidth": 4 188 | } 189 | } 190 | ] 191 | }, 192 | "stylelint": { 193 | "extends": [ 194 | "stylelint-config-recommended", 195 | "stylelint-config-standard", 196 | "stylelint-prettier/recommended" 197 | ], 198 | "plugins": [ 199 | "stylelint-csstree-validator" 200 | ], 201 | "rules": { 202 | "csstree/validator": true, 203 | "property-no-vendor-prefix": null, 204 | "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$", 205 | "selector-no-vendor-prefix": null, 206 | "value-no-vendor-prefix": null 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupyter_copilot 2 | 3 | [PyPi Package](https://pypi.org/project/jupyter-copilot/) 4 | 5 | A GitHub Copilot extension for JupyterLab. This extension uses the language server provided by [copilot.vim](https://github.com/github/copilot.vim) and the [@jupyter/completer](https://jupyterlab.readthedocs.io/en/latest/user/completer.html) module to provide native GitHub Copilot autocomplete into notebooks. 6 | 7 | ![https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/demo.gif](https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/demo.gif?raw=true) 8 | 9 | 10 | **⚠️ WARNING: You should not use this for remote notebooks over SSH as authentication for the extension server is currently disabled. Also, This extension also only supports JupyterLab, Jupyter Notebook 7 but not the Classic Notebook (v6)** 11 | 12 | **This extension is still very new and may be rough around the edges. If you experience any bugs or have any feature requests please feel free to open an issue or make a PR :)** 13 | 14 | ## Features 15 | 16 | - Inline completions with GitHub Copilot 🤖 17 | - Native GitHub authentication 🔐 18 | - Custom keybindings 🔥 19 | - Multilanguage support 20 | 21 | ## **Requirements** 22 | 23 | - JupyterLab >= 4.1.0 or Jupyter Notebook >= 7.1.0 24 | - Node.js >= 18.x 25 | 26 | ## Setup 27 | 28 | To install the extension, execute: 29 | 30 | ```bash 31 | pip install jupyter_copilot 32 | ``` 33 | 34 | To login to copilot open the command palette with `Ctrl+Shift+C` (`Cmd+Shift+C` on mac) then select the `Sign In With Github` command and follow the instructions. 35 | 36 | ![https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/login.png](https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/login.png?raw=true) 37 | ![https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/auth.png](https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/auth.png?raw=true) 38 | 39 | Once signed in open any notebook and the extension should run! 40 | 41 | ## Settings 42 | 43 | To change settings go to `Settings > Settings Editor` then search for Copilot. 44 | 45 | | Setting | Description | 46 | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 47 | | Enable/Disable | Enables or disables the extension | 48 | | Accept Keybind | The keybind you want to use to accept a completion, default value is `Ctrl + J`. This setting is just a string and is not validated to see if works. Currently using `Tab` for completions does not work, and you must refresh the notebook to see changes in effect. | 49 | 50 | ## Uninstall 51 | 52 | To remove the extension, execute: 53 | 54 | ```bash 55 | pip uninstall jupyter_copilot 56 | ``` 57 | 58 | ## Troubleshoot 59 | 60 | If you are seeing the frontend extension, but it is not working, check 61 | that the server extension is enabled: 62 | 63 | ```bash 64 | jupyter server extension list 65 | ``` 66 | 67 | If the server extension is installed and enabled, but you are not seeing 68 | the frontend extension, check the frontend extension is installed: 69 | 70 | ```bash 71 | jupyter labextension list 72 | ``` 73 | 74 | ## Contributing 75 | 76 | ### Development install 77 | 78 | Note: You will need NodeJS to build the extension package. 79 | 80 | The `jlpm` command is JupyterLab's pinned version of 81 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 82 | `yarn` or `npm` in lieu of `jlpm` below. 83 | 84 | ```bash 85 | # First install jupyterlab with pip 86 | # Clone the repo to your local environment 87 | # Change directory to the jupyter_copilot directory 88 | # Install package in development mode 89 | pip install -e "." 90 | # Link your development version of the extension with JupyterLab 91 | jupyter labextension develop . --overwrite 92 | # Server extension must be manually installed in develop mode 93 | jupyter server extension enable jupyter_copilot 94 | # Rebuild extension Typescript source after making changes 95 | jlpm build 96 | ``` 97 | 98 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 99 | 100 | ```bash 101 | # Watch the source directory in one terminal, automatically rebuilding when needed 102 | jlpm watch 103 | # Run JupyterLab in another terminal 104 | jupyter lab 105 | ``` 106 | 107 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 108 | 109 | By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 110 | 111 | ```bash 112 | jupyter lab build --minimize=False 113 | ``` 114 | 115 | ### Development uninstall 116 | 117 | ```bash 118 | # Server extension must be manually disabled in develop mode 119 | jupyter server extension disable jupyter_copilot 120 | pip uninstall jupyter_copilot 121 | ``` 122 | 123 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 124 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 125 | folder is located. Then you can remove the symlink named `jupyter_copilot` within that folder. 126 | 127 | ### Layout and structure 128 | 129 | ![https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/diagram.png](https://github.com/baolong281/jupyter-copilot/blob/656c425c9956eb1563a3f90990e0b270ebff725f/imgs/diagram.png?raw=true) 130 | 131 | This extension is composed of a Python package named `jupyter_copilot` 132 | for the server extension and a NPM package named `jupyter_copilot` 133 | for the frontend extension. 134 | 135 | The extension uses the language server provided by [copilot.vim](https://github.com/github/copilot.vim) for authentication and to actually fetch completions from GitHub. The language server is packaged as a node module caleld [copilot-node-server](https://github.com/jfcherng/copilot-node-server). 136 | 137 | The frontend is connected to the local extension server via websocket for updates to notebooks and to fetch completions from the LSP server. 138 | 139 | ## `jupyter_copilot` 140 | 141 | This is the code for the local server. `handler.py` has the handles any websocket messages from the frontend through a queue as to not break stuff. The handling of websocket messages is done in `NotebookLSPHandler`. There is another class `NotebookHandler` which creates an in-memory representation of the code from a notebook. This works by having an array for each code block, then indexing into the array and changing its content when theres an update. This class also uses the `lsp_client` to communicate with the LSP server. 142 | 143 | The actual node.js Copilot LSP server is spawned in as a process in `lsp.py`. The server is located in `node_modules/copilot-node-server/dist/copilot/language-server.js` and is spawned as. `lsp.py` provides an interface to communicate with this LSP server, and restart if it crashes. 144 | 145 | **When you make changes to this folder `npm run watch` will not detect the change, so you need to restart the Jupyter instance in the terminal to see changes take effect** 146 | 147 | ## TODO 148 | 149 | - Completions inside brackets 150 | - Find out a better keybind system 151 | - Copilot chat (?) 152 | - Custom providers (?) 153 | - Port to notebooks (?) 154 | 155 | --- 156 | 157 | \ 158 | Huge thank you to these projects ❤️ 159 | 160 | [copilot.vim](https://github.com/github/copilot.vim) 161 | 162 | [LSP-copilot](https://github.com/TerminalFi/LSP-copilot) 163 | 164 | [copilot-node-server](https://github.com/jfcherng/copilot-node-server) 165 | 166 | [copilot.lua](https://www.google.com/search?q=copilot.lua&oq=copilot.lua&aqs=chrome..69i57j0i512j35i39i512i650j69i60j5i44l2.1196j0j4&sourceid=chrome&ie=UTF-8) 167 | 168 | [stackoverflow post](https://stackoverflow.com/questions/76741410/how-to-invoke-github-copilot-programmatically) 169 | 170 | [GitHub Copilot](https://github.com/features/copilot) 171 | 172 | ### Packaging the extension 173 | 174 | See [RELEASE](RELEASE.md) 175 | -------------------------------------------------------------------------------- /jupyter_copilot/lsp.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | import threading 4 | import time 5 | from typing import Dict, Callable, Any, List 6 | import os 7 | 8 | class LSPWrapper: 9 | """ 10 | Wrapper class for interfacing with the Copilot LSP. 11 | initializes, sends messages, and reads output 12 | the actual LSP server is from copilot-node-server which actually calls Copilot servers 13 | https://www.npmjs.com/package/copilot-node-server?activeTab=dependents 14 | the LSP requires that we communicate with it through stdout using json rpc 15 | """ 16 | def __init__(self, logger): 17 | current_dir = os.path.dirname(os.path.abspath(__file__)) 18 | lsp_path = os.path.join(current_dir, "dist", "language-server.js") 19 | node_path = os.getenv("JUPYTER_COPILOT_NODE_PATH", "node") 20 | self.spawn_command = [node_path, lsp_path, "--stdio"] 21 | 22 | 23 | self.logger = logger 24 | 25 | self.process = self.__spawn_process() 26 | self.request_id = 0 27 | 28 | # lock for restarting callback thread 29 | self.restart_lock = threading.Lock() 30 | 31 | # these maps hold callbacks for requests for when we recieve a response 32 | self.resolve_map: Dict[int, Callable[[Any], None]] = {} 33 | self.reject_map: Dict[int, Callable[[Any], None]] = {} 34 | 35 | # Start reading output in a separate thread 36 | self.output_thread = threading.Thread(target=self.__read_output) 37 | self.output_thread.start() 38 | self.restart_callbacks: List[Callable[[], None]] = [] 39 | 40 | # Check if the process started successfully 41 | if self.is_process_running() != 0: 42 | raise RuntimeError("Failed to start the LSP server process") 43 | 44 | self.wait(500) 45 | self.__send_startup_notification() 46 | self.logger.debug("[Copilot] LSP server started successfully") 47 | 48 | def register_restart_callback(self, callback: Callable[[], None]): 49 | self.restart_callbacks.append(callback) 50 | 51 | def unregister_restart_callback(self, callback: Callable[[], None]): 52 | """ remove callback from the list """ 53 | self.restart_callbacks.remove(callback) 54 | 55 | 56 | def __spawn_process(self) -> subprocess.Popen[str]: 57 | """ spawns LSP process then returns it""" 58 | self.logger.debug("[Copilot] Spawning LSP process with command %s", self.spawn_command) 59 | try: 60 | # start the process and throw an error if it fails 61 | process = subprocess.Popen( 62 | self.spawn_command, 63 | stdin=subprocess.PIPE, 64 | stdout=subprocess.PIPE, 65 | stderr=subprocess.PIPE, 66 | text=True, 67 | bufsize=0 68 | ) 69 | except FileNotFoundError as e: 70 | self.logger.error( 71 | f"Error: Could not find the specified file or directory. Full error: {e}") 72 | self.logger.error(f"Current working directory: {os.getcwd()}") 73 | raise 74 | except PermissionError as e: 75 | self.logger.error( 76 | f"Error: Permission denied when trying to execute the command. Full error: {e}") 77 | raise 78 | except Exception as e: 79 | self.logger.error( 80 | f"An unexpected error occurred while starting the LSP server: {e}") 81 | raise 82 | 83 | 84 | return process 85 | 86 | def __send_startup_notification(self): 87 | """ 88 | send the initialize request to the lsp server 89 | must be called after the server has started 90 | """ 91 | self.logger.debug("[Copilot] Sending initialize request to LSP server") 92 | self.send_request("initialize", { 93 | "capabilities": {"workspace": {"workspaceFolders": True}} 94 | }) 95 | 96 | self.send_notification("initialized", {}) 97 | 98 | 99 | def is_process_running(self) -> int: 100 | """ 101 | polls the process to see if it is running 102 | if it is running return 0 else return the exit code 103 | this might be be bad if the process exited with code 0 104 | fix later 105 | """ 106 | if self.process.poll() is None: 107 | return 0 108 | else: 109 | # only print out if exit code is not 130 110 | # if exit code is 130, ctrl + c was pressed in terminal 111 | # printing will mess up the exit confirmation 112 | if self.process.returncode != 130: 113 | self.logger.error(f"LSP server process has terminated. Exit code: {self.process.returncode}") 114 | self.logger.error("stderr output:") 115 | 116 | return self.process.returncode 117 | 118 | 119 | def __restart_server(self): 120 | """ 121 | restarts the server process 122 | this should run in a seperate thread 123 | """ 124 | with self.restart_lock: 125 | self.logger.debug("[Copilot] Restarting LSP server...") 126 | if self.process: 127 | self.process.terminate() 128 | self.process.wait() 129 | 130 | self.process = self.__spawn_process() 131 | 132 | self.wait(500) 133 | 134 | self.__send_startup_notification() 135 | 136 | if self.is_process_running() != 0: 137 | raise RuntimeError("Failed to restart the LSP server process") 138 | 139 | for callback in self.restart_callbacks: 140 | callback() 141 | 142 | def __create_restart_thread(self): 143 | """ 144 | restart the serer in a new thread 145 | """ 146 | if not self.restart_lock.locked(): 147 | restart_thread = threading.Thread(target=self.__restart_server) 148 | restart_thread.start() 149 | 150 | 151 | def __read_output(self): 152 | """ 153 | this runs in a separate thread to read the output from the lsp 154 | if the process is not running and the exit code was not 130 (ctrl + c) then restart the server 155 | """ 156 | while True: 157 | process_return_code = self.is_process_running() 158 | # if ctrl + c just exit the thread 159 | # process should have already been killed 160 | # if you press ctrl + c but cancel then the LSP will be killed and you have to restart the program 161 | # fix this later 162 | if process_return_code == 130: 163 | return 164 | # if the process is not running, restart it 165 | elif process_return_code != 0: 166 | self.logger.debug("[Copilot] LSP server process has stopped. Attempting to restart...") 167 | 168 | self.__create_restart_thread() 169 | 170 | # wait 10 ms before checking again 171 | # the output thread keeps looping so it would print out the error message multiple times 172 | self.wait(10) 173 | continue 174 | 175 | if not self.process.stdout: 176 | self.logger.erorr("Erorr: stdout is none") 177 | continue 178 | 179 | header = self.process.stdout.readline() 180 | if not header: 181 | continue 182 | try: 183 | content_length = int(header.strip().split(': ')[1]) 184 | self.process.stdout.readline() # Read the empty line 185 | content = self.process.stdout.read(content_length) 186 | self._handle_received_payload(json.loads(content)) 187 | except Exception as e: 188 | self.logger.error(f"Error processing server output: {e}") 189 | 190 | 191 | def send_notification(self, method: str, params: dict): 192 | """ send notification to lsp server with no response """ 193 | self.__send_message({"method": method, "params": params}) 194 | 195 | def __send_message(self, data: dict): 196 | """ send message with lsp format to lsp server """ 197 | if self.is_process_running() != 0: 198 | raise RuntimeError("The LSP server process has terminated unexpectedly.") 199 | 200 | message = json.dumps({**data, "jsonrpc": "2.0"}) 201 | content_length = len(message.encode('utf-8')) 202 | rpc_message = f"Content-Length: {content_length}\r\n\r\n{message}" 203 | try: 204 | if not self.process.stdin: 205 | self.logger.error("Error: stdin is none") 206 | return 207 | self.process.stdin.write(rpc_message) 208 | self.process.stdin.flush() 209 | except BrokenPipeError: 210 | self.logger.error("Error: Broken pipe. The LSP server process may have terminated unexpectedly.") 211 | # restart the server in new thread 212 | raise 213 | 214 | 215 | 216 | def send_request(self, method: str, params: dict) -> Any: 217 | """ 218 | sends a request to the lsp and returns the response 219 | if a response comes then __handle_recieved_payloads will be called 220 | and will run the resolve or reject callback 221 | """ 222 | self.request_id += 1 223 | self.__send_message({"id": self.request_id, "method": method, "params": params}) 224 | result = threading.Event() 225 | response = {} 226 | 227 | def resolve(payload): 228 | response['result'] = payload 229 | result.set() 230 | 231 | def reject(payload): 232 | response['error'] = payload 233 | result.set() 234 | 235 | # put the callback into the map 236 | # when we get the response, we will call resolve or reject and the entry will be popped 237 | self.resolve_map[self.request_id] = resolve 238 | self.reject_map[self.request_id] = reject 239 | 240 | # 10 second timeout to prevent indefinite waiting 241 | # this will immediately stop blocking if the result is set by calling either resolve or reject 242 | result.wait(timeout=10) 243 | 244 | # at this point if a response has not been received then result will not be set, so we throw an error 245 | if not result.is_set(): 246 | raise TimeoutError(f"Request timed out: method={method}, id={self.request_id}") 247 | 248 | if 'error' in response: 249 | raise Exception(response['error']) 250 | 251 | self.resolve_map.pop(self.request_id, None) 252 | self.reject_map.pop(self.request_id, None) 253 | return response['result'] 254 | 255 | def _handle_received_payload(self, payload: dict): 256 | """ 257 | handle the payload from the lsp server 258 | if the payload has an id, then call the resolve or reject callback 259 | """ 260 | if "id" in payload: 261 | if "result" in payload: 262 | # pop from map then call 263 | resolve = self.resolve_map.pop(payload["id"], None) 264 | if resolve: 265 | resolve(payload["result"]) 266 | elif "error" in payload: 267 | reject = self.reject_map.pop(payload["id"], None) 268 | if reject: 269 | reject(payload["error"]) 270 | 271 | @ staticmethod 272 | def wait(ms: int): 273 | time.sleep(ms / 1000) 274 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { IDisposable } from '@lumino/disposable'; 6 | import { INotebookTracker } from '@jupyterlab/notebook'; 7 | import { ServerConnection } from '@jupyterlab/services'; 8 | import { URLExt } from '@jupyterlab/coreutils'; 9 | import { NotebookLSPClient } from './lsp'; 10 | import { ICommandPalette } from '@jupyterlab/apputils'; 11 | import { 12 | ICompletionProviderManager, 13 | IInlineCompletionItem, 14 | IInlineCompletionList, 15 | IInlineCompletionProvider, 16 | IInlineCompletionContext, 17 | CompletionHandler 18 | } from '@jupyterlab/completer'; 19 | import { CodeEditor } from '@jupyterlab/codeeditor'; 20 | import { LoginExecute, SignOutExecute } from './commands/authentication'; 21 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 22 | import { makePostRequest } from './utils'; 23 | 24 | class GlobalSettings { 25 | enabled: boolean; 26 | completionBind: string; 27 | authenticated: boolean; 28 | 29 | constructor() { 30 | this.enabled = true; 31 | this.completionBind = 'Ctrl J'; 32 | this.authenticated = false; 33 | 34 | makePostRequest('login', {}) 35 | .then(response => { 36 | const res = JSON.parse(response) as any; 37 | this.authenticated = res.status === 'AlreadySignedIn'; 38 | console.log(this.authenticated); 39 | }) 40 | .catch(error => { 41 | console.error('Error checking authentication state:', error); 42 | }); 43 | } 44 | 45 | setEnabled(enabled: boolean) { 46 | this.enabled = enabled; 47 | } 48 | 49 | setCompletionBind(completionBind: string) { 50 | this.completionBind = completionBind; 51 | } 52 | 53 | setAuthenticated(authenticated: boolean) { 54 | this.authenticated = authenticated; 55 | } 56 | } 57 | 58 | export const GLOBAL_SETTINGS = new GlobalSettings(); 59 | 60 | class CopilotInlineProvider implements IInlineCompletionProvider { 61 | readonly name = 'GitHub Copilot'; 62 | readonly identifier = 'jupyter_copilot:provider'; 63 | readonly rank = 1000; 64 | notebookClients: Map; 65 | private lastRequestTime: number = 0; 66 | private timeout: any = null; 67 | private lastResolved: ( 68 | value: 69 | | IInlineCompletionList 70 | | PromiseLike> 71 | ) => void = () => {}; 72 | private requestInProgress: boolean = false; 73 | 74 | constructor(notebookClients: Map) { 75 | this.notebookClients = notebookClients; 76 | } 77 | 78 | async fetch( 79 | request: CompletionHandler.IRequest, 80 | context: IInlineCompletionContext 81 | ): Promise> { 82 | if (!GLOBAL_SETTINGS.enabled || !GLOBAL_SETTINGS.authenticated) { 83 | return { items: [] }; 84 | } 85 | 86 | const now = Date.now(); 87 | 88 | // debounce mechanism 89 | // if a request is made within 90ms of the last request, throttle the request 90 | // but if it is the last request, then make the request 91 | if (this.requestInProgress || now - this.lastRequestTime < 150) { 92 | this.lastRequestTime = now; 93 | 94 | // this request was made less than 90ms after the last request 95 | // so we resolve the last request with an empty list then clear the timeout 96 | this.lastResolved({ items: [] }); 97 | clearTimeout(this.timeout); 98 | 99 | return new Promise(resolve => { 100 | this.lastResolved = resolve; 101 | // set a timeout that will resolve the request after 200ms 102 | // if no calls are made within 90ms then this will resolve and fetch 103 | // if a call comes in < 90ms then this will be cleared and the request will be solved to empty list 104 | this.timeout = setTimeout(async () => { 105 | this.requestInProgress = true; 106 | this.lastRequestTime = Date.now(); 107 | 108 | const items = await this.fetchCompletion(request, context); 109 | 110 | resolve(items); 111 | }, 200); 112 | }); 113 | } else { 114 | // if request is not throttled, just get normally 115 | this.requestInProgress = true; 116 | this.lastRequestTime = now; 117 | 118 | return await this.fetchCompletion(request, context); 119 | } 120 | } 121 | 122 | // logic to actually fetch the completion 123 | private async fetchCompletion( 124 | _request: CompletionHandler.IRequest, 125 | context: IInlineCompletionContext 126 | ): Promise> { 127 | const editor = (context as any).editor as CodeEditor.IEditor; 128 | const cell = (context.widget as any)._content._activeCellIndex; 129 | const client = this.notebookClients.get((context.widget as any).id); 130 | const cursor = editor?.getCursorPosition(); 131 | const { line, column } = cursor; 132 | client?.sendUpdateLSPVersion(); 133 | const items: IInlineCompletionItem[] = []; 134 | const completions = await client?.getCopilotCompletion(cell, line, column); 135 | completions?.forEach(completion => { 136 | items.push({ 137 | // sometimes completions have ``` in them, so we remove it 138 | insertText: completion.displayText.replace('```', ''), 139 | isIncomplete: false 140 | }); 141 | }); 142 | this.requestInProgress = false; 143 | return { items }; 144 | } 145 | } 146 | 147 | /** 148 | * Initialization data for the jupyter_copilot extension. 149 | */ 150 | const plugin: JupyterFrontEndPlugin = { 151 | id: 'jupyter_copilot:plugin', 152 | description: 'GitHub Copilot for Jupyter', 153 | autoStart: true, 154 | requires: [ 155 | INotebookTracker, 156 | ICompletionProviderManager, 157 | ICommandPalette, 158 | ISettingRegistry 159 | ], 160 | activate: ( 161 | app: JupyterFrontEnd, 162 | notebookTracker: INotebookTracker, 163 | providerManager: ICompletionProviderManager, 164 | palette: ICommandPalette, 165 | settingRegistry: ISettingRegistry 166 | ) => { 167 | console.debug('Jupyter Copilot Extension Activated'); 168 | 169 | const command = 'jupyter_copilot:completion'; 170 | 171 | app.commands.addCommand(command, { 172 | label: 'Copilot Completion', 173 | execute: () => { 174 | // get id of current notebook panel 175 | const notebookPanelId = notebookTracker.currentWidget?.id; 176 | providerManager.inline?.accept(notebookPanelId || ''); 177 | } 178 | }); 179 | 180 | Promise.all([app.restored, settingRegistry.load(plugin.id)]).then( 181 | ([, settings]) => { 182 | let keybindingDisposer: IDisposable | null = null; 183 | const loadSettings = (settings: ISettingRegistry.ISettings) => { 184 | const enabled = settings.get('flag').composite as boolean; 185 | const completion_bind = settings.get('keybind').composite as string; 186 | GLOBAL_SETTINGS.setEnabled(enabled); 187 | GLOBAL_SETTINGS.setCompletionBind(completion_bind); 188 | 189 | console.debug('Settings loaded:', enabled, completion_bind); 190 | 191 | if (keybindingDisposer) { 192 | const currentKeys = app.commands.keyBindings.find( 193 | kb => kb.command === command 194 | )?.keys; 195 | console.debug('Disposing old keybinding ', currentKeys); 196 | keybindingDisposer.dispose(); 197 | keybindingDisposer = null; 198 | } 199 | keybindingDisposer = app.commands.addKeyBinding({ 200 | command, 201 | keys: [completion_bind], 202 | selector: '.cm-editor' 203 | }); 204 | }; 205 | 206 | loadSettings(settings); 207 | 208 | settings.changed.connect(loadSettings); 209 | const SignInCommand = 'Copilot: Sign In'; 210 | app.commands.addCommand(SignInCommand, { 211 | label: 'Copilot: Sign In With GitHub', 212 | iconClass: 'cpgithub-icon', 213 | execute: () => LoginExecute(app) 214 | }); 215 | 216 | const SignOutCommand = 'Copilot: Sign Out'; 217 | app.commands.addCommand(SignOutCommand, { 218 | label: 'Copilot: Sign Out With GitHub', 219 | iconClass: 'cpgithub-icon', 220 | execute: () => SignOutExecute(app) 221 | }); 222 | 223 | // make them pop up at the top of the palette first items on the palleete commands and update rank 224 | palette.addItem({ 225 | command: SignInCommand, 226 | category: 'GitHub Copilot', 227 | rank: 0 228 | }); 229 | palette.addItem({ 230 | command: SignOutCommand, 231 | category: 'GitHub Copilot', 232 | rank: 1 233 | }); 234 | } 235 | ); 236 | 237 | const notebookClients = new Map(); 238 | 239 | const provider = new CopilotInlineProvider(notebookClients); 240 | providerManager.registerInlineProvider(provider); 241 | 242 | const serverSettings = ServerConnection.makeSettings(); 243 | 244 | // notebook tracker is used to keep track of the notebooks that are open 245 | // when a new notebook is opened, we create a new LSP client and socket connection for that notebook 246 | notebookTracker.widgetAdded.connect(async (_, notebook) => { 247 | await notebook.context.ready; 248 | 249 | const wsURL = URLExt.join(serverSettings.wsUrl, 'jupyter-copilot', 'ws'); 250 | const client = new NotebookLSPClient(notebook.context.path, wsURL); 251 | notebookClients.set(notebook.id, client); 252 | 253 | notebook.sessionContext.ready.then(() => { 254 | notebook.sessionContext.session?.kernel?.info.then(info => { 255 | client.setNotebookLanguage(info.language_info.name); 256 | }); 257 | 258 | notebook.sessionContext.kernelChanged.connect(async (_, kernel) => { 259 | const info = await kernel.newValue?.info; 260 | client.setNotebookLanguage(info?.language_info.name as string); 261 | }); 262 | }); 263 | 264 | // run whenever a notebook cell updates 265 | // types are of ISharedCodeCell and CellChange 266 | // i cannot import them and i cannot find where they are supposed to be 267 | const onCellUpdate = (update: any, change: any) => { 268 | // only change if it is a source change 269 | if (change.sourceChange) { 270 | const content = update.source; 271 | client.sendCellUpdate(notebook.content.activeCellIndex, content); 272 | } 273 | }; 274 | 275 | // keep the current cell so when can clean up whenever this changes 276 | let current_cell = notebook.content.activeCell; 277 | current_cell?.model.sharedModel.changed.connect(onCellUpdate); 278 | 279 | // run cleanup when notebook is closed 280 | notebook.disposed.connect(() => { 281 | client.dispose(); 282 | notebookClients.delete(notebook.id); 283 | }); 284 | 285 | // notifies the extension server when a cell is added or removed 286 | // swapping consists of an add and a remove, so this should be sufficient 287 | notebook.model?.cells.changed.connect((_, change) => { 288 | if (change.type === 'remove') { 289 | client.sendCellDelete(change.oldIndex); 290 | } else if (change.type === 'add') { 291 | const content = change.newValues[0].sharedModel.getSource(); 292 | client.sendCellAdd(change.newIndex, content); 293 | } 294 | }); 295 | 296 | notebook.context.pathChanged.connect((_, newPath) => { 297 | client.sendPathChange(newPath); 298 | }); 299 | 300 | // whenever active cell changes remove handler then add to new one 301 | notebook.content.activeCellChanged.connect((_, cell) => { 302 | current_cell?.model.sharedModel.changed.disconnect(onCellUpdate); 303 | current_cell = cell; 304 | current_cell?.model.sharedModel.changed.connect(onCellUpdate); 305 | }); 306 | }); 307 | } 308 | }; 309 | 310 | export default plugin; 311 | -------------------------------------------------------------------------------- /jupyter_copilot/handlers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Dict, List 3 | from tornado.ioloop import IOLoop 4 | from tornado.websocket import WebSocketHandler 5 | from jupyter_server.utils import url_path_join 6 | import logging 7 | import json 8 | import nbformat 9 | import os 10 | from jupyter_copilot.lsp import LSPWrapper 11 | from jupyter_server.base.handlers import JupyterHandler 12 | 13 | class NotebookManager: 14 | """ 15 | class managing the content of the notebook in memory 16 | notebook code is stored in an array of strings, each string representing a cell 17 | on an update we update the cell index in the array 18 | """ 19 | def __init__(self, path: str) -> None: 20 | self.path = path 21 | # remove leading slash for name 22 | self.name = path[1:] if path.startswith("/") else path 23 | self.document_version = 0 24 | self.language = "python" 25 | self.notebook_cells = self.load_notebook() 26 | 27 | # callback to run if the lsp server is ever restarted 28 | # need to reload the notebook content into the lsp server 29 | def _restart_callback(): 30 | self.load_notebook() 31 | 32 | self._callback = _restart_callback 33 | lsp_client.register_restart_callback(self._callback) 34 | logging.debug("[Copilot] Notebook manager initialized for %s", self.path) 35 | 36 | def load_notebook(self) -> List[str]: 37 | """ 38 | read the content of the notebook into the cells 39 | only runs on the first sync / when the notebook is opened 40 | """ 41 | 42 | if not os.path.exists(self.path): 43 | raise FileNotFoundError(f"Notebook {self.path} not found") 44 | 45 | with open(self.path, 'r') as f: 46 | nb = nbformat.read(f, as_version=4) 47 | 48 | code = self.extract_code_cells(nb) 49 | 50 | # if new notebook, code will be empty so just add empty string 51 | if len(code) == 0: 52 | code = [''] 53 | 54 | # when a notebook is newly created and never run this information is not available 55 | if nb.metadata and nb.metadata.kernelspec: 56 | self.language = nb.metadata.kernelspec.language.lower() 57 | 58 | lsp_client.send_notification("textDocument/didOpen", { 59 | "textDocument": { 60 | "uri": f"file:///{self.name}", 61 | "languageId": self.language, 62 | "version": self.document_version, 63 | "text": "".join(code) 64 | } 65 | }) 66 | 67 | return code 68 | 69 | def extract_code_cells(self, notebook: nbformat.NotebookNode) -> List[str]: 70 | """ extract code cells from a notebook into a list of strings """ 71 | return [cell.source for cell in notebook.cells if (cell.cell_type == "code" or cell.cell_type == "markdown")] 72 | 73 | def delete_cell(self, cell_id: int) -> None: 74 | """ deletes a cell id from the array if it exists """ 75 | if 0 <= cell_id < len(self.notebook_cells): 76 | self.notebook_cells.pop(cell_id) 77 | else: 78 | logging.error(f"Cell {cell_id} does not exist") 79 | 80 | def add_cell(self, cell_id: int, content: str) -> None: 81 | """ 82 | inserts a cell into the array at the given index 83 | if the cell index is larger than the length, make a blunch of blank cells 84 | """ 85 | if 0 <= cell_id <= len(self.notebook_cells): 86 | self.notebook_cells.insert(cell_id, content) 87 | elif cell_id > len(self.notebook_cells): 88 | # fill in the gap with empty strings if the cell_id is greater than the length of the array for some reason 89 | for _ in range(cell_id - len(self.notebook_cells)): 90 | self.notebook_cells.append('') 91 | self.notebook_cells.append(content) 92 | 93 | 94 | def update_cell(self, cell_id: int, content: str) -> None: 95 | """ index into array and update the cell content if it exists """ 96 | if 0 <= cell_id < len(self.notebook_cells): 97 | self.notebook_cells[cell_id] = content 98 | else: 99 | logging.error(f"Cell {cell_id} does not exist") 100 | 101 | def get_full_code(self) -> str: 102 | """ return the full code of the notebook as a string """ 103 | return "\n\n".join(self.notebook_cells) 104 | 105 | def send_full_update(self) -> None: 106 | """ sends an update to the lsp with the latest code """ 107 | self.document_version += 1 108 | code = self.get_full_code() 109 | lsp_client.send_notification("textDocument/didChange", { 110 | "textDocument": { 111 | "uri": f"file:///{self.name}", 112 | "version": self.document_version 113 | }, 114 | "contentChanges": [{"text": code}] 115 | }) 116 | logging.debug("[Copilot] Sending full update for %s", self.path) 117 | 118 | def request_completion(self, cell_id: int, line: int, character: int) -> Dict[str, Any]: 119 | """ 120 | requests a completion from the lsp server given a cell id, line number, and character position 121 | then returns the response 122 | """ 123 | line = self.__get_absolute_line_num(cell_id, line) 124 | logging.debug(f"[Copilot] Requesting completion for cell {cell_id}, line {line}, character {character}") 125 | response = lsp_client.send_request("getCompletions", { 126 | "doc": { 127 | "uri": f"file:///{self.name}", 128 | "position": {"line": line, "character": character}, 129 | "version": self.document_version 130 | } 131 | }) 132 | 133 | return response 134 | 135 | def __get_absolute_line_num(self, cellId: int, line: int) -> int: 136 | """ 137 | given cellid and line of the current cell, return the absolute line number in the code representation 138 | this sort of sucks but it works 139 | """ 140 | return sum([len(cell.split('\n')) for cell in self.notebook_cells[:cellId]]) + line + cellId 141 | 142 | def handle_path_change(self, path: str) -> None: 143 | """ on path change, send close signal to lsp and open signal with new path """ 144 | 145 | self.send_close_signal() 146 | 147 | self.path = path 148 | self.name = path[1:] if path.startswith("/") else path 149 | 150 | lsp_client.send_notification("textDocument/didOpen", { 151 | "textDocument": { 152 | "uri": f"file:///{self.name}", 153 | "languageId": self.language, 154 | "version": self.document_version, 155 | "text": self.get_full_code() 156 | } 157 | }) 158 | 159 | logging.debug(f"[Copilot] Path changed to {self.path}") 160 | 161 | def send_close_signal(self) -> None: 162 | """ send a close signal to the lsp server """ 163 | logging.debug("[Copilot] Sending close signal to LSP for %s", self.path) 164 | lsp_client.send_notification("textDocument/didClose", { 165 | "textDocument": { 166 | "uri": f"file:///{self.name}" 167 | } 168 | }) 169 | 170 | def set_language(self, language: str) -> None: 171 | """ 172 | closes and opens the lsp server with the new language 173 | this runs whenever a notebook is initially loaded 174 | """ 175 | self.language = language 176 | self.send_close_signal( ) 177 | lsp_client.send_notification("textDocument/didOpen", { 178 | "textDocument": { 179 | "uri": f"file:///{self.name}", 180 | "languageId": self.language, 181 | "version": self.document_version, 182 | "text": self.get_full_code() 183 | } 184 | }) 185 | logging.debug(f"[Copilot] Language set to {language}") 186 | 187 | 188 | class NotebookLSPHandler(WebSocketHandler): 189 | def initialize(self): 190 | self.notebook_manager: NotebookManager | None = None 191 | # we need a queue so that we can fully process one request before moving onto the next 192 | self.message_queue = asyncio.Queue() 193 | # register functino to run in the background 194 | IOLoop.current().add_callback(self.process_message_queue) 195 | 196 | async def open(self, *args, **kwargs): 197 | notebook_path = self.get_argument('path', '') 198 | notebook_path = os.path.join(root_dir, notebook_path) 199 | self.notebook_manager = NotebookManager(notebook_path) 200 | await self.send_message('connection_established', {}) 201 | logging.debug("[Copilot] WebSocket opened") 202 | 203 | async def on_message(self, message): 204 | try: 205 | data = json.loads(message) 206 | await self.message_queue.put(data) 207 | except json.JSONDecodeError: 208 | logging.error(f"Received invalid JSON: {message}") 209 | 210 | # constantly runs in the background to process messages from the queue 211 | # fully processes one message before moving onto the next to not break stuff 212 | async def process_message_queue(self): 213 | while True: 214 | try: 215 | data = await self.message_queue.get() 216 | if data['type'] == 'cell_update': 217 | await self.handle_cell_update(data) 218 | elif data['type'] == 'cell_add': 219 | await self.handle_cell_add(data) 220 | elif data['type'] == 'get_completion': 221 | await self.handle_completion_request(data) 222 | elif data['type'] == 'update_lsp_version': 223 | await self.handle_update_lsp_version() 224 | elif data['type'] == 'cell_delete': 225 | await self.handle_cell_delete(data) 226 | elif data['type'] == 'sync_request': 227 | await self.handle_sync_request() 228 | elif data['type'] == 'change_path': 229 | await self.handler_path_change(data); 230 | elif data['type'] == 'set_language': 231 | await self.handle_set_language(data) 232 | 233 | # Add other message types as needed 234 | except Exception as e: 235 | logging.error(f"Error processing message: {e}") 236 | finally: 237 | self.message_queue.task_done() 238 | 239 | async def handle_update_lsp_version(self): 240 | if self.notebook_manager is None: 241 | raise Exception("Notebook manager not initialized") 242 | 243 | self.notebook_manager.send_full_update() 244 | 245 | async def handler_path_change(self, data): 246 | if self.notebook_manager is None: 247 | raise Exception("Notebook manager not initialized") 248 | 249 | notebook_path = data['new_path'] 250 | notebook_path = os.path.join(root_dir, notebook_path) 251 | self.notebook_manager.handle_path_change(notebook_path) 252 | 253 | async def handle_set_language(self, data): 254 | if self.notebook_manager is None: 255 | raise Exception("Notebook manager not initialized") 256 | 257 | self.notebook_manager.set_language(data['language']) 258 | 259 | 260 | async def handle_completion_request(self, data): 261 | if self.notebook_manager is None: 262 | raise Exception("Notebook manager not initialized") 263 | 264 | response = self.notebook_manager.request_completion( 265 | data['cell_id'], 266 | data['line'], data['character']) 267 | response['req_id'] = data['req_id'] 268 | await self.send_message('completion', response) 269 | 270 | async def handle_sync_request(self): 271 | if self.notebook_manager is None: 272 | raise Exception("Notebook manager not initialized") 273 | 274 | code = self.notebook_manager.get_full_code() 275 | await self.send_message('sync_response', {'code': code}) 276 | 277 | async def handle_cell_add(self, data): 278 | if self.notebook_manager is None: 279 | raise Exception("Notebook manager not initialized") 280 | 281 | self.notebook_manager.add_cell(data['cell_id'], data['content']) 282 | 283 | async def handle_cell_update(self, data): 284 | if self.notebook_manager is None: 285 | raise Exception("Notebook manager not initialized") 286 | 287 | self.notebook_manager.update_cell(data['cell_id'], data['content']) 288 | 289 | async def handle_cell_delete(self, data): 290 | if self.notebook_manager is None: 291 | raise Exception("Notebook manager not initialized") 292 | self.notebook_manager.delete_cell(data['cell_id']) 293 | 294 | async def send_message(self, msg_type, payload): 295 | message = json.dumps({'type': msg_type, **payload}) 296 | try: 297 | await self.write_message(message) 298 | except Exception as e: 299 | logging.error(f"Error sending message: {e}") 300 | 301 | def on_close(self): 302 | logging.debug("[Copilot] WebSocket closed") 303 | 304 | if self.notebook_manager is None: 305 | raise Exception("Notebook manager not initialized") 306 | 307 | # when socket is closed send the close signal to server 308 | # unregister the lsp server restart callback 309 | self.notebook_manager.send_close_signal() 310 | lsp_client.unregister_restart_callback(self.notebook_manager._callback) 311 | self.notebook_manager = None 312 | 313 | class AuthHandler(JupyterHandler): 314 | async def post(self): 315 | action = self.request.path.split("/")[-1] 316 | if action == "login": 317 | res = lsp_client.send_request("signInInitiate", {}) 318 | elif action == "signout": 319 | res = lsp_client.send_request("signOut", {}) 320 | else: 321 | self.set_status(404) 322 | res = {"error": "Invalid action"} 323 | 324 | self.finish(res) 325 | 326 | def setup_handlers(server_app): 327 | global logging 328 | logging = server_app.log 329 | 330 | global root_dir 331 | root_dir = server_app.root_dir 332 | 333 | global lsp_client 334 | lsp_client = LSPWrapper(logging) 335 | 336 | web_app = server_app.web_app 337 | host_pattern = ".*$" 338 | base_url = web_app.settings["base_url"] + "jupyter-copilot" 339 | handlers = [ 340 | (url_path_join(base_url, "ws"), NotebookLSPHandler), 341 | (url_path_join(base_url, "login"), AuthHandler), 342 | (url_path_join(base_url, "signout"), AuthHandler), 343 | ] 344 | web_app.add_handlers(host_pattern, handlers) 345 | 346 | for handler in handlers: 347 | logging.info("jupyter_copilot | Registered handler at %s", handler[0]) 348 | 349 | logging.info("jupyter_copilot | Sucessfully registered handlers at %s", base_url) 350 | --------------------------------------------------------------------------------