├── .copier-answers.yml ├── .github └── workflows │ ├── binder-on-pr.yml │ ├── build.yml │ ├── check-release.yml │ ├── enforce-label.yml │ ├── prep-release.yml │ ├── publish-release.yml │ └── update-integration-tests.yml ├── .gitignore ├── .prettierignore ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── archive.gif ├── binder ├── environment.yml └── postBuild ├── conftest.py ├── environment.yml ├── install.json ├── jupyter-config └── server-config │ └── jupyter-archive.json ├── jupyter_archive ├── __init__.py ├── handlers.py └── tests │ └── test_archive_handler.py ├── package.json ├── pyproject.toml ├── schema └── archive.json ├── setup.py ├── src ├── icon.ts ├── index.ts └── svg.d.ts ├── style ├── base.css ├── icons │ ├── archive.svg │ └── unarchive.svg ├── index.css └── index.js ├── tsconfig.json ├── ui-tests ├── README.md ├── jupyter_server_test_config.py ├── package.json ├── playwright-notebook.config.js ├── playwright.config.js ├── tests │ ├── data │ │ └── folder.tar.xz │ └── jupyter-archive.spec.ts └── yarn.lock └── yarn.lock /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v4.1.0 3 | _src_path: https://github.com/jupyterlab/extension-template 4 | author_email: 'Hadrien Mary' 5 | author_name: 'hadrien.mary@gmail.com' 6 | data_format: string 7 | file_extension: '' 8 | has_binder: true 9 | has_settings: true 10 | kind: server 11 | labextension_name: '@hadim/jupyter-archive' 12 | mimetype: '' 13 | mimetype_name: '' 14 | project_short_description: A Jupyterlab extension to make, download and extract archive 15 | files. 16 | python_name: jupyter_archive 17 | repository: https://github.com/jupyterlab-contrib/jupyter-archive.git 18 | test: true 19 | viewer_name: '' 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Binder Badge 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | jobs: 7 | binder: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: jupyterlab/maintainer-tools/.github/actions/binder-link@v1 13 | with: 14 | github_token: ${{ secrets.github_token }} 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Base Setup 18 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 19 | 20 | - name: Install dependencies 21 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 22 | 23 | - name: Lint the extension 24 | run: | 25 | set -eux 26 | jlpm 27 | jlpm run lint:check 28 | 29 | - name: Build the extension 30 | run: | 31 | set -eux 32 | python -m pip install .[test] 33 | 34 | pytest -vv -r ap --cov jupyter_archive 35 | jupyter server extension list 36 | jupyter server extension list 2>&1 | grep -ie "jupyter_archive.*OK" 37 | 38 | jupyter labextension list 39 | jupyter labextension list 2>&1 | grep -ie "@hadim/jupyter-archive.*OK" 40 | python -m jupyterlab.browser_check 41 | 42 | - name: Package the extension 43 | run: | 44 | set -eux 45 | 46 | pip install build 47 | python -m build 48 | pip uninstall -y "jupyter-archive" jupyterlab 49 | 50 | - name: Upload extension packages 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: extension-artifacts 54 | path: dist/jupyter_archive* 55 | if-no-files-found: error 56 | 57 | test_isolated: 58 | needs: build 59 | runs-on: ubuntu-latest 60 | 61 | steps: 62 | - name: Install Python 63 | uses: actions/setup-python@v4 64 | with: 65 | python-version: '3.9' 66 | architecture: 'x64' 67 | - uses: actions/download-artifact@v4 68 | with: 69 | name: extension-artifacts 70 | - name: Install and Test 71 | run: | 72 | set -eux 73 | # Remove NodeJS, twice to take care of system and locally installed node versions. 74 | sudo rm -rf $(which node) 75 | sudo rm -rf $(which node) 76 | 77 | pip install "jupyterlab>=4.0.0,<5" jupyter_archive*.whl 78 | 79 | 80 | jupyter server extension list 81 | jupyter server extension list 2>&1 | grep -ie "jupyter_archive.*OK" 82 | 83 | jupyter labextension list 84 | jupyter labextension list 2>&1 | grep -ie "@hadim/jupyter-archive.*OK" 85 | python -m jupyterlab.browser_check --no-browser-test 86 | 87 | integration-tests-lab: 88 | name: Integration tests for JupyterLab 89 | strategy: 90 | fail-fast: false 91 | matrix: 92 | jupyterlab: ['3.6', '4.0'] 93 | needs: build 94 | runs-on: ubuntu-latest 95 | 96 | env: 97 | PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers 98 | 99 | steps: 100 | - name: Checkout 101 | uses: actions/checkout@v4 102 | 103 | - name: Base Setup 104 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 105 | 106 | - name: Download extension package 107 | uses: actions/download-artifact@v4 108 | with: 109 | name: extension-artifacts 110 | 111 | - name: Install the extension 112 | run: | 113 | set -eux 114 | python -m pip install "jupyterlab~=${{ matrix.jupyterlab }}" jupyter_archive*.whl 115 | 116 | - name: Install dependencies 117 | working-directory: ui-tests 118 | env: 119 | PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 120 | run: | 121 | if [[ "${{ matrix.jupyterlab }}" == "3.6" ]]; then 122 | jlpm add -D "@jupyterlab/galata@^4.0.0" 123 | fi 124 | jlpm install 125 | 126 | - name: Set up browser cache 127 | uses: actions/cache@v4 128 | with: 129 | path: | 130 | ${{ github.workspace }}/pw-browsers 131 | key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }} 132 | 133 | - name: Install browser 134 | run: jlpm playwright install chromium 135 | working-directory: ui-tests 136 | 137 | - name: Execute integration tests 138 | working-directory: ui-tests 139 | run: | 140 | jlpm playwright test --output test-results-${{ matrix.jupyterlab }} 141 | 142 | - name: Upload Playwright Test report 143 | if: always() 144 | uses: actions/upload-artifact@v4 145 | with: 146 | name: jupyter-archive-playwright-tests 147 | path: | 148 | ui-tests/test-results* 149 | ui-tests/playwright-report 150 | 151 | integration-tests-nb: 152 | name: Integration tests for Notebook 153 | needs: build 154 | runs-on: ubuntu-latest 155 | 156 | env: 157 | PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers 158 | 159 | steps: 160 | - name: Checkout 161 | uses: actions/checkout@v4 162 | 163 | - name: Base Setup 164 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 165 | 166 | - name: Download extension package 167 | uses: actions/download-artifact@v4 168 | with: 169 | name: extension-artifacts 170 | 171 | - name: Install the extension 172 | run: | 173 | set -eux 174 | python -m pip install "notebook~=7.0" jupyter_archive*.whl 175 | 176 | - name: Install dependencies 177 | working-directory: ui-tests 178 | env: 179 | PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 180 | run: | 181 | jlpm install 182 | 183 | - name: Set up browser cache 184 | uses: actions/cache@v4 185 | with: 186 | path: | 187 | ${{ github.workspace }}/pw-browsers 188 | key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }} 189 | 190 | - name: Install browser 191 | run: jlpm playwright install chromium 192 | working-directory: ui-tests 193 | 194 | - name: Execute integration tests 195 | working-directory: ui-tests 196 | run: | 197 | jlpm playwright test -c playwright-notebook.config.js 198 | 199 | - name: Upload Playwright Test report 200 | if: always() 201 | uses: actions/upload-artifact@v4 202 | with: 203 | name: notebook-tour-playwright-tests 204 | path: | 205 | ui-tests/test-results 206 | ui-tests/playwright-report 207 | 208 | check_links: 209 | name: Check Links 210 | runs-on: ubuntu-latest 211 | timeout-minutes: 15 212 | steps: 213 | - uses: actions/checkout@v4 214 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 215 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 216 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["*"] 7 | 8 | jobs: 9 | check_release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Base Setup 15 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 16 | - name: Install Dependencies 17 | run: | 18 | pip install -e . 19 | - name: Check Release 20 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 21 | with: 22 | 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Upload Distributions 26 | uses: actions/upload-artifact@v3 27 | with: 28 | name: jupyter-archive-releaser-dist-${{ github.run_number }} 29 | path: .jupyter_releaser_checkout/dist 30 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | since: 16 | description: "Use PRs with activity since this date or git reference" 17 | required: false 18 | since_last_stable: 19 | description: "Use PRs with activity since the last stable git tag" 20 | required: false 21 | type: boolean 22 | jobs: 23 | prep_release: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 27 | 28 | - name: Prep Release 29 | id: prep-release 30 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 31 | with: 32 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 33 | version_spec: ${{ github.event.inputs.version_spec }} 34 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 35 | branch: ${{ github.event.inputs.branch }} 36 | since: ${{ github.event.inputs.since }} 37 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 38 | 39 | - name: "** Next Step **" 40 | run: | 41 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 42 | -------------------------------------------------------------------------------- /.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 | permissions: 19 | # This is useful if you want to use PyPI trusted publisher 20 | # and NPM provenance 21 | id-token: write 22 | steps: 23 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 24 | 25 | - name: Populate Release 26 | id: populate-release 27 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 28 | with: 29 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 30 | branch: ${{ github.event.inputs.branch }} 31 | release_url: ${{ github.event.inputs.release_url }} 32 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 33 | 34 | - name: Finalize Release 35 | id: finalize-release 36 | env: 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | uses: jupyter-server/jupyter-releaser/.github/actions/finalize-release@v2 39 | with: 40 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 41 | release_url: ${{ steps.populate-release.outputs.release_url }} 42 | 43 | - name: "** Next Step **" 44 | if: ${{ success() }} 45 | run: | 46 | echo "Verify the final release" 47 | echo ${{ steps.finalize-release.outputs.release_url }} 48 | 49 | - name: "** Failure Message **" 50 | if: ${{ failure() }} 51 | run: | 52 | echo "Failed to Publish the Draft Release Url:" 53 | echo ${{ steps.populate-release.outputs.release_url }} 54 | -------------------------------------------------------------------------------- /.github/workflows/update-integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Update Playwright Snapshots 2 | 3 | on: 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | update-snapshots: 13 | if: > 14 | ( 15 | github.event.issue.author_association == 'OWNER' || 16 | github.event.issue.author_association == 'COLLABORATOR' || 17 | github.event.issue.author_association == 'MEMBER' 18 | ) && github.event.issue.pull_request && contains(github.event.comment.body, 'please update snapshots') 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: React to the triggering comment 23 | run: | 24 | gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1' 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Get PR Info 34 | id: pr 35 | env: 36 | PR_NUMBER: ${{ github.event.issue.number }} 37 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | GH_REPO: ${{ github.repository }} 39 | COMMENT_AT: ${{ github.event.comment.created_at }} 40 | run: | 41 | pr="$(gh api /repos/${GH_REPO}/pulls/${PR_NUMBER})" 42 | head_sha="$(echo "$pr" | jq -r .head.sha)" 43 | pushed_at="$(echo "$pr" | jq -r .pushed_at)" 44 | 45 | if [[ $(date -d "$pushed_at" +%s) -gt $(date -d "$COMMENT_AT" +%s) ]]; then 46 | echo "Updating is not allowed because the PR was pushed to (at $pushed_at) after the triggering comment was issued (at $COMMENT_AT)" 47 | exit 1 48 | fi 49 | 50 | echo "head_sha=$head_sha" >> $GITHUB_OUTPUT 51 | 52 | - name: Checkout the branch from the PR that triggered the job 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | run: gh pr checkout ${{ github.event.issue.number }} 56 | 57 | - name: Validate the fetched branch HEAD revision 58 | env: 59 | EXPECTED_SHA: ${{ steps.pr.outputs.head_sha }} 60 | run: | 61 | actual_sha="$(git rev-parse HEAD)" 62 | 63 | if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then 64 | echo "The HEAD of the checked out branch ($actual_sha) differs from the HEAD commit available at the time when trigger comment was submitted ($EXPECTED_SHA)" 65 | exit 1 66 | fi 67 | 68 | - name: Base Setup 69 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 70 | 71 | - name: Install dependencies 72 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 73 | 74 | - name: Install extension 75 | run: | 76 | set -eux 77 | jlpm 78 | python -m pip install . 79 | 80 | - uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1 81 | with: 82 | github_token: ${{ secrets.GITHUB_TOKEN }} 83 | # Playwright knows how to start JupyterLab server 84 | start_server_script: 'null' 85 | test_folder: ui-tests 86 | npm_client: jlpm 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyter_archive/labextension 11 | # Version file is handled by hatchling 12 | jupyter_archive/_version.py 13 | 14 | # Integration tests 15 | ui-tests/test-results/ 16 | ui-tests/playwright-report/ 17 | 18 | # Created by https://www.gitignore.io/api/python 19 | # Edit at https://www.gitignore.io/?templates=python 20 | 21 | ### Python ### 22 | # Byte-compiled / optimized / DLL files 23 | __pycache__/ 24 | *.py[cod] 25 | *$py.class 26 | 27 | # C extensions 28 | *.so 29 | 30 | # Distribution / packaging 31 | .Python 32 | build/ 33 | develop-eggs/ 34 | dist/ 35 | downloads/ 36 | eggs/ 37 | .eggs/ 38 | lib/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | wheels/ 44 | pip-wheel-metadata/ 45 | share/python-wheels/ 46 | .installed.cfg 47 | *.egg 48 | MANIFEST 49 | 50 | # PyInstaller 51 | # Usually these files are written by a python script from a template 52 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 53 | *.manifest 54 | *.spec 55 | 56 | # Installer logs 57 | pip-log.txt 58 | pip-delete-this-directory.txt 59 | 60 | # Unit test / coverage reports 61 | htmlcov/ 62 | .tox/ 63 | .nox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage/ 69 | coverage.xml 70 | *.cover 71 | .hypothesis/ 72 | .pytest_cache/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # SageMath parsed files 94 | *.sage.py 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # Mr Developer 104 | .mr.developer.cfg 105 | .project 106 | .pydevproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | 119 | # End of https://www.gitignore.io/api/python 120 | 121 | # OSX files 122 | .DS_Store 123 | 124 | .vscode/ 125 | .pytest_cache/ 126 | # Yarn cache 127 | .yarn/ 128 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyter_archive 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableImmutableInstalls: false 2 | 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | ## 3.4.0 6 | 7 | ([Full Changelog](https://github.com/jupyterlab-contrib/jupyter-archive/compare/v3.3.4...4e65950cb8d7818f04d855e9cea7ee6aa84da053)) 8 | 9 | ### Bugs fixed 10 | 11 | - Upgrade for JupyterLab 4 and notebook 7 [#118](https://github.com/jupyterlab-contrib/jupyter-archive/pull/118) ([@fcollonval](https://github.com/fcollonval)) 12 | 13 | ### Maintenance and upkeep improvements 14 | 15 | - Bump loader-utils from 2.0.0 to 2.0.4 [#120](https://github.com/jupyterlab-contrib/jupyter-archive/pull/120) ([@dependabot](https://github.com/dependabot)) 16 | 17 | ### Contributors to this release 18 | 19 | ([GitHub contributors page for this release](https://github.com/jupyterlab-contrib/jupyter-archive/graphs/contributors?from=2023-01-08&to=2023-08-15&type=c)) 20 | 21 | [@dependabot](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyter-archive+involves%3Adependabot+updated%3A2023-01-08..2023-08-15&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyter-archive+involves%3Afcollonval+updated%3A2023-01-08..2023-08-15&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyter-archive+involves%3Agithub-actions+updated%3A2023-01-08..2023-08-15&type=Issues) 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2020, Hadrien Mary, Frederic Collonval 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | Neither the name of the read_roi nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupyter-archive 2 | 3 | [![Extension status](https://img.shields.io/badge/status-ready-success 'ready to be used')](https://jupyterlab-contrib.github.io/) 4 | [![Github Actions Status](https://github.com/jupyterlab-contrib/jupyter-archive/workflows/Build/badge.svg)](https://github.com/jupyterlab-contrib/jupyter-archive/actions?query=workflow%3ABuild) 5 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jupyterlab-contrib/jupyter-archive.git/master?urlpath=lab) 6 | [![Version](https://img.shields.io/npm/v/@hadim/jupyter-archive.svg)](https://www.npmjs.com/package/@hadim/jupyter-archive) 7 | [![PyPI](https://img.shields.io/pypi/v/jupyter-archive)](https://pypi.org/project/jupyter-archive/) 8 | [![Conda (channel only)](https://img.shields.io/conda/vn/conda-forge/jupyter-archive)](https://anaconda.org/conda-forge/jupyter-archive) 9 | 10 | A Jupyter extension to make, download and extract archive files. 11 | 12 | Features: 13 | 14 | - Download selected or current folder as an archive. 15 | - Supported formats: 'zip', 'tar.gz', 'tar.bz2' and 'tar.xz'. 16 | - Archiving and downloading are non-blocking for Jupyter. UI can still be used. 17 | - Archive format can be set in the JLab settings. 18 | - Alternatively, you can choose the format in the file browser menu (the format setting needs to be set to `null`). 19 | - Decompress an archive directly in file browser. 20 | - Notebok client extension not available. [Contributions are welcome](https://github.com/jupyterlab-contrib/jupyter-archive/issues/21). 21 | 22 | ![jupyter-archive in action](https://raw.githubusercontent.com/jupyterlab-contrib/jupyter-archive/master/archive.gif) 23 | 24 | ## Configuration 25 | 26 | The server extension has some [configuration settings](https://jupyter-server.readthedocs.io/en/latest/users/configuration.html) -- 27 | the values below are the default one: 28 | 29 | ```json5 30 | { 31 | JupyterArchive: { 32 | stream_max_buffer_size: 104857600, // The max size of tornado IOStream buffer 33 | handler_max_buffer_length: 10240, // The max length of chunks in tornado RequestHandler 34 | archive_download_flush_delay: 100 // The delay in ms at which we send the chunk of data to the client. 35 | } 36 | } 37 | ``` 38 | 39 | You can also set new values with the following environment variables: 40 | 41 | - `JA_IOSTREAM_MAX_BUFFER_SIZE` 42 | - `JA_HANDLER_MAX_BUFFER_LENGTH` 43 | - `JA_ARCHIVE_DOWNLOAD_FLUSH_DELAY` 44 | 45 | ## Requirements 46 | 47 | - JupyterLab >= 3.0 or Notebook >= 7.0 48 | 49 | For JupyterLab 2.x, have look [there](https://github.com/jupyterlab-contrib/jupyter-archive/tree/2.x). 50 | 51 | ## Install 52 | 53 | To install the extension, execute: 54 | 55 | ```bash 56 | pip install jupyter-archive 57 | ``` 58 | 59 | Or 60 | 61 | ```bash 62 | conda install -c conda-forge jupyter-archive 63 | ``` 64 | 65 | ## Uninstall 66 | 67 | To remove the extension, execute: 68 | 69 | ```bash 70 | pip uninstall jupyter-archive 71 | ``` 72 | 73 | Or 74 | 75 | ```bash 76 | conda remove jupyter-archive 77 | ``` 78 | 79 | ## Troubleshoot 80 | 81 | If you are seeing the frontend extension, but it is not working, check 82 | that the server extension is enabled: 83 | 84 | ```bash 85 | jupyter server extension list 86 | ``` 87 | 88 | If the server extension is installed and enabled, but you are not seeing 89 | the frontend extension, check the frontend extension is installed: 90 | 91 | ```bash 92 | jupyter labextension list 93 | ``` 94 | 95 | ## Contributing 96 | 97 | ### Development install 98 | 99 | Note: You will need NodeJS to build the extension package. 100 | 101 | The `jlpm` command is JupyterLab's pinned version of 102 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 103 | `yarn` or `npm` in lieu of `jlpm` below. 104 | 105 | ```bash 106 | # Clone the repo to your local environment 107 | # Change directory to the jupyter-archive directory 108 | # Install package in development mode 109 | pip install -e ".[test]" 110 | # Link your development version of the extension with JupyterLab 111 | jupyter labextension develop . --overwrite 112 | # Server extension must be manually installed in develop mode 113 | jupyter server extension enable jupyter_archive 114 | # Rebuild extension Typescript source after making changes 115 | jlpm build 116 | ``` 117 | 118 | 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. 119 | 120 | ```bash 121 | # Watch the source directory in one terminal, automatically rebuilding when needed 122 | jlpm watch 123 | # Run JupyterLab in another terminal 124 | jupyter lab 125 | ``` 126 | 127 | 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). 128 | 129 | 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: 130 | 131 | ```bash 132 | jupyter lab build --minimize=False 133 | ``` 134 | 135 | ## License 136 | 137 | Under BSD license. See [LICENSE](LICENSE). 138 | 139 | ## Authors 140 | 141 | - Hadrien Mary: [@hadim](https://github.com/hadim) 142 | - Frédéric Collonval: [@fcollonval](https://github.com/fcollonval) 143 | 144 | ### Development uninstall 145 | 146 | ```bash 147 | # Server extension must be manually disabled in develop mode 148 | jupyter server extension disable jupyter_archive 149 | pip uninstall jupyter-archive 150 | ``` 151 | 152 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 153 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 154 | folder is located. Then you can remove the symlink named `@hadim/jupyter-archive` within that folder. 155 | 156 | ### Testing the extension 157 | 158 | #### Server tests 159 | 160 | This extension is using [Pytest](https://docs.pytest.org/) for Python code testing. 161 | 162 | Install test dependencies (needed only once): 163 | 164 | ```sh 165 | pip install -e ".[test]" 166 | # Each time you install the Python package, you need to restore the front-end extension link 167 | jupyter labextension develop . --overwrite 168 | ``` 169 | 170 | To execute them, run: 171 | 172 | ```sh 173 | pytest -vv -r ap --cov jupyter_archive 174 | ``` 175 | 176 | #### Integration tests 177 | 178 | This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests). 179 | More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab. 180 | 181 | More information are provided within the [ui-tests](./ui-tests/README.md) README. 182 | 183 | ### Packaging the extension 184 | 185 | See [RELEASE](RELEASE.md) 186 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupyter-archive 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. 62 | 63 | Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) for more information. 64 | 65 | Here is a summary of the steps to cut a new release: 66 | 67 | - Add tokens to the [Github Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in the repository: 68 | - `ADMIN_GITHUB_TOKEN` (with "public_repo" and "repo:status" permissions); see the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) 69 | - `NPM_TOKEN` (with "automation" permission); see the [documentation](https://docs.npmjs.com/creating-and-viewing-access-tokens) 70 | - Set up PyPI 71 | 72 |
Using PyPI trusted publisher (modern way) 73 | 74 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 75 | - The _workflow name_ is `publish-release.yml` and the _environment_ should be left blank. 76 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 77 | 78 |
79 | 80 |
Using PyPI token (legacy way) 81 | 82 | - If the repo generates PyPI release(s), create a scoped PyPI [token](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github). We recommend using a scoped token for security reasons. 83 | 84 | - You can store the token as `PYPI_TOKEN` in your fork's `Secrets`. 85 | 86 | - Advanced usage: if you are releasing multiple repos, you can create a secret named `PYPI_TOKEN_MAP` instead of `PYPI_TOKEN` that is formatted as follows: 87 | 88 | ```text 89 | owner1/repo1,token1 90 | owner2/repo2,token2 91 | ``` 92 | 93 | If you have multiple Python packages in the same repository, you can point to them as follows: 94 | 95 | ```text 96 | owner1/repo1/path/to/package1,token1 97 | owner1/repo1/path/to/package2,token2 98 | ``` 99 | 100 |
101 | 102 | - Go to the Actions panel 103 | - Run the "Step 1: Prep Release" workflow 104 | - Check the draft changelog 105 | - Run the "Step 2: Publish Release" workflow 106 | 107 | ## Publishing to `conda-forge` 108 | 109 | 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 110 | 111 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 112 | -------------------------------------------------------------------------------- /archive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab-contrib/jupyter-archive/efcdf8b419509957242ff75c40ceae85471708b4/archive.gif -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | # a mybinder.org-ready environment for demoing jupyter-archive 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-archive-demo 6 | # 7 | name: jupyter-archive-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 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of jupyter-archive 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 | _( 35 | sys.executable, 36 | "-m", 37 | "jupyter", 38 | "server", 39 | "extension", 40 | "enable", 41 | "jupyter_archive", 42 | ) 43 | 44 | # verify the environment the extension didn't break anything 45 | _(sys.executable, "-m", "pip", "check") 46 | 47 | # list the extensions 48 | _("jupyter", "server", "extension", "list") 49 | 50 | # initially list installed extensions to determine if there are any surprises 51 | _("jupyter", "labextension", "list") 52 | 53 | 54 | print("JupyterLab with jupyter-archive is ready to run with:\n") 55 | print("\tjupyter lab\n") 56 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest_plugins = ("pytest_jupyter.jupyter_server", ) 4 | 5 | 6 | @pytest.fixture 7 | def jp_server_config(jp_server_config): 8 | return {"ServerApp": {"jpserver_extensions": {"jupyter_archive": True}}} 9 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | 4 | dependencies: 5 | - python>=3.6 6 | - pytest 7 | - jupyterlab>=3.0.0,<4.0.0 8 | - nodejs 9 | - black 10 | - pytest-tornasync==0.6.0.post2 11 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyter-archive", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyter-archive" 5 | } 6 | -------------------------------------------------------------------------------- /jupyter-config/server-config/jupyter-archive.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyter_archive": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter_archive/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from traitlets.config import Configurable 4 | from traitlets import Int, default 5 | 6 | try: 7 | from ._version import __version__ 8 | except ImportError: 9 | # Fallback when using the package in dev mode without installing 10 | # in editable mode with pip. It is highly recommended to install 11 | # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs 12 | import warnings 13 | warnings.warn("Importing 'jupyter-archive' outside a proper installation.") 14 | __version__ = "dev" 15 | from .handlers import setup_handlers 16 | 17 | 18 | def _jupyter_labextension_paths(): 19 | return [{ 20 | "src": "labextension", 21 | "dest": "@hadim/jupyter-archive" 22 | }] 23 | 24 | 25 | def _jupyter_server_extension_points(): 26 | return [{ 27 | "module": "jupyter_archive" 28 | }] 29 | 30 | 31 | class JupyterArchive(Configurable): 32 | stream_max_buffer_size = Int(help="The max size of tornado IOStream buffer", 33 | config=True) 34 | 35 | @default("stream_max_buffer_size") 36 | def _default_stream_max_buffer_size(self): 37 | # 100 * 1024 * 1024 equals to 100M 38 | return int(os.environ.get("JA_IOSTREAM_MAX_BUFFER_SIZE", 100 * 1024 * 1024)) 39 | 40 | handler_max_buffer_length = Int(help="The max length of chunks in tornado RequestHandler", 41 | config=True) 42 | 43 | @default("handler_max_buffer_length") 44 | def _default_handler_max_buffer_length(self): 45 | # if 8K for one chunk, 10240 * 8K equals to 80M 46 | return int(os.environ.get("JA_HANDLER_MAX_BUFFER_LENGTH", 10240)) 47 | 48 | archive_download_flush_delay = Int(help="The delay in ms at which we send the chunk of data to the client.", 49 | config=True) 50 | 51 | @default("archive_download_flush_delay") 52 | def _default_archive_download_flush_delay(self): 53 | return int(os.environ.get("JA_ARCHIVE_DOWNLOAD_FLUSH_DELAY", 100)) 54 | 55 | 56 | def _load_jupyter_server_extension(server_app): 57 | """Registers the API handler to receive HTTP requests from the frontend extension. 58 | 59 | Parameters 60 | ---------- 61 | server_app: jupyterlab.labapp.LabApp 62 | JupyterLab application instance 63 | """ 64 | config = JupyterArchive(config=server_app.config) 65 | server_app.web_app.settings["jupyter_archive"] = config 66 | setup_handlers(server_app.web_app) 67 | -------------------------------------------------------------------------------- /jupyter_archive/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pathlib 4 | import tarfile 5 | import time 6 | import traceback 7 | import zipfile 8 | import threading 9 | from http.client import responses 10 | 11 | from jupyter_server.base.handlers import JupyterHandler 12 | from jupyter_server.utils import url2path, url_path_join, ensure_async 13 | from tornado import ioloop, web 14 | from urllib.parse import quote 15 | 16 | SUPPORTED_FORMAT = [ 17 | "zip", 18 | "tgz", 19 | "tar.gz", 20 | "tbz", 21 | "tbz2", 22 | "tar.bz", 23 | "tar.bz2", 24 | "txz", 25 | "tar.xz", 26 | ] 27 | 28 | 29 | class ArchiveStream: 30 | def __init__(self, handler): 31 | self.handler = handler 32 | self.position = 0 33 | 34 | def write(self, data): 35 | if self.handler.canceled: 36 | raise ValueError("File download canceled") 37 | # timeout 600s for this while loop 38 | time_out_cnt = 600 * 1000 / self.handler.archive_download_flush_delay 39 | while len(self.handler._write_buffer) > self.handler.handler_max_buffer_length: 40 | # write_buffer or handler is too large, wait for an flush cycle 41 | time.sleep(self.handler.archive_download_flush_delay / 1000) 42 | if self.handler.canceled: 43 | raise ValueError("File download canceled") 44 | time_out_cnt -= 1 45 | if time_out_cnt <= 0: 46 | raise ValueError("Time out for writing into tornado buffer") 47 | self.position += len(data) 48 | with self.handler.lock: 49 | self.handler.write(data) 50 | del data 51 | 52 | def tell(self): 53 | return self.position 54 | 55 | def flush(self): 56 | # Note: Flushing is done elsewhere, in the main thread 57 | # because `write()` is called in a background thread. 58 | # self.handler.flush() 59 | pass 60 | 61 | 62 | def make_writer(handler, archive_format="zip"): 63 | fileobj = ArchiveStream(handler) 64 | 65 | if archive_format == "zip": 66 | archive_file = zipfile.ZipFile(fileobj, mode="w", compression=zipfile.ZIP_DEFLATED) 67 | archive_file.add = archive_file.write 68 | elif archive_format in ["tgz", "tar.gz"]: 69 | archive_file = tarfile.open(fileobj=fileobj, mode="w|gz") 70 | elif archive_format in ["tbz", "tbz2", "tar.bz", "tar.bz2"]: 71 | archive_file = tarfile.open(fileobj=fileobj, mode="w|bz2") 72 | elif archive_format in ["txz", "tar.xz"]: 73 | archive_file = tarfile.open(fileobj=fileobj, mode="w|xz") 74 | else: 75 | raise ValueError("'{}' is not a valid archive format.".format(archive_format)) 76 | return archive_file 77 | 78 | 79 | def make_reader(archive_path): 80 | 81 | archive_format = "".join(archive_path.suffixes) 82 | 83 | if archive_format.endswith(".zip"): 84 | archive_file = zipfile.ZipFile(archive_path, mode="r") 85 | elif any([archive_format.endswith(ext) for ext in [".tgz", ".tar.gz"]]): 86 | archive_file = tarfile.open(archive_path, mode="r|gz") 87 | elif any([archive_format.endswith(ext) for ext in [".tbz", ".tbz2", ".tar.bz", ".tar.bz2"]]): 88 | archive_file = tarfile.open(archive_path, mode="r|bz2") 89 | elif any([archive_format.endswith(ext) for ext in [".txz", ".tar.xz"]]): 90 | archive_file = tarfile.open(archive_path, mode="r|xz") 91 | else: 92 | raise ValueError("'{}' is not a valid archive format.".format(archive_format)) 93 | return archive_file 94 | 95 | 96 | class DownloadArchiveHandler(JupyterHandler): 97 | lock = threading.Lock() 98 | 99 | @property 100 | def stream_max_buffer_size(self): 101 | return self.settings["jupyter_archive"].stream_max_buffer_size 102 | 103 | @property 104 | def handler_max_buffer_length(self): 105 | return self.settings["jupyter_archive"].handler_max_buffer_length 106 | 107 | @property 108 | def archive_download_flush_delay(self): 109 | return self.settings["jupyter_archive"].archive_download_flush_delay 110 | 111 | def flush(self, include_footers=False, force=False): 112 | # skip flush when stream_buffer is larger than stream_max_buffer_size 113 | stream_buffer = self.request.connection.stream._write_buffer 114 | if not force and stream_buffer and len(stream_buffer) > self.stream_max_buffer_size: 115 | return 116 | with self.lock: 117 | return super(DownloadArchiveHandler, self).flush(include_footers) 118 | 119 | @web.authenticated 120 | async def get(self, archive_path, include_body=False): 121 | 122 | # /directories/ requests must originate from the same site 123 | self.check_xsrf_cookie() 124 | cm = self.contents_manager 125 | 126 | if await ensure_async(cm.is_hidden(archive_path)) and not cm.allow_hidden: 127 | self.log.info("Refusing to serve hidden file, via 404 Error") 128 | raise web.HTTPError(404) 129 | 130 | archive_token = self.get_argument("archiveToken") 131 | archive_format = self.get_argument("archiveFormat", "zip") 132 | if archive_format not in SUPPORTED_FORMAT: 133 | self.log.error("Unsupported format {}.".format(archive_format)) 134 | raise web.HTTPError(404) 135 | # Because urls can only pass strings, must check if string value is true 136 | # or false. If it is not either value, then it is an invalid argument 137 | # and raise http error 400. 138 | if self.get_argument("followSymlinks", "true") == "true": 139 | follow_symlinks = True 140 | elif self.get_argument("followSymlinks", "true") == "false": 141 | follow_symlinks = False 142 | else: 143 | raise web.HTTPError(400) 144 | if self.get_argument("downloadHidden", "false") == "true": 145 | download_hidden = True 146 | elif self.get_argument("downloadHidden", "false") == "false": 147 | download_hidden = False 148 | else: 149 | raise web.HTTPError(400) 150 | 151 | archive_path = pathlib.Path(cm.root_dir) / url2path(archive_path) 152 | archive_filename = f"{archive_path.name}.{archive_format}" 153 | archive_filename = quote(archive_filename) 154 | 155 | self.log.info("Prepare {} for archiving and downloading.".format(archive_filename)) 156 | self.set_header("content-type", "application/octet-stream") 157 | self.set_header("cache-control", "no-cache") 158 | self.set_header("content-disposition", "attachment; filename={}".format(archive_filename)) 159 | 160 | self.canceled = False 161 | self.flush_cb = ioloop.PeriodicCallback(self.flush, self.archive_download_flush_delay) 162 | self.flush_cb.start() 163 | 164 | try: 165 | args = ( 166 | archive_path, 167 | archive_format, 168 | archive_token, 169 | follow_symlinks, 170 | download_hidden, 171 | ) 172 | await ioloop.IOLoop.current().run_in_executor(None, self.archive_and_download, *args) 173 | 174 | if self.canceled: 175 | self.log.info("Download canceled.") 176 | else: 177 | # Here, we need to flush forcibly to move all data from _write_buffer to stream._write_buffer 178 | self.flush(force=True) 179 | self.log.info("Finished downloading {}.".format(archive_filename)) 180 | except Exception: 181 | raise 182 | finally: 183 | self.flush_cb.stop() 184 | 185 | self.set_cookie("archiveToken", archive_token) 186 | self.finish() 187 | 188 | def archive_and_download( 189 | self, 190 | archive_path, 191 | archive_format, 192 | archive_token, 193 | follow_symlinks, 194 | download_hidden, 195 | ): 196 | 197 | with make_writer(self, archive_format) as archive: 198 | prefix = len(str(archive_path.parent)) + len(os.path.sep) 199 | for root, dirs, files in os.walk(archive_path, followlinks=follow_symlinks): 200 | # This ensures that if download_hidden is false, then the 201 | # hidden files are skipped when walking the directory. 202 | if not download_hidden: 203 | files = [f for f in files if not f[0] == "."] 204 | dirs[:] = [d for d in dirs if not d[0] == "."] 205 | for file_ in files: 206 | file_name = os.path.join(root, file_) 207 | if not self.canceled: 208 | self.log.debug("{}\n".format(file_name)) 209 | archive.add(file_name, os.path.join(root[prefix:], file_)) 210 | else: 211 | break 212 | 213 | def on_connection_close(self): 214 | super().on_connection_close() 215 | self.canceled = True 216 | self.flush_cb.stop() 217 | 218 | 219 | class ExtractArchiveHandler(JupyterHandler): 220 | @web.authenticated 221 | async def get(self, archive_path, include_body=False): 222 | 223 | # /extract-archive/ requests must originate from the same site 224 | self.check_xsrf_cookie() 225 | cm = self.contents_manager 226 | 227 | if await ensure_async(cm.is_hidden(archive_path)) and not cm.allow_hidden: 228 | self.log.info("Refusing to serve hidden file, via 404 Error") 229 | raise web.HTTPError(404) 230 | 231 | archive_path = pathlib.Path(cm.root_dir) / url2path(archive_path) 232 | 233 | await ioloop.IOLoop.current().run_in_executor(None, self.extract_archive, archive_path) 234 | 235 | self.finish() 236 | 237 | def extract_archive(self, archive_path): 238 | 239 | archive_destination = archive_path.parent 240 | self.log.info("Begin extraction of {} to {}.".format(archive_path, archive_destination)) 241 | 242 | archive_reader = make_reader(archive_path) 243 | 244 | if isinstance(archive_reader, tarfile.TarFile): 245 | # Check file path to avoid path traversal 246 | # See https://nvd.nist.gov/vuln/detail/CVE-2007-4559 247 | with archive_reader as archive: 248 | for name in archive_reader.getnames(): 249 | if os.path.relpath(archive_destination / name, archive_destination).startswith(os.pardir): 250 | error_message = f"The archive file includes an unsafe file path: {name}" 251 | self.log.error(error_message) 252 | raise web.HTTPError(400, reason=error_message) 253 | # Re-open stream 254 | archive_reader = make_reader(archive_path) 255 | 256 | with archive_reader as archive: 257 | archive.extractall(archive_destination) 258 | 259 | self.log.info("Finished extracting {} to {}.".format(archive_path, archive_destination)) 260 | 261 | def write_error(self, status_code, **kwargs): 262 | # Return error response as JSON 263 | # See https://github.com/pyenv/pyenv/blob/ff9d3ca69ef5006352cadc31e57f51aca42705a6/versions/3.8.12/lib/python3.8/site-packages/jupyter_server/base/handlers.py#L610 264 | self.set_header("Content-Type", "application/json") 265 | message = responses.get(status_code, "Unknown HTTP Error") 266 | reply = { 267 | "message": message, 268 | } 269 | exc_info = kwargs.get("exc_info") 270 | if exc_info: 271 | e = exc_info[1] 272 | if isinstance(e, web.HTTPError): 273 | reply["message"] = e.log_message or message 274 | reply["reason"] = e.reason 275 | else: 276 | reply["message"] = "Unhandled error" 277 | reply["reason"] = None 278 | reply["traceback"] = "".join(traceback.format_exception(*exc_info)) 279 | self.finish(json.dumps(reply)) 280 | 281 | 282 | def setup_handlers(web_app): 283 | host_pattern = ".*$" 284 | base_url = web_app.settings["base_url"] 285 | 286 | handlers = [ 287 | (url_path_join(base_url, r"/directories/(.*)"), DownloadArchiveHandler), 288 | (url_path_join(base_url, r"/extract-archive/(.*)"), ExtractArchiveHandler), 289 | ] 290 | web_app.add_handlers(host_pattern, handlers) 291 | -------------------------------------------------------------------------------- /jupyter_archive/tests/test_archive_handler.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import shutil 3 | import tarfile 4 | import zipfile 5 | 6 | import pytest 7 | 8 | from tornado.httpclient import HTTPClientError 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "followSymlinks, download_hidden, file_list", 13 | [ 14 | ( 15 | False, 16 | False, 17 | { 18 | "download-archive-dir/test2.txt", 19 | "download-archive-dir/test1.txt", 20 | "download-archive-dir/test3.md", 21 | "download-archive-dir/中文文件夹/中文.txt", 22 | }, 23 | ), 24 | ( 25 | False, 26 | True, 27 | { 28 | "download-archive-dir/test2.txt", 29 | "download-archive-dir/test1.txt", 30 | "download-archive-dir/test3.md", 31 | "download-archive-dir/中文文件夹/中文.txt", 32 | "download-archive-dir/.test4.md", 33 | "download-archive-dir/.test-hidden-folder/test5.md", 34 | }, 35 | ), 36 | ( 37 | True, 38 | False, 39 | { 40 | "download-archive-dir/test2.txt", 41 | "download-archive-dir/test1.txt", 42 | "download-archive-dir/test3.md", 43 | "download-archive-dir/中文文件夹/中文.txt", 44 | "download-archive-dir/symlink-test-dir/test6.md", 45 | }, 46 | ), 47 | ( 48 | True, 49 | True, 50 | { 51 | "download-archive-dir/test2.txt", 52 | "download-archive-dir/test1.txt", 53 | "download-archive-dir/test3.md", 54 | "download-archive-dir/中文文件夹/中文.txt", 55 | "download-archive-dir/.test4.md", 56 | "download-archive-dir/.test-hidden-folder/test5.md", 57 | "download-archive-dir/symlink-test-dir/test6.md", 58 | }, 59 | ), 60 | ], 61 | ) 62 | @pytest.mark.parametrize( 63 | "format, mode", 64 | [ 65 | ("zip", "r"), 66 | ("tgz", "r|gz"), 67 | ("tar.gz", "r|gz"), 68 | ("tbz", "r|bz2"), 69 | ("tbz2", "r|bz2"), 70 | ("tar.bz", "r|bz2"), 71 | ("tar.bz2", "r|bz2"), 72 | ("txz", "r|xz"), 73 | ("tar.xz", "r|xz"), 74 | ], 75 | ) 76 | async def test_download(jp_fetch, jp_root_dir, followSymlinks, download_hidden, file_list, format, mode): 77 | if followSymlinks and platform.system() == "Windows": 78 | pytest.skip("Symlinks not working on Windows") 79 | 80 | # Create a dummy directory. 81 | archive_dir_path = jp_root_dir / "download-archive-dir" 82 | archive_dir_path.mkdir(parents=True) 83 | (archive_dir_path / "test1.txt").write_text("hello1") 84 | (archive_dir_path / "test2.txt").write_text("hello2") 85 | (archive_dir_path / "test3.md").write_text("hello3") 86 | (archive_dir_path / ".test4.md").write_text("hello4") 87 | 88 | hidden_folder = archive_dir_path / ".test-hidden-folder" 89 | hidden_folder.mkdir(parents=True) 90 | (hidden_folder / "test5.md").write_text("hello5") 91 | 92 | non_ascii_folder = archive_dir_path / "中文文件夹" 93 | non_ascii_folder.mkdir(parents=True) 94 | (non_ascii_folder / "中文.txt").write_text("你好") 95 | 96 | symlink_dir_path = jp_root_dir / "symlink-archive-dir" 97 | symlink_dir_path.mkdir(parents=True) 98 | (symlink_dir_path / "test6.md").write_text("hello6") 99 | if platform.system() != "Windows": 100 | (archive_dir_path / "symlink-test-dir").symlink_to(symlink_dir_path, target_is_directory=True) 101 | 102 | # Try to download the created folder. 103 | archive_relative_path = archive_dir_path.relative_to(jp_root_dir) 104 | params = { 105 | "archiveToken": 564646, 106 | "archiveFormat": format, 107 | "followSymlinks": str(followSymlinks).lower(), 108 | "downloadHidden": str(download_hidden).lower(), 109 | } 110 | r = await jp_fetch("directories", archive_dir_path.stem, params=params, method="GET") 111 | 112 | assert r.code == 200 113 | assert r.headers["content-type"] == "application/octet-stream" 114 | assert r.headers["cache-control"] == "no-cache" 115 | 116 | if format == "zip": 117 | with zipfile.ZipFile(r.buffer, mode=mode) as zf: 118 | assert set(zf.namelist()) == file_list 119 | else: 120 | with tarfile.open(fileobj=r.buffer, mode=mode) as tf: 121 | assert set(map(lambda m: m.name, tf.getmembers())) == file_list 122 | 123 | 124 | def _create_archive_file(root_dir, file_name, format, mode): 125 | # Create a dummy directory. 126 | archive_dir_path = root_dir / file_name 127 | archive_dir_path.mkdir(parents=True) 128 | 129 | (archive_dir_path / "extract-test1.txt").write_text("hello1") 130 | (archive_dir_path / "extract-test2.txt").write_text("hello2") 131 | (archive_dir_path / "extract-test3.md").write_text("hello3") 132 | 133 | # Make an archive 134 | archive_dir_path = root_dir / file_name 135 | # The request should fail when the extension has an unnecessary prefix. 136 | archive_path = archive_dir_path.parent / f"{archive_dir_path.name}.{format}" 137 | if format == "zip": 138 | with zipfile.ZipFile(archive_path, mode=mode) as writer: 139 | for file_path in archive_dir_path.rglob("*"): 140 | if file_path.is_file(): 141 | writer.write(file_path, file_path.relative_to(root_dir)) 142 | else: 143 | with tarfile.open(str(archive_path), mode=mode) as writer: 144 | for file_path in archive_dir_path.rglob("*"): 145 | if file_path.is_file(): 146 | writer.add(file_path, file_path.relative_to(root_dir)) 147 | 148 | # Remove the directory 149 | shutil.rmtree(archive_dir_path) 150 | 151 | return archive_dir_path, archive_path 152 | 153 | 154 | @pytest.mark.parametrize( 155 | "file_name", 156 | [ 157 | ('archive'), 158 | ('archive.hello'), 159 | ('archive.tar.gz'), 160 | ], 161 | ) 162 | @pytest.mark.parametrize( 163 | "format, mode", 164 | [ 165 | ("zip", "w"), 166 | ("tgz", "w|gz"), 167 | ("tar.gz", "w|gz"), 168 | ("tbz", "w|bz2"), 169 | ("tbz2", "w|bz2"), 170 | ("tar.bz", "w|bz2"), 171 | ("tar.bz2", "w|bz2"), 172 | ("txz", "w|xz"), 173 | ("tar.xz", "w|xz"), 174 | ], 175 | ) 176 | async def test_extract(jp_fetch, jp_root_dir, file_name, format, mode): 177 | archive_dir_path, archive_path = _create_archive_file(jp_root_dir, file_name, format, mode) 178 | 179 | r = await jp_fetch("extract-archive", archive_path.relative_to(jp_root_dir).as_posix(), method="GET") 180 | assert r.code == 200 181 | assert archive_dir_path.is_dir() 182 | 183 | n_files = len(list(archive_dir_path.glob("*"))) 184 | assert n_files == 3 185 | 186 | 187 | @pytest.mark.parametrize( 188 | "format, mode", 189 | [ 190 | ("zip", "w"), 191 | ("tgz", "w|gz"), 192 | ("tar.gz", "w|gz"), 193 | ("tbz", "w|bz2"), 194 | ("tbz2", "w|bz2"), 195 | ("tar.bz", "w|bz2"), 196 | ("tar.bz2", "w|bz2"), 197 | ("txz", "w|xz"), 198 | ("tar.xz", "w|xz"), 199 | ], 200 | ) 201 | async def test_extract_failure(jp_fetch, jp_root_dir, format, mode): 202 | # The request should fail when the extension has an unnecessary prefix. 203 | prefixed_format = f"prefix{format}" 204 | archive_dir_path, archive_path = _create_archive_file(jp_root_dir, "extract-archive-dir", prefixed_format, mode) 205 | 206 | with pytest.raises(Exception) as e: 207 | await jp_fetch("extract-archive", archive_path.relative_to(jp_root_dir).as_posix(), method="GET") 208 | assert e.type == HTTPClientError 209 | assert not archive_dir_path.exists() 210 | 211 | 212 | @pytest.mark.parametrize( 213 | "file_path", 214 | [ 215 | ("../../../../../../../../../../tmp/test"), 216 | ("../test"), 217 | ], 218 | ) 219 | async def test_extract_path_traversal(jp_fetch, jp_root_dir, file_path): 220 | unsafe_file_path = jp_root_dir / "test" 221 | archive_path = jp_root_dir / "test.tar.gz" 222 | open(unsafe_file_path, 'a').close() 223 | with tarfile.open(archive_path, "w:gz") as tf: 224 | tf.add(unsafe_file_path, file_path) 225 | 226 | with pytest.raises(Exception) as e: 227 | await jp_fetch("extract-archive", archive_path.relative_to(jp_root_dir).as_posix(), method="GET") 228 | assert e.type == HTTPClientError 229 | assert e.value.code == 400 230 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hadim/jupyter-archive", 3 | "version": "3.4.0", 4 | "description": "A JupyterLab extension to make, download and extract archive files.", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/jupyterlab-contrib/jupyter-archive", 11 | "bugs": { 12 | "url": "https://github.com/jupyterlab-contrib/jupyter-archive/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": { 16 | "name": "Hadrien Mary", 17 | "email": "hadrien.mary@gmail.com" 18 | }, 19 | "contributors": [ 20 | { 21 | "name": "Frederic Collonval", 22 | "email": "fcollonval@gmail.com" 23 | } 24 | ], 25 | "files": [ 26 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 27 | "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}", 28 | "style/index.js", 29 | "schema/*.json" 30 | ], 31 | "main": "lib/index.js", 32 | "types": "lib/index.d.ts", 33 | "style": "style/index.css", 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/jupyterlab-contrib/jupyter-archive.git" 37 | }, 38 | "scripts": { 39 | "build": "jlpm build:lib && jlpm build:labextension:dev", 40 | "build:all": "yarn run build:labextension", 41 | "build:labextension": "jupyter labextension build .", 42 | "build:labextension:dev": "jupyter labextension build --development True .", 43 | "build:lib": "tsc --sourceMap", 44 | "build:lib:prod": "tsc", 45 | "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", 46 | "clean": "jlpm clean:lib", 47 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 48 | "clean:labextension": "rimraf jupyter_archive/labextension jupyter_archive/_version.py", 49 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 50 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 51 | "eslint": "jlpm eslint:check --fix", 52 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 53 | "install:extension": "jlpm build", 54 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 55 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 56 | "prettier": "jlpm prettier:base --write --list-different", 57 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 58 | "prettier:check": "jlpm prettier:base --check", 59 | "stylelint": "jlpm stylelint:check --fix", 60 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 61 | "test": "jest --coverage", 62 | "watch": "run-p watch:src watch:labextension", 63 | "watch:labextension": "jupyter labextension watch .", 64 | "watch:src": "tsc -w --sourceMap" 65 | }, 66 | "packageManager": "yarn@3.5.0", 67 | "dependencies": { 68 | "@jupyterlab/application": "^4.0.3", 69 | "@jupyterlab/apputils": "^4.1.3", 70 | "@jupyterlab/coreutils": "^6.0.3", 71 | "@jupyterlab/filebrowser": "^4.0.3", 72 | "@jupyterlab/services": "^7.0.3", 73 | "@jupyterlab/settingregistry": "^4.0.3", 74 | "@jupyterlab/translation": "^4.0.3", 75 | "@jupyterlab/ui-components": "^4.0.3" 76 | }, 77 | "devDependencies": { 78 | "@jupyterlab/builder": "^4.0.0", 79 | "@jupyterlab/testutils": "^4.0.0", 80 | "@types/jest": "^29.2.0", 81 | "@types/json-schema": "^7.0.11", 82 | "@types/react": "^18.0.26", 83 | "@typescript-eslint/eslint-plugin": "^6.1.0", 84 | "@typescript-eslint/parser": "^6.1.0", 85 | "css-loader": "^6.7.1", 86 | "eslint": "^8.36.0", 87 | "eslint-config-prettier": "^8.8.0", 88 | "eslint-plugin-prettier": "^5.0.0", 89 | "jest": "^29.2.0", 90 | "mkdirp": "^1.0.3", 91 | "npm-run-all": "^4.1.5", 92 | "prettier": "^3.0.0", 93 | "rimraf": "^5.0.1", 94 | "source-map-loader": "^1.0.2", 95 | "style-loader": "^3.3.1", 96 | "stylelint": "^15.10.1", 97 | "stylelint-config-recommended": "^13.0.0", 98 | "stylelint-config-standard": "^34.0.0", 99 | "stylelint-prettier": "^4.0.0", 100 | "typescript": "~5.0.2", 101 | "yjs": "^13.5.40" 102 | }, 103 | "resolutions": { 104 | "@types/node": "^18.0.0" 105 | }, 106 | "sideEffects": [ 107 | "style/*.css", 108 | "style/index.js" 109 | ], 110 | "styleModule": "style/index.js", 111 | "jupyterlab": { 112 | "extension": true, 113 | "schemaDir": "schema", 114 | "discovery": { 115 | "server": { 116 | "managers": [ 117 | "conda", 118 | "pip" 119 | ], 120 | "base": { 121 | "name": "jupyter-archive" 122 | } 123 | } 124 | }, 125 | "outputDir": "jupyter_archive/labextension" 126 | }, 127 | "eslintIgnore": [ 128 | "node_modules", 129 | "dist", 130 | "coverage", 131 | "**/*.d.ts", 132 | "tests", 133 | "**/__tests__", 134 | "ui-tests" 135 | ], 136 | "eslintConfig": { 137 | "extends": [ 138 | "eslint:recommended", 139 | "plugin:@typescript-eslint/eslint-recommended", 140 | "plugin:@typescript-eslint/recommended", 141 | "plugin:prettier/recommended" 142 | ], 143 | "parser": "@typescript-eslint/parser", 144 | "parserOptions": { 145 | "project": "tsconfig.json", 146 | "sourceType": "module" 147 | }, 148 | "plugins": [ 149 | "@typescript-eslint" 150 | ], 151 | "rules": { 152 | "@typescript-eslint/naming-convention": [ 153 | "error", 154 | { 155 | "selector": "interface", 156 | "format": [ 157 | "PascalCase" 158 | ], 159 | "custom": { 160 | "regex": "^I[A-Z]", 161 | "match": true 162 | } 163 | } 164 | ], 165 | "@typescript-eslint/no-unused-vars": [ 166 | "warn", 167 | { 168 | "args": "none" 169 | } 170 | ], 171 | "@typescript-eslint/no-explicit-any": "off", 172 | "@typescript-eslint/no-namespace": "off", 173 | "@typescript-eslint/no-use-before-define": "off", 174 | "@typescript-eslint/quotes": [ 175 | "error", 176 | "single", 177 | { 178 | "avoidEscape": true, 179 | "allowTemplateLiterals": false 180 | } 181 | ], 182 | "curly": [ 183 | "error", 184 | "all" 185 | ], 186 | "eqeqeq": "error", 187 | "prefer-arrow-callback": "error" 188 | } 189 | }, 190 | "prettier": { 191 | "singleQuote": true, 192 | "trailingComma": "none", 193 | "arrowParens": "avoid", 194 | "endOfLine": "auto", 195 | "overrides": [ 196 | { 197 | "files": "package.json", 198 | "options": { 199 | "tabWidth": 4 200 | } 201 | } 202 | ] 203 | }, 204 | "stylelint": { 205 | "extends": [ 206 | "stylelint-config-recommended", 207 | "stylelint-config-standard", 208 | "stylelint-prettier/recommended" 209 | ], 210 | "rules": { 211 | "property-no-vendor-prefix": null, 212 | "selector-no-vendor-prefix": null, 213 | "value-no-vendor-prefix": null 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyter-archive" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.6" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 3", 14 | "Framework :: Jupyter :: JupyterLab :: 4", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions", 16 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 17 | "License :: OSI Approved :: BSD License", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.6", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | ] 27 | dependencies = [ 28 | "jupyter_server>=1.21,<3" 29 | ] 30 | dynamic = ["version", "description", "authors", "urls", "keywords"] 31 | 32 | [project.optional-dependencies] 33 | test = [ 34 | "coverage", 35 | "pytest", 36 | "pytest-asyncio", 37 | "pytest-cov", 38 | "pytest-jupyter[server]>=0.6.0" 39 | ] 40 | 41 | [tool.hatch.version] 42 | source = "nodejs" 43 | 44 | [tool.hatch.metadata.hooks.nodejs] 45 | fields = ["description", "authors", "urls"] 46 | 47 | [tool.hatch.build.targets.sdist] 48 | artifacts = ["jupyter_archive/labextension"] 49 | exclude = [".github", "binder"] 50 | 51 | [tool.hatch.build.targets.wheel.shared-data] 52 | "jupyter_archive/labextension" = "share/jupyter/labextensions/@hadim/jupyter-archive" 53 | "install.json" = "share/jupyter/labextensions/@hadim/jupyter-archive/install.json" 54 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 55 | 56 | [tool.hatch.build.hooks.version] 57 | path = "jupyter_archive/_version.py" 58 | 59 | [tool.hatch.build.hooks.jupyter-builder] 60 | dependencies = ["hatch-jupyter-builder>=0.5"] 61 | build-function = "hatch_jupyter_builder.npm_builder" 62 | ensured-targets = [ 63 | "jupyter_archive/labextension/static/style.js", 64 | "jupyter_archive/labextension/package.json", 65 | ] 66 | skip-if-exists = ["jupyter_archive/labextension/static/style.js"] 67 | 68 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 69 | build_cmd = "build:prod" 70 | npm = ["jlpm"] 71 | 72 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 73 | build_cmd = "install:extension" 74 | npm = ["jlpm"] 75 | source_dir = "src" 76 | build_dir = "jupyter_archive/labextension" 77 | 78 | [tool.jupyter-releaser.options] 79 | version_cmd = "hatch version" 80 | 81 | [tool.jupyter-releaser.hooks] 82 | before-build-npm = [ 83 | "python -m pip install 'jupyterlab>=4.0.0,<5'", 84 | "jlpm", 85 | "jlpm build:prod" 86 | ] 87 | before-build-python = ["jlpm clean:all"] 88 | 89 | [tool.check-wheel-contents] 90 | ignore = ["W002"] 91 | -------------------------------------------------------------------------------- /schema/archive.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.setting-icon": "jupyter-archive:archive", 3 | "title": "Archive", 4 | "description": "Archive handler options.", 5 | "properties": { 6 | "format": { 7 | "enum": [ 8 | "", 9 | "zip", 10 | "tgz", 11 | "tar.gz", 12 | "tbz", 13 | "tbz2", 14 | "tar.bz", 15 | "tar.bz2", 16 | "txz", 17 | "tar.xz" 18 | ], 19 | "title": "Archive format", 20 | "description": "Archive format for compressing folder; one of ['' (submenu), 'zip', 'tgz', 'tar.gz', 'tbz', 'tbz2', 'tar.bz', 'tar.bz2', 'txz', 'tar.xz']", 21 | "default": "zip" 22 | }, 23 | "followSymlinks": { 24 | "enum": ["true", "false"], 25 | "title": "Follow Symlinks", 26 | "description": "Whether or not to resolve symlinks and add resulting files to the archive; one of ['true', 'false']", 27 | "default": "true" 28 | }, 29 | "downloadHidden": { 30 | "enum": ["true", "false"], 31 | "title": "Download Hidden Files", 32 | "description": "Whether or not to add hidden files to the archive when downloading; one of ['true', 'false']", 33 | "default": "false" 34 | } 35 | }, 36 | "additionalProperties": false, 37 | "type": "object" 38 | } 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /src/icon.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | 3 | // icon svg import statements 4 | import archiveSvg from '../style/icons/archive.svg'; 5 | import unarchiveSvg from '../style/icons/unarchive.svg'; 6 | 7 | export const archiveIcon = new LabIcon({ 8 | name: 'jupyter-archive:archive', 9 | svgstr: archiveSvg 10 | }); 11 | export const unarchiveIcon = new LabIcon({ 12 | name: 'jupyter-archive:unarchive', 13 | svgstr: unarchiveSvg 14 | }); 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { showErrorMessage } from '@jupyterlab/apputils'; 6 | import { URLExt, PathExt } from '@jupyterlab/coreutils'; 7 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 8 | import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; 9 | import { ServerConnection } from '@jupyterlab/services'; 10 | import { ITranslator, nullTranslator } from '@jupyterlab/translation'; 11 | import { each } from '@lumino/algorithm'; 12 | import { IDisposable } from '@lumino/disposable'; 13 | import { Menu } from '@lumino/widgets'; 14 | import { archiveIcon, unarchiveIcon } from './icon'; 15 | 16 | const DIRECTORIES_URL = 'directories'; 17 | const EXTRACT_ARCHIVE_URL = 'extract-archive'; 18 | type ArchiveFormat = 19 | | '' 20 | | 'zip' 21 | | 'tgz' 22 | | 'tar.gz' 23 | | 'tbz' 24 | | 'tbz2' 25 | | 'tar.bz' 26 | | 'tar.bz2' 27 | | 'txz' 28 | | 'tar.xz'; 29 | 30 | namespace CommandIDs { 31 | export const downloadArchive = 'filebrowser:download-archive'; 32 | export const extractArchive = 'filebrowser:extract-archive'; 33 | export const downloadArchiveCurrentFolder = 34 | 'filebrowser:download-archive-current-folder'; 35 | } 36 | 37 | function downloadArchiveRequest( 38 | path: string, 39 | archiveFormat: ArchiveFormat, 40 | followSymlinks: string, 41 | downloadHidden: string 42 | ): void { 43 | const settings = ServerConnection.makeSettings(); 44 | 45 | const baseUrl = settings.baseUrl; 46 | let url = URLExt.join(baseUrl, DIRECTORIES_URL, URLExt.encodeParts(path)); 47 | // Request downloading the root folder 48 | if (path === '') { 49 | url += '/'; 50 | } 51 | 52 | const fullurl = new URL(url); 53 | 54 | // Generate a random token. 55 | const rand = (): string => Math.random().toString(36).slice(2); 56 | const token = (length: number): string => 57 | (rand() + rand() + rand() + rand()).slice(0, length); 58 | 59 | fullurl.searchParams.append('archiveToken', token(20)); 60 | fullurl.searchParams.append('archiveFormat', archiveFormat); 61 | fullurl.searchParams.append('followSymlinks', followSymlinks); 62 | fullurl.searchParams.append('downloadHidden', downloadHidden); 63 | 64 | const xsrfTokenMatch = document.cookie.match('\\b_xsrf=([^;]*)\\b'); 65 | if (xsrfTokenMatch) { 66 | fullurl.searchParams.append('_xsrf', xsrfTokenMatch[1]); 67 | } 68 | 69 | url = fullurl.toString(); 70 | 71 | // Check the browser is Chrome https://stackoverflow.com/a/9851769 72 | const chrome = (window as any).chrome; 73 | const isChrome = !!chrome && (!!chrome.webstore || !!chrome.runtime); 74 | if (isChrome) { 75 | // Workaround https://bugs.chromium.org/p/chromium/issues/detail?id=455987 76 | window.open(url); 77 | } else { 78 | const element = document.createElement('a'); 79 | document.body.appendChild(element); 80 | element.setAttribute('href', url); 81 | element.setAttribute('download', ''); 82 | element.click(); 83 | document.body.removeChild(element); 84 | } 85 | } 86 | 87 | async function extractArchiveRequest(path: string): Promise { 88 | const settings = ServerConnection.makeSettings(); 89 | 90 | const baseUrl = settings.baseUrl; 91 | let url = URLExt.join(baseUrl, EXTRACT_ARCHIVE_URL, URLExt.encodeParts(path)); 92 | 93 | const fullurl = new URL(url); 94 | 95 | const xsrfTokenMatch = document.cookie.match('\\b_xsrf=([^;]*)\\b'); 96 | if (xsrfTokenMatch) { 97 | fullurl.searchParams.append('_xsrf', xsrfTokenMatch[1]); 98 | } 99 | 100 | url = fullurl.toString(); 101 | const request = { method: 'GET' }; 102 | 103 | const response = await ServerConnection.makeRequest(url, request, settings); 104 | if (response.status !== 200) { 105 | const data = await response.json(); 106 | showErrorMessage('Fail to extract the archive file', data.reason); 107 | throw new ServerConnection.ResponseError(response); 108 | } 109 | } 110 | 111 | /** 112 | * Initialization data for the jupyter-archive extension. 113 | */ 114 | const extension: JupyterFrontEndPlugin = { 115 | id: '@hadim/jupyter-archive:archive', 116 | autoStart: true, 117 | 118 | requires: [IFileBrowserFactory, ISettingRegistry], 119 | optional: [ITranslator], 120 | 121 | activate: ( 122 | app: JupyterFrontEnd, 123 | factory: IFileBrowserFactory, 124 | settingRegistry: ISettingRegistry, 125 | translator: ITranslator | null 126 | ) => { 127 | const trans = (translator ?? nullTranslator).load('jupyter_archive'); 128 | 129 | console.log('JupyterLab extension jupyter-archive is activated!'); 130 | 131 | const { commands } = app; 132 | const { tracker } = factory; 133 | 134 | const allowedArchiveExtensions = [ 135 | '.zip', 136 | '.tgz', 137 | '.tar.gz', 138 | '.tbz', 139 | '.tbz2', 140 | '.tar.bz', 141 | '.tar.bz2', 142 | '.txz', 143 | '.tar.xz' 144 | ]; 145 | let archiveFormat: ArchiveFormat; // Default value read from settings 146 | let followSymlinks: string; // Default value read from settings 147 | let downloadHidden: string; // Default value read from settings 148 | 149 | // matches anywhere on filebrowser 150 | const selectorContent = '.jp-DirListing-content'; 151 | 152 | // matches directory filebrowser items 153 | const selectorOnlyDir = '.jp-DirListing-item[data-isdir="true"]'; 154 | 155 | // matches file filebrowser items 156 | const selectorNotDir = '.jp-DirListing-item[data-isdir="false"]'; 157 | 158 | // Create submenus 159 | const archiveFolder = new Menu({ 160 | commands 161 | }); 162 | archiveFolder.title.label = trans.__('Download As'); 163 | archiveFolder.title.icon = archiveIcon; 164 | const archiveCurrentFolder = new Menu({ 165 | commands 166 | }); 167 | archiveCurrentFolder.title.label = trans.__('Download Current Folder As'); 168 | archiveCurrentFolder.title.icon = archiveIcon; 169 | 170 | ['zip', 'tar.bz2', 'tar.gz', 'tar.xz'].forEach(format => { 171 | archiveFolder.addItem({ 172 | command: CommandIDs.downloadArchive, 173 | args: { format } 174 | }); 175 | archiveCurrentFolder.addItem({ 176 | command: CommandIDs.downloadArchiveCurrentFolder, 177 | args: { format } 178 | }); 179 | }); 180 | 181 | // Reference to menu items 182 | let archiveFolderItem: IDisposable; 183 | let archiveCurrentFolderItem: IDisposable; 184 | 185 | function updateFormat( 186 | newFormat: ArchiveFormat, 187 | oldFormat: ArchiveFormat 188 | ): void { 189 | if (newFormat !== oldFormat) { 190 | if (!newFormat || !oldFormat) { 191 | if (oldFormat !== undefined) { 192 | archiveFolderItem.dispose(); 193 | archiveCurrentFolderItem.dispose(); 194 | } 195 | 196 | if (!newFormat) { 197 | archiveFolderItem = app.contextMenu.addItem({ 198 | selector: selectorOnlyDir, 199 | rank: 10, 200 | type: 'submenu', 201 | submenu: archiveFolder 202 | }); 203 | 204 | archiveCurrentFolderItem = app.contextMenu.addItem({ 205 | selector: selectorContent, 206 | rank: 3, 207 | type: 'submenu', 208 | submenu: archiveCurrentFolder 209 | }); 210 | } else { 211 | archiveFolderItem = app.contextMenu.addItem({ 212 | command: CommandIDs.downloadArchive, 213 | selector: selectorOnlyDir, 214 | rank: 10 215 | }); 216 | 217 | archiveCurrentFolderItem = app.contextMenu.addItem({ 218 | command: CommandIDs.downloadArchiveCurrentFolder, 219 | selector: selectorContent, 220 | rank: 3 221 | }); 222 | } 223 | } 224 | 225 | archiveFormat = newFormat; 226 | } 227 | } 228 | 229 | // Load the settings 230 | settingRegistry 231 | .load('@hadim/jupyter-archive:archive') 232 | .then(settings => { 233 | settings.changed.connect(settings => { 234 | const newFormat = settings.get('format').composite as ArchiveFormat; 235 | updateFormat(newFormat, archiveFormat); 236 | followSymlinks = settings.get('followSymlinks').composite as string; 237 | downloadHidden = settings.get('downloadHidden').composite as string; 238 | }); 239 | 240 | const newFormat = settings.get('format').composite as ArchiveFormat; 241 | updateFormat(newFormat, archiveFormat); 242 | followSymlinks = settings.get('followSymlinks').composite as string; 243 | downloadHidden = settings.get('downloadHidden').composite as string; 244 | }) 245 | .catch(reason => { 246 | console.error(reason); 247 | showErrorMessage( 248 | "Fail to read settings for '@hadim/jupyter-archive:archive'", 249 | reason 250 | ); 251 | }); 252 | 253 | // Add the 'downloadArchive' command to the file's menu. 254 | commands.addCommand(CommandIDs.downloadArchive, { 255 | execute: args => { 256 | const widget = tracker.currentWidget; 257 | if (widget) { 258 | each(widget.selectedItems(), item => { 259 | if (item.type === 'directory') { 260 | const format = args['format'] as ArchiveFormat; 261 | downloadArchiveRequest( 262 | item.path, 263 | allowedArchiveExtensions.indexOf('.' + format) >= 0 264 | ? format 265 | : archiveFormat, 266 | followSymlinks, 267 | downloadHidden 268 | ); 269 | } 270 | }); 271 | } 272 | }, 273 | icon: args => ('format' in args ? undefined : archiveIcon), 274 | label: args => { 275 | const format = (args['format'] as ArchiveFormat) ?? ''; 276 | const label = format.replace('.', ' ').toLocaleUpperCase(); 277 | return label 278 | ? trans.__('%1 Archive', label) 279 | : trans.__('Download as an Archive'); 280 | } 281 | }); 282 | 283 | // Add the 'extractArchive' command to the file's menu. 284 | commands.addCommand(CommandIDs.extractArchive, { 285 | execute: () => { 286 | const widget = tracker.currentWidget; 287 | if (widget) { 288 | each(widget.selectedItems(), item => { 289 | extractArchiveRequest(item.path); 290 | }); 291 | } 292 | }, 293 | icon: unarchiveIcon, 294 | isVisible: () => { 295 | const widget = tracker.currentWidget; 296 | let visible = false; 297 | if (widget) { 298 | let firstItem; 299 | try { 300 | firstItem = widget.selectedItems().next().value; 301 | } catch (e) { 302 | // Lumino v1 API 303 | firstItem = widget.selectedItems().next(); 304 | } 305 | if (firstItem) { 306 | const basename = PathExt.basename(firstItem.path); 307 | const splitName = basename.split('.'); 308 | let lastTwoParts = ''; 309 | if (splitName.length >= 2) { 310 | lastTwoParts = 311 | '.' + splitName.splice(splitName.length - 2, 2).join('.'); 312 | } 313 | visible = 314 | allowedArchiveExtensions.indexOf(PathExt.extname(basename)) >= 315 | 0 || allowedArchiveExtensions.indexOf(lastTwoParts) >= 0; 316 | } 317 | } 318 | return visible; 319 | }, 320 | label: trans.__('Extract Archive') 321 | }); 322 | 323 | app.contextMenu.addItem({ 324 | command: CommandIDs.extractArchive, 325 | selector: selectorNotDir, 326 | rank: 10 327 | }); 328 | 329 | // Add the 'downloadArchiveCurrentFolder' command to file browser content. 330 | commands.addCommand(CommandIDs.downloadArchiveCurrentFolder, { 331 | execute: args => { 332 | const widget = tracker.currentWidget; 333 | if (widget) { 334 | const format = args['format'] as ArchiveFormat; 335 | downloadArchiveRequest( 336 | widget.model.path, 337 | allowedArchiveExtensions.indexOf('.' + format) >= 0 338 | ? format 339 | : archiveFormat, 340 | followSymlinks, 341 | downloadHidden 342 | ); 343 | } 344 | }, 345 | icon: args => ('format' in args ? undefined : archiveIcon), 346 | label: args => { 347 | const format = (args['format'] as ArchiveFormat) || ''; 348 | const label = format.replace('.', ' ').toLocaleUpperCase(); 349 | return label 350 | ? trans.__('%1 Archive', label) 351 | : trans.__('Download Current Folder as an Archive'); 352 | } 353 | }); 354 | } 355 | }; 356 | 357 | export default extension; 358 | -------------------------------------------------------------------------------- /src/svg.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | // including this file in a package allows for the use of import statements 5 | // with svg files. Example: `import xSvg from 'path/xSvg.svg'` 6 | 7 | // for use with raw-loader in Webpack. 8 | // The svg will be imported as a raw string 9 | 10 | declare module '*.svg' { 11 | const value: string; 12 | export default value; 13 | } 14 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) 2023, JupyterLab Contributors Community. 3 | | 4 | | Distributed under the terms of the Modified BSD License. 5 | |---------------------------------------------------------------------------- */ 6 | -------------------------------------------------------------------------------- /style/icons/archive.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/unarchive.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2018" 21 | }, 22 | "include": ["src/*"] 23 | } 24 | -------------------------------------------------------------------------------- /ui-tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Testing 2 | 3 | This folder contains the integration tests of the extension. 4 | 5 | They are defined using [Playwright](https://playwright.dev/docs/intro) test runner 6 | and [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) helper. 7 | 8 | The Playwright configuration is defined in [playwright.config.js](./playwright.config.js). 9 | 10 | The JupyterLab server configuration to use for the integration test is defined 11 | in [jupyter_server_test_config.py](./jupyter_server_test_config.py). 12 | 13 | The default configuration will produce video for failing tests and an HTML report. 14 | 15 | > There is a new experimental UI mode that you may fall in love with; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0). 16 | 17 | ## Run the tests 18 | 19 | > All commands are assumed to be executed from the root directory 20 | 21 | To run the tests, you need to: 22 | 23 | 1. Compile the extension: 24 | 25 | ```sh 26 | jlpm install 27 | jlpm build:prod 28 | ``` 29 | 30 | > Check the extension is installed in JupyterLab. 31 | 32 | 2. Install test dependencies (needed only once): 33 | 34 | ```sh 35 | cd ./ui-tests 36 | jlpm install 37 | jlpm playwright install 38 | cd .. 39 | ``` 40 | 41 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: 42 | 43 | ```sh 44 | cd ./ui-tests 45 | jlpm playwright test 46 | ``` 47 | 48 | Test results will be shown in the terminal. In case of any test failures, the test report 49 | will be opened in your browser at the end of the tests execution; see 50 | [Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) 51 | for configuring that behavior. 52 | 53 | ## Update the tests snapshots 54 | 55 | > All commands are assumed to be executed from the root directory 56 | 57 | If you are comparing snapshots to validate your tests, you may need to update 58 | the reference snapshots stored in the repository. To do that, you need to: 59 | 60 | 1. Compile the extension: 61 | 62 | ```sh 63 | jlpm install 64 | jlpm build:prod 65 | ``` 66 | 67 | > Check the extension is installed in JupyterLab. 68 | 69 | 2. Install test dependencies (needed only once): 70 | 71 | ```sh 72 | cd ./ui-tests 73 | jlpm install 74 | jlpm playwright install 75 | cd .. 76 | ``` 77 | 78 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) command: 79 | 80 | ```sh 81 | cd ./ui-tests 82 | jlpm playwright test -u 83 | ``` 84 | 85 | > Some discrepancy may occurs between the snapshots generated on your computer and 86 | > the one generated on the CI. To ease updating the snapshots on a PR, you can 87 | > type `please update playwright snapshots` to trigger the update by a bot on the CI. 88 | > Once the bot has computed new snapshots, it will commit them to the PR branch. 89 | 90 | ## Create tests 91 | 92 | > All commands are assumed to be executed from the root directory 93 | 94 | To create tests, the easiest way is to use the code generator tool of playwright: 95 | 96 | 1. Compile the extension: 97 | 98 | ```sh 99 | jlpm install 100 | jlpm build:prod 101 | ``` 102 | 103 | > Check the extension is installed in JupyterLab. 104 | 105 | 2. Install test dependencies (needed only once): 106 | 107 | ```sh 108 | cd ./ui-tests 109 | jlpm install 110 | jlpm playwright install 111 | cd .. 112 | ``` 113 | 114 | 3. Start the server: 115 | 116 | ```sh 117 | cd ./ui-tests 118 | jlpm start 119 | ``` 120 | 121 | 4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**: 122 | 123 | ```sh 124 | cd ./ui-tests 125 | jlpm playwright codegen localhost:8888 126 | ``` 127 | 128 | ## Debug tests 129 | 130 | > All commands are assumed to be executed from the root directory 131 | 132 | To debug tests, a good way is to use the inspector tool of playwright: 133 | 134 | 1. Compile the extension: 135 | 136 | ```sh 137 | jlpm install 138 | jlpm build:prod 139 | ``` 140 | 141 | > Check the extension is installed in JupyterLab. 142 | 143 | 2. Install test dependencies (needed only once): 144 | 145 | ```sh 146 | cd ./ui-tests 147 | jlpm install 148 | jlpm playwright install 149 | cd .. 150 | ``` 151 | 152 | 3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): 153 | 154 | ```sh 155 | cd ./ui-tests 156 | jlpm playwright test --debug 157 | ``` 158 | 159 | ## Upgrade Playwright and the browsers 160 | 161 | To update the web browser versions, you must update the package `@playwright/test`: 162 | 163 | ```sh 164 | cd ./ui-tests 165 | jlpm up "@playwright/test" 166 | jlpm playwright install 167 | ``` 168 | -------------------------------------------------------------------------------- /ui-tests/jupyter_server_test_config.py: -------------------------------------------------------------------------------- 1 | """Server configuration for integration tests. 2 | 3 | !! Never use this configuration in production because it 4 | opens the server to the world and provide access to JupyterLab 5 | JavaScript objects through the global window variable. 6 | """ 7 | try: 8 | # Available from JupyterLab 4 and later 9 | from jupyterlab.galata import configure_jupyter_server 10 | 11 | configure_jupyter_server(c) 12 | 13 | # Option specific for notebook v7+ 14 | import jupyterlab 15 | from pathlib import Path 16 | c.JupyterNotebookApp.expose_app_in_browser = True 17 | c.LabServerApp.extra_labextensions_path = str(Path(jupyterlab.__file__).parent / "galata") 18 | 19 | except ImportError: 20 | from tempfile import mkdtemp 21 | 22 | c.ServerApp.port = 8888 23 | c.ServerApp.port_retries = 0 24 | c.ServerApp.open_browser = False 25 | 26 | c.ServerApp.root_dir = mkdtemp(prefix='galata-test-') 27 | c.ServerApp.token = "" 28 | c.ServerApp.password = "" 29 | c.ServerApp.disable_check_xsrf = True 30 | c.LabApp.expose_app_in_browser = True 31 | 32 | # Uncomment to set server log level to debug level 33 | # c.ServerApp.log_level = "DEBUG" 34 | -------------------------------------------------------------------------------- /ui-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-archive-ui-tests", 3 | "version": "1.0.0", 4 | "description": "JupyterLab jupyterlab-archive Integration Tests", 5 | "private": true, 6 | "scripts": { 7 | "start": "jupyter lab --config jupyter_server_test_config.py", 8 | "start:notebook": "jupyter notebook --config jupyter_server_test_config.py", 9 | "test": "jlpm playwright test", 10 | "test:notebook": "jlpm playwright test -c playwright-notebook.config.js", 11 | "test:update": "jlpm playwright test --update-snapshots", 12 | "test:notebook:update": "jlpm playwright test -c playwright-notebook.config.js --update-snapshots" 13 | }, 14 | "devDependencies": { 15 | "@jupyterlab/galata": "^5.0.5", 16 | "@playwright/test": "^1.32.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui-tests/playwright-notebook.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for Playwright using default from @jupyterlab/galata 3 | */ 4 | const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); 5 | 6 | // Trick to customize the fixture `waitForApplication` 7 | process.env.IS_NOTEBOOK = '1'; 8 | 9 | module.exports = { 10 | ...baseConfig, 11 | use: { 12 | ...baseConfig.use, 13 | appPath: '' 14 | }, 15 | webServer: { 16 | command: 'jlpm start:notebook', 17 | url: 'http://localhost:8888/tree', 18 | timeout: 120 * 1000, 19 | reuseExistingServer: !process.env.CI 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /ui-tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for Playwright using default from @jupyterlab/galata 3 | */ 4 | const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); 5 | 6 | module.exports = { 7 | ...baseConfig, 8 | webServer: { 9 | command: 'jlpm start', 10 | url: 'http://localhost:8888/lab', 11 | timeout: 120 * 1000, 12 | reuseExistingServer: !process.env.CI 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /ui-tests/tests/data/folder.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab-contrib/jupyter-archive/efcdf8b419509957242ff75c40ceae85471708b4/ui-tests/tests/data/folder.tar.xz -------------------------------------------------------------------------------- /ui-tests/tests/jupyter-archive.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, galata, test } from '@jupyterlab/galata'; 2 | import * as path from 'path'; 3 | 4 | const fileName = 'folder.tar.xz'; 5 | 6 | if (process.env.IS_NOTEBOOK) { 7 | test.use({ 8 | waitForApplication: async ({ baseURL }, use, testInfo) => { 9 | const waitIsReady = async (page): Promise => { 10 | await page.waitForSelector('#main-panel'); 11 | }; 12 | await use(waitIsReady); 13 | } 14 | }); 15 | } 16 | 17 | test('should download a folder as an archive', async ({ page }) => { 18 | await page.locator('.jp-DirListing-content').click({ 19 | button: 'right' 20 | }); 21 | await page.getByText('New Folder').click(); 22 | await page.keyboard.press('Enter'); 23 | await page.getByRole('listitem', { name: 'Untitled Folder' }).click({ 24 | button: 'right' 25 | }); 26 | 27 | const downloadPromise = page.waitForEvent('download'); 28 | await page.getByText('Download as an Archive').click(); 29 | const download = await downloadPromise; 30 | expect(await download.path()).toBeDefined(); 31 | }); 32 | 33 | test('should download the current folder as an archive', async ({ page }) => { 34 | await page.locator('.jp-DirListing-content').click({ 35 | button: 'right' 36 | }); 37 | await page.getByText('New Folder').click(); 38 | await page.keyboard.press('Enter'); 39 | await page.getByRole('listitem', { name: 'Untitled Folder' }).click({ 40 | button: 'right' 41 | }); 42 | const downloadPromise = page.waitForEvent('download'); 43 | await page.getByText('Download Current Folder as an Archive').click(); 44 | const download = await downloadPromise; 45 | 46 | expect(await download.path()).toBeDefined(); 47 | }); 48 | 49 | test('should extract an archive', async ({ baseURL, page, tmpPath }) => { 50 | const skip = await page.evaluate(() => { 51 | return window.jupyterapp.version.startsWith('3.'); 52 | }); 53 | 54 | if (skip) { 55 | console.log('Test skipped'); 56 | return; 57 | } 58 | 59 | const contents = galata.newContentsHelper(page.request); 60 | await contents.uploadFile( 61 | path.resolve(__dirname, `./data/${fileName}`), 62 | `${tmpPath}/${fileName}` 63 | ); 64 | 65 | await page.getByRole('listitem', { name: 'folder.tar.xz' }).click({ 66 | button: 'right' 67 | }); 68 | await page.getByText('Extract Archive').click(); 69 | await page.getByRole('button', { name: 'Refresh the file browser.' }).click(); 70 | await page.getByText('schema', { exact: true }).dblclick(); 71 | 72 | await expect(page.getByText('archive.json')).toHaveCount(1); 73 | }); 74 | 75 | test.describe('submenu', () => { 76 | test.use({ 77 | mockSettings: { 78 | '@hadim/jupyter-archive:archive': { 79 | format: '' 80 | } 81 | } 82 | }); 83 | 84 | test('should pick folder archive type from submenu', async ({ page }) => { 85 | await page.locator('.jp-DirListing-content').click({ 86 | button: 'right' 87 | }); 88 | await page.getByText('New Folder').click(); 89 | await page.keyboard.press('Enter'); 90 | await page.getByRole('listitem', { name: 'Untitled Folder' }).click({ 91 | button: 'right' 92 | }); 93 | 94 | await page.getByText('Download As').click(); 95 | await expect(page.getByText(/^[\w\s]+\sArchive$/)).toHaveCount(4); 96 | }); 97 | }); 98 | --------------------------------------------------------------------------------