diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 321985c..9f74408 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,9 +55,6 @@ jobs: - name: Install the Python dependencies run: | pip install -e ".[test]" codecov - - name: Point at Jupyter Server branch with terminals removed. - run: | - pip install -U git+https://github.com/Zsailer/jupyter_server.git@jupyter_server_terminals - name: List installed packages run: | pip freeze @@ -75,6 +72,42 @@ jobs: run: | codecov + test_minimum_versions: + name: Test Minimum Versions + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Base Setup + uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + with: + python_version: "3.7" + - name: Install miniumum versions + uses: jupyterlab/maintainer-tools/.github/actions/install-minimums@v1 + - name: Run the unit tests + run: | + pytest -vv -W default || pytest -vv -W default --lf + + test_prereleases: + name: Test Prereleases + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Base Setup + uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - name: Install the Python dependencies + run: | + pip install --pre -e ".[test]" + - name: List installed packages + run: | + pip freeze + pip check + - name: Run the tests + run: | + pytest -vv || pytest -vv --lf + make_sdist: name: Make SDist runs-on: ubuntu-latest @@ -84,11 +117,11 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/make-sdist@v1 - # test_sdist: - # runs-on: ubuntu-latest - # needs: [make_sdist] - # name: Install from SDist and Test - # timeout-minutes: 15 - # steps: - # - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - # - uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1 + test_sdist: + runs-on: ubuntu-latest + needs: [make_sdist] + name: Install from SDist and Test + timeout-minutes: 15 + steps: + - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1 diff --git a/jupyter_server_terminals/api_handlers.py b/jupyter_server_terminals/api_handlers.py index 3efc04a..9739037 100644 --- a/jupyter_server_terminals/api_handlers.py +++ b/jupyter_server_terminals/api_handlers.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from tornado import web @@ -31,6 +32,27 @@ def post(self): """POST /terminals creates a new terminal and redirects to it""" data = self.get_json_body() or {} + # if cwd is a relative path, it should be relative to the root_dir, + # but if we pass it as relative, it will we be considered as relative to + # the path jupyter_server was started in + if "cwd" in data: + cwd = Path(data["cwd"]) + if not cwd.resolve().exists(): + cwd = Path(self.settings["server_root_dir"]).expanduser() / cwd + if not cwd.resolve().exists(): + cwd = None + + if cwd is None: + server_root_dir = self.settings["server_root_dir"] + self.log.debug( + f"Failed to find requested terminal cwd: {data.get('cwd')}\n" + f" It was not found within the server root neither: {server_root_dir}." + ) + del data["cwd"] + else: + self.log.debug(f"Opening terminal in: {cwd.resolve()!s}") + data["cwd"] = str(cwd.resolve()) + model = self.terminal_manager.create(**data) self.finish(json.dumps(model)) diff --git a/jupyter_server_terminals/app.py b/jupyter_server_terminals/app.py index 6fed83b..38fca86 100644 --- a/jupyter_server_terminals/app.py +++ b/jupyter_server_terminals/app.py @@ -73,6 +73,9 @@ def initialize_handlers(self): ) self.handlers.extend(api_handlers.default_handlers) self.serverapp.web_app.settings["terminal_manager"] = self.terminal_manager + self.serverapp.web_app.settings["terminals_available"] = self.settings[ + "terminals_available" + ] def current_activity(self): if self.terminals_available: @@ -89,7 +92,7 @@ async def cleanup_terminals(self): if not self.terminals_available: return - terminal_manager = self.web_app.settings["terminal_manager"] + terminal_manager = self.terminal_manager n_terminals = len(terminal_manager.list()) terminal_msg = trans.ngettext( "Shutting down %d terminal", "Shutting down %d terminals", n_terminals diff --git a/setup.cfg b/setup.cfg index 928c0ea..e6f2971 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ test = pytest_tornasync pytest-cov pytest-timeout - jupyter_server[test]>=1 + jupyter_server[test] @ git+https://github.com/Zsailer/jupyter_server.git@jupyter_server_terminals [options.packages.find] exclude = ['docs*', 'tests*'] diff --git a/tests/test_auth.py b/tests/test_auth.py index f7be949..16aa051 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,11 +1,9 @@ """Tests for authorization""" +import asyncio import pytest -from jupyter_client.kernelspec import NATIVE_KERNEL_NAME from jupyter_server.auth.authorizer import Authorizer from jupyter_server.auth.utils import HTTP_METHOD_TO_AUTH_ACTION, match_url_to_resource -from nbformat import writes -from nbformat.v4 import new_notebook from tornado.httpclient import HTTPClientError from tornado.websocket import WebSocketHandler from traitlets.config import Config @@ -123,7 +121,6 @@ async def test_authorized_requests( request, io_loop, send_request, - tmp_path, jp_serverapp, jp_cleanup_subprocesses, method, @@ -131,29 +128,12 @@ async def test_authorized_requests( body, allowed, ): - # Setup stuff for the Contents API - # Add a notebook on disk - contents_dir = tmp_path / jp_serverapp.root_dir - p = contents_dir / "dir_for_testing" - p.mkdir(parents=True, exist_ok=True) - - # Create a notebook - nb = writes(new_notebook(), version=4) - nbname = p.joinpath("nb_for_testing.ipynb") - nbname.write_text(nb, encoding="utf-8") - - # Setup - nbpath = "dir_for_testing/nb_for_testing.ipynb" - kernelspec = NATIVE_KERNEL_NAME - km = jp_serverapp.kernel_manager - - if "terminal" in url: - term_manager = jp_serverapp.web_app.settings["terminal_manager"] - request.addfinalizer(lambda: io_loop.run_sync(term_manager.terminate_all)) - term_model = term_manager.create() - term_name = term_model["name"] - - url = url.format(**locals()) + term_manager = jp_serverapp.web_app.settings["terminal_manager"] + request.addfinalizer(lambda: io_loop.run_sync(term_manager.terminate_all)) + term_model = term_manager.create() + term_name = term_model["name"] + + url = url.format(term_name=term_name) if allowed: # Create a server with full permissions permissions = { @@ -168,7 +148,12 @@ async def test_authorized_requests( expected_codes = {403} jp_serverapp.authorizer.permissions = permissions - code = await send_request(url, body=body, method=method) - assert code in expected_codes + while True: + code = await send_request(url, body=body, method=method) + if code == 404: + await asyncio.sleep(1) + continue + assert code in expected_codes + break await jp_cleanup_subprocesses() diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 0c7cfbd..0331d4b 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -2,7 +2,7 @@ import json import os import shutil -import time +import sys import pytest from tornado.httpclient import HTTPClientError @@ -19,6 +19,16 @@ def terminal_path(tmp_path): shutil.rmtree(str(subdir), ignore_errors=True) +@pytest.fixture +def terminal_root_dir(jp_root_dir): + subdir = jp_root_dir.joinpath("terminal_path") + subdir.mkdir() + + yield subdir + + shutil.rmtree(str(subdir), ignore_errors=True) + + CULL_TIMEOUT = 10 CULL_INTERVAL = 3 @@ -38,7 +48,7 @@ def jp_server_config(): ) -async def test_no_terminals(jp_fetch, jp_server_config): +async def test_no_terminals(jp_fetch): resp_list = await jp_fetch( "api", "terminals", @@ -51,7 +61,7 @@ async def test_no_terminals(jp_fetch, jp_server_config): assert len(data) == 0 -async def test_terminal_create(jp_server_config, jp_fetch, jp_cleanup_subprocesses): +async def test_terminal_create(jp_fetch, jp_cleanup_subprocesses): resp = await jp_fetch( "api", "terminals", @@ -77,9 +87,7 @@ async def test_terminal_create(jp_server_config, jp_fetch, jp_cleanup_subprocess await jp_cleanup_subprocesses() -async def test_terminal_create_with_kwargs( - jp_server_config, jp_fetch, terminal_path, jp_cleanup_subprocesses -): +async def test_terminal_create_with_kwargs(jp_fetch, terminal_path, jp_cleanup_subprocesses): resp_create = await jp_fetch( "api", "terminals", @@ -106,7 +114,7 @@ async def test_terminal_create_with_kwargs( async def test_terminal_create_with_cwd( - jp_server_config, jp_fetch, jp_ws_fetch, terminal_path, jp_cleanup_subprocesses + jp_fetch, jp_ws_fetch, terminal_path, jp_cleanup_subprocesses ): resp = await jp_fetch( "api", @@ -126,7 +134,7 @@ async def test_terminal_create_with_cwd( except HTTPClientError as e: if e.code != 404: raise - time.sleep(1) + await asyncio.sleep(1) ws.write_message(json.dumps(["stdin", "pwd\r\n"])) @@ -148,7 +156,93 @@ async def test_terminal_create_with_cwd( await jp_cleanup_subprocesses() -async def test_culling_config(jp_server_config, jp_configurable_serverapp): +async def test_terminal_create_with_relative_cwd( + jp_fetch, jp_ws_fetch, jp_root_dir, terminal_root_dir, jp_cleanup_subprocesses +): + resp = await jp_fetch( + "api", + "terminals", + method="POST", + body=json.dumps({"cwd": str(terminal_root_dir.relative_to(jp_root_dir))}), + allow_nonstandard_methods=True, + ) + + data = json.loads(resp.body.decode()) + term_name = data["name"] + + while True: + try: + ws = await jp_ws_fetch("terminals", "websocket", term_name) + break + except HTTPClientError as e: + if e.code != 404: + raise + await asyncio.sleep(1) + + ws.write_message(json.dumps(["stdin", "pwd\r\n"])) + + message_stdout = "" + while True: + try: + message = await asyncio.wait_for(ws.read_message(), timeout=5.0) + except asyncio.TimeoutError: + break + + message = json.loads(message) + + if message[0] == "stdout": + message_stdout += message[1] + + ws.close() + + expected = terminal_root_dir.name if sys.platform == "win32" else str(terminal_root_dir) + assert expected in message_stdout + await jp_cleanup_subprocesses() + + +async def test_terminal_create_with_bad_cwd(jp_fetch, jp_ws_fetch, jp_cleanup_subprocesses): + non_existing_path = "/tmp/path/to/nowhere" + resp = await jp_fetch( + "api", + "terminals", + method="POST", + body=json.dumps({"cwd": non_existing_path}), + allow_nonstandard_methods=True, + ) + + data = json.loads(resp.body.decode()) + term_name = data["name"] + + while True: + try: + ws = await jp_ws_fetch("terminals", "websocket", term_name) + break + except HTTPClientError as e: + if e.code != 404: + raise + await asyncio.sleep(1) + + ws.write_message(json.dumps(["stdin", "pwd\r\n"])) + + message_stdout = "" + while True: + try: + message = await asyncio.wait_for(ws.read_message(), timeout=5.0) + except asyncio.TimeoutError: + break + + message = json.loads(message) + + if message[0] == "stdout": + message_stdout += message[1] + + ws.close() + + assert non_existing_path not in message_stdout + await jp_cleanup_subprocesses() + + +async def test_culling_config(jp_configurable_serverapp): terminal_mgr_config = jp_configurable_serverapp().config.ServerApp.TerminalManager assert terminal_mgr_config.cull_inactive_timeout == CULL_TIMEOUT assert terminal_mgr_config.cull_interval == CULL_INTERVAL @@ -158,7 +252,7 @@ async def test_culling_config(jp_server_config, jp_configurable_serverapp): @pytest.mark.skipif(os.name == "nt", reason="Not currently working on Windows") -async def test_culling(jp_server_config, jp_fetch, jp_cleanup_subprocesses): +async def test_culling(jp_fetch, jp_cleanup_subprocesses): # POST request resp = await jp_fetch( "api",