diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f27457de..3ebb1a39 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -67,7 +67,7 @@ jobs: # NOTE: See CONTRIBUTING.md for a local development setup that differs # slightly from this. # - # Pytest options are set in tests/pytest.ini. + # Pytest options are set in `pyproject.toml`. run: | pip install -vv $(ls ./dist/jupyter_server_proxy-*.whl)\[acceptance\] 'jupyterlab~=${{ matrix.jupyterlab-version }}.0' 'jupyter_server~=${{ matrix.jupyter_server-version }}.0' @@ -76,18 +76,8 @@ jobs: pip freeze pip check - - name: Run tests against jupyter-notebook + - name: Run tests run: | - JUPYTER_TOKEN=secret jupyter-notebook --config=./tests/resources/jupyter_server_config.py & - sleep 5 - cd tests - pytest -k "not acceptance" - - - name: Run tests against jupyter-lab - run: | - JUPYTER_TOKEN=secret jupyter-lab --config=./tests/resources/jupyter_server_config.py & - sleep 5 - cd tests pytest -k "not acceptance" - name: Upload pytest and coverage reports diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdbe485d..f222d61c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,16 +22,10 @@ jupyter labextension develop --overwrite . jupyter server extension enable jupyter_server_proxy ``` -Before running tests, you need a server that we can test against. - -```bash -JUPYTER_TOKEN=secret jupyter-lab --config=./tests/resources/jupyter_server_config.py --no-browser -``` - Run the tests: ```bash -pytest --verbose +pytest ``` These generate test and coverage reports in `build/pytest` and `build/coverage`. diff --git a/jupyter_server_proxy/__init__.py b/jupyter_server_proxy/__init__.py index ee658938..bb3d0f00 100644 --- a/jupyter_server_proxy/__init__.py +++ b/jupyter_server_proxy/__init__.py @@ -71,6 +71,11 @@ def _load_jupyter_server_extension(nbapp): ], ) + nbapp.log.debug( + "[jupyter-server-proxy] Started with known servers: %s", + ", ".join([p.name for p in server_processes]), + ) + # For backward compatibility load_jupyter_server_extension = _load_jupyter_server_extension diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 0cb37a78..f0c1348f 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -1,10 +1,15 @@ """ Traitlets based configuration for jupyter_server_proxy """ +import sys from collections import namedtuple from warnings import warn -import pkg_resources +if sys.version_info < (3, 10): # pragma: no cover + from importlib_metadata import entry_points +else: # pragma: no cover + from importlib.metadata import entry_points + from jupyter_server.utils import url_path_join as ujoin from traitlets import Dict, List, Tuple, Union, default, observe from traitlets.config import Configurable @@ -90,7 +95,7 @@ def get_timeout(self): def get_entrypoint_server_processes(serverproxy_config): sps = [] - for entry_point in pkg_resources.iter_entry_points("jupyter_serverproxy_servers"): + for entry_point in entry_points(group="jupyter_serverproxy_servers"): name = entry_point.name server_process_config = entry_point.load()() sps.append(make_server_process(name, server_process_config, serverproxy_config)) diff --git a/pyproject.toml b/pyproject.toml index 470bbaf1..e72839f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ classifiers = [ ] dependencies = [ "aiohttp", + "importlib_metadata >=4.8.3 ; python_version<\"3.10\"", "jupyter-server >=1.0", "simpervisor >=0.4", ] @@ -175,3 +176,26 @@ tag_template = "v{new_version}" [[tool.tbump.file]] src = "labextension/package.json" + +[tool.pytest.ini_options] +cache_dir = "build/.cache/pytest" +addopts = [ + "-vv", + "--cov=jupyter_server_proxy", + "--cov-branch", + "--cov-context=test", + "--cov-report=term-missing:skip-covered", + "--cov-report=html:build/coverage", + "--html=build/pytest/index.html", + "--color=yes", +] + +[tool.coverage.run] +data_file = "build/.coverage" +concurrency = [ + "multiprocessing", + "thread" +] + +[tool.coverage.html] +show_contexts = true diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..eaa400ab --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,113 @@ +"""Reusable test fixtures for ``jupyter_server_proxy``.""" +import os +import shutil +import socket +import sys +import time +from pathlib import Path +from subprocess import Popen +from typing import Any, Generator, Tuple +from urllib.error import URLError +from urllib.request import urlopen +from uuid import uuid4 + +from pytest import fixture + +HERE = Path(__file__).parent +RESOURCES = HERE / "resources" + + +@fixture +def a_token() -> str: + """Get a random UUID to use for a token.""" + return str(uuid4()) + + +@fixture +def an_unused_port() -> int: + """Get a random unused port.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + s.listen(1) + port = s.getsockname()[1] + s.close() + return port + + +@fixture(params=["notebook", "lab"]) +def a_server_cmd(request: Any) -> str: + """Get a viable name for a command.""" + return request.param + + +@fixture +def a_server( + a_server_cmd: str, + tmp_path: Path, + an_unused_port: int, + a_token: str, +) -> Generator[str, None, None]: + """Get a running server.""" + # get a copy of the resources + tests = tmp_path / "tests" + tests.mkdir() + shutil.copytree(RESOURCES, tests / "resources") + args = [ + sys.executable, + "-m", + "jupyter", + a_server_cmd, + f"--port={an_unused_port}", + "--no-browser", + "--config=./tests/resources/jupyter_server_config.py", + "--debug", + ] + + # prepare an env + env = dict(os.environ) + env.update(JUPYTER_TOKEN=a_token) + + # start the process + server_proc = Popen(args, cwd=str(tmp_path), env=env) + + # prepare some URLss + url = f"http://127.0.0.1:{an_unused_port}/" + canary_url = f"{url}favicon.ico" + shutdown_url = f"{url}api/shutdown?token={a_token}" + + retries = 10 + + while retries: + try: + urlopen(canary_url) + break + except URLError as err: + if "Connection refused" in str(err): + print( + f"{a_server_cmd} not ready, will try again in 0.5s [{retries} retries]", + flush=True, + ) + time.sleep(0.5) + retries -= 1 + continue + raise err + + print(f"{a_server_cmd} is ready...", flush=True) + + yield url + + # clean up after server is no longer needed + print(f"{a_server_cmd} shutting down...", flush=True) + urlopen(shutdown_url, data=[]) + server_proc.wait() + print(f"{a_server_cmd} is stopped", flush=True) + + +@fixture +def a_server_port_and_token( + a_server: str, # noqa + an_unused_port: int, + a_token: str, +) -> Tuple[int, str]: + """Get the port and token for a running server.""" + return an_unused_port, a_token diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 0a5f338a..00000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,9 +0,0 @@ -[pytest] -addopts = - -s - -vv - --cov jupyter_server_proxy - --cov-report term-missing:skip-covered - --cov-report html:../build/coverage - --html=../build/pytest/index.html - --color=yes diff --git a/tests/resources/jupyter_server_config.py b/tests/resources/jupyter_server_config.py index 760b8b8f..5712e17f 100644 --- a/tests/resources/jupyter_server_config.py +++ b/tests/resources/jupyter_server_config.py @@ -1,3 +1,10 @@ +import sys +from pathlib import Path + +HERE = Path(__file__).parent.resolve() + +sys.path.append(str(HERE)) + # load the config object for traitlets based configuration c = get_config() # noqa @@ -116,9 +123,5 @@ def cats_only(response, path): c.ServerProxy.non_service_rewrite_response = hello_to_foo -import sys - -sys.path.append("./tests/resources") c.ServerApp.jpserver_extensions = {"proxyextension": True} c.NotebookApp.nbserver_extensions = {"proxyextension": True} -# c.Application.log_level = 'DEBUG' diff --git a/tests/test_proxies.py b/tests/test_proxies.py index 79b20600..582a7104 100644 --- a/tests/test_proxies.py +++ b/tests/test_proxies.py @@ -1,19 +1,19 @@ import asyncio import gzip import json -import os from http.client import HTTPConnection from io import BytesIO +from typing import Tuple from urllib.parse import quote import pytest from tornado.websocket import websocket_connect -PORT = os.getenv("TEST_PORT", 8888) -TOKEN = os.getenv("JUPYTER_TOKEN", "secret") +# use ipv4 for CI, etc. +LOCALHOST = "127.0.0.1" -def request_get(port, path, token, host="localhost"): +def request_get(port, path, token, host=LOCALHOST): h = HTTPConnection(host, port, 10) if "?" in path: url = f"{path}&token={token}" @@ -32,7 +32,10 @@ def request_get(port, path, token, host="localhost"): "/python-unix-socket-file-no-command/", ], ) -def test_server_proxy_minimal_proxy_path_encoding(server_process_path): +def test_server_proxy_minimal_proxy_path_encoding( + server_process_path: str, a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token """Test that we don't encode anything more than we must to have a valid web request.""" special_path = quote( @@ -41,6 +44,7 @@ def test_server_proxy_minimal_proxy_path_encoding(server_process_path): ) test_url = server_process_path + special_path r = request_get(PORT, test_url, TOKEN) + __import__("pprint").pprint(r.headers.__dict__) assert r.code == 200 s = r.read().decode("ascii") assert f"GET /{special_path}&token=" in s @@ -55,14 +59,17 @@ def test_server_proxy_minimal_proxy_path_encoding(server_process_path): "/python-unix-socket-file-no-command/", ], ) -def test_server_proxy_hash_sign_encoding(server_process_path): +def test_server_proxy_hash_sign_encoding( + server_process_path: str, a_server_port_and_token: Tuple[int, str] +) -> None: """ FIXME: This is a test to establish the current behavior, but if it should be like this is a separate question not yet addressed. Related: https://github.com/jupyterhub/jupyter-server-proxy/issues/109 """ - h = HTTPConnection("localhost", PORT, 10) + PORT, TOKEN = a_server_port_and_token + h = HTTPConnection(LOCALHOST, PORT, 10) # Case 0: a reference case path = f"?token={TOKEN}" @@ -108,7 +115,8 @@ def test_server_proxy_hash_sign_encoding(server_process_path): assert s == "" -def test_server_rewrite_response(): +def test_server_rewrite_response(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-http-rewrite-response/ciao-a-tutti", TOKEN) assert r.code == 418 assert r.reason == "I'm a teapot" @@ -119,7 +127,8 @@ def test_server_rewrite_response(): assert s.startswith("GET /hello-a-tutti?token=") -def test_chained_rewrite_response(): +def test_chained_rewrite_response(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-chained-rewrite-response/ciao-a-tutti", TOKEN) assert r.code == 418 assert r.reason == "I'm a teapot" @@ -127,7 +136,10 @@ def test_chained_rewrite_response(): assert s.startswith("GET /foo-a-tutti?token=") -def test_cats_and_dogs_rewrite_response(): +def test_cats_and_dogs_rewrite_response( + a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-cats-only-rewrite-response/goats", TOKEN) assert r.code == 200 r = request_get(PORT, "/python-cats-only-rewrite-response/cat-club", TOKEN) @@ -142,7 +154,8 @@ def test_cats_and_dogs_rewrite_response(): assert s == "cats not allowed" -def test_server_proxy_non_absolute(): +def test_server_proxy_non_absolute(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-http/abc", TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -151,7 +164,8 @@ def test_server_proxy_non_absolute(): assert "X-Proxycontextpath: /python-http\n" in s -def test_server_proxy_absolute(): +def test_server_proxy_absolute(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-http-abs/def", TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -160,7 +174,8 @@ def test_server_proxy_absolute(): assert "X-Proxycontextpath" not in s -def test_server_proxy_requested_port(): +def test_server_proxy_requested_port(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-http-port54321/ghi", TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -172,7 +187,10 @@ def test_server_proxy_requested_port(): assert direct.code == 200 -def test_server_proxy_on_requested_port_no_command(): +def test_server_proxy_on_requested_port_no_command( + a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-proxyto54321-no-command/ghi", TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -184,7 +202,10 @@ def test_server_proxy_on_requested_port_no_command(): assert direct.code == 200 -def test_server_proxy_port_non_absolute(): +def test_server_proxy_port_non_absolute( + a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/proxy/54321/jkl", TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -193,7 +214,8 @@ def test_server_proxy_port_non_absolute(): assert "X-Proxycontextpath: /proxy/54321\n" in s -def test_server_proxy_port_absolute(): +def test_server_proxy_port_absolute(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/proxy/absolute/54321/nmo", TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -202,7 +224,10 @@ def test_server_proxy_port_absolute(): assert "X-Proxycontextpath" not in s -def test_server_proxy_host_non_absolute(): +def test_server_proxy_host_non_absolute( + a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token # note: localhost: is stripped but 127.0.0.1: is not r = request_get(PORT, "/proxy/127.0.0.1:54321/jkl", TOKEN) assert r.code == 200 @@ -212,7 +237,8 @@ def test_server_proxy_host_non_absolute(): assert "X-Proxycontextpath: /proxy/127.0.0.1:54321\n" in s -def test_server_proxy_host_absolute(): +def test_server_proxy_host_absolute(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/proxy/absolute/127.0.0.1:54321/nmo", TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -221,7 +247,11 @@ def test_server_proxy_host_absolute(): assert "X-Proxycontextpath" not in s -def test_server_proxy_port_non_service_rewrite_response(): +def test_server_proxy_port_non_service_rewrite_response( + a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token + """Test that 'hello' is replaced by 'foo'.""" r = request_get(PORT, "/proxy/54321/hello", TOKEN) assert r.code == 200 @@ -237,7 +267,10 @@ def test_server_proxy_port_non_service_rewrite_response(): ("/pqr?q=2", "/pqr?q=2&token="), ], ) -def test_server_proxy_mappath_dict(requestpath, expected): +def test_server_proxy_mappath_dict( + requestpath, expected, a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-http-mappath" + requestpath, TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -254,7 +287,10 @@ def test_server_proxy_mappath_dict(requestpath, expected): ("/stu?q=2", "/stumapped?q=2&token="), ], ) -def test_server_proxy_mappath_callable(requestpath, expected): +def test_server_proxy_mappath_callable( + requestpath, expected, a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-http-mappathf" + requestpath, TOKEN) assert r.code == 200 s = r.read().decode("ascii") @@ -263,19 +299,24 @@ def test_server_proxy_mappath_callable(requestpath, expected): assert "X-Proxycontextpath: /python-http-mappathf\n" in s -def test_server_proxy_remote(): +def test_server_proxy_remote(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/newproxy", TOKEN, host="127.0.0.1") assert r.code == 200 -def test_server_request_headers(): +def test_server_request_headers(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-request-headers/", TOKEN, host="127.0.0.1") assert r.code == 200 s = r.read().decode("ascii") assert "X-Custom-Header: pytest-23456\n" in s -def test_server_content_encoding_header(): +def test_server_content_encoding_header( + a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, "/python-gzipserver/", TOKEN, host="127.0.0.1") assert r.code == 200 assert r.headers["Content-Encoding"] == "gzip" @@ -290,8 +331,9 @@ def event_loop(): loop.close() -async def _websocket_echo(): - url = f"ws://localhost:{PORT}/python-websocket/echosocket" +async def _websocket_echo(a_server_port_and_token: Tuple[int, str]) -> None: + PORT = a_server_port_and_token[0] + url = f"ws://{LOCALHOST}:{PORT}/python-websocket/echosocket" conn = await websocket_connect(url) expected_msg = "Hello, world!" await conn.write_message(expected_msg) @@ -299,12 +341,15 @@ async def _websocket_echo(): assert msg == expected_msg -def test_server_proxy_websocket(event_loop): - event_loop.run_until_complete(_websocket_echo()) +def test_server_proxy_websocket( + event_loop, a_server_port_and_token: Tuple[int, str] +) -> None: + event_loop.run_until_complete(_websocket_echo(a_server_port_and_token)) -async def _websocket_headers(): - url = f"ws://localhost:{PORT}/python-websocket/headerssocket" +async def _websocket_headers(a_server_port_and_token: Tuple[int, str]) -> None: + PORT = a_server_port_and_token[0] + url = f"ws://{LOCALHOST}:{PORT}/python-websocket/headerssocket" conn = await websocket_connect(url) await conn.write_message("Hello") msg = await conn.read_message() @@ -313,35 +358,43 @@ async def _websocket_headers(): assert headers["X-Custom-Header"] == "pytest-23456" -def test_server_proxy_websocket_headers(event_loop): - event_loop.run_until_complete(_websocket_headers()) +def test_server_proxy_websocket_headers( + event_loop, a_server_port_and_token: Tuple[int, str] +): + event_loop.run_until_complete(_websocket_headers(a_server_port_and_token)) -async def _websocket_subprotocols(): - url = f"ws://localhost:{PORT}/python-websocket/subprotocolsocket" +async def _websocket_subprotocols(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token + url = f"ws://{LOCALHOST}:{PORT}/python-websocket/subprotocolsocket" conn = await websocket_connect(url, subprotocols=["protocol_1", "protocol_2"]) await conn.write_message("Hello, world!") msg = await conn.read_message() assert json.loads(msg) == ["protocol_1", "protocol_2"] -def test_server_proxy_websocket_subprotocols(event_loop): - event_loop.run_until_complete(_websocket_subprotocols()) +def test_server_proxy_websocket_subprotocols( + event_loop, a_server_port_and_token: Tuple[int, str] +): + event_loop.run_until_complete(_websocket_subprotocols(a_server_port_and_token)) @pytest.mark.parametrize( "proxy_path, status", [ - ("127.0.0.1", 404), - ("127.0.0.1/path", 404), - ("127.0.0.1@192.168.1.1", 404), - ("127.0.0.1@192.168.1.1/path", 404), + (LOCALHOST, 404), + (f"{LOCALHOST}/path", 404), + (f"{LOCALHOST}@192.168.1.1", 404), + (f"{LOCALHOST}@192.168.1.1/path", 404), ("user:pass@host:123/foo", 404), ("user:pass@host/foo", 404), - ("absolute/127.0.0.1:123@192.168.1.1/path", 404), + ("absolute/{LOCALHOST}:123@192.168.1.1/path", 404), ], ) -def test_bad_server_proxy_url(proxy_path, status): +def test_bad_server_proxy_url( + proxy_path, status, a_server_port_and_token: Tuple[int, str] +) -> None: + PORT, TOKEN = a_server_port_and_token r = request_get(PORT, f"/proxy/{proxy_path}", TOKEN) assert r.code == status if status >= 400: