Skip to content

Commit 8d6d0cb

Browse files
authored
Add client plugin and echo kernel (#20)
1 parent 9b58f0b commit 8d6d0cb

File tree

9 files changed

+197
-12
lines changed

9 files changed

+197
-12
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
A set of pytest plugins for Jupyter libraries and extensions.
44

5+
[![Build Status](https://github.com/jupyter-server/pytest-jupyter/actions/workflows/test.yml/badge.svg?query=branch%3Amain++)](https://github.com/jupyter-server/pytest-jupyter/actions/workflows/test.yml/badge.svg?query=branch%3Amain++)
6+
[![codecov](https://codecov.io/gh/jupyter-server/pytest-jupyter/branch/main/graph/badge.svg?token=2MY8C1A777)](https://codecov.io/gh/jupyter-server/pytest-jupyter)
7+
58
## Basic Usage
69

710
First, install `pytest-jupyter` from PyPI using pip:

pyproject.toml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ docs = [
3737
"Sphinx",
3838
]
3939
client = [
40-
"jupyter_client>=7.4.0"
40+
"jupyter_client>=7.4.0",
41+
"ipykernel>=6.14",
4142
]
4243
server = [
4344
"jupyter_server>=1.21",
44-
"ipykernel>=6.14",
4545
"nbformat>=5.3",
4646
"pytest-tornasync>=0.6"
4747
]
@@ -54,6 +54,7 @@ Homepage = "http://jupyter.org"
5454

5555
[tool.hatch.version]
5656
path = "pytest_jupyter/_version.py"
57+
validate-bump = false
5758

5859
[tool.hatch.build.targets.sdist]
5960
include = [
@@ -74,23 +75,28 @@ nowarn = "test -W default {args}"
7475

7576
[tool.hatch.envs.cov]
7677
features = ["test", "server", "client"]
77-
dependencies = ["coverage", "pytest-cov"]
78+
dependencies = ["coverage[toml]"]
7879
[tool.hatch.envs.cov.scripts]
79-
test = "python -m pytest -vv --cov pytest_jupyter --cov-branch --cov-report term-missing:skip-covered {args}"
80+
test = "coverage run -m pytest {args}"
8081
nowarn = "test -W default {args}"
8182

83+
[tool.jupyter-releaser.options]
84+
post-version-spec = "dev"
85+
8286
[tool.pytest.ini_options]
8387
addopts = "-raXs --durations 10 --color=yes --doctest-modules"
8488
testpaths = [
8589
"tests"
8690
]
8791
asyncio_mode = "auto"
88-
timeout = 30
92+
timeout = 10
8993
# Restore this setting to debug failures
90-
#timeout_method = "thread"
94+
timeout_method = "thread"
9195
filterwarnings= [
9296
# Fail on warnings
9397
"error",
98+
# TODO: from jupyter_server
99+
"always:unclosed <socket.socket:ResourceWarning",
94100
]
95101

96102
[tool.coverage.report]

pytest_jupyter/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
# Copyright (c) Jupyter Development Team.
22
# Distributed under the terms of the Modified BSD License.
33

4+
import asyncio
5+
import os
6+
47
from .jupyter_core import * # noqa
8+
9+
if os.name == "nt":
10+
asyncio.set_event_loop_policy(
11+
asyncio.WindowsSelectorEventLoopPolicy() # type:ignore[attr-defined]
12+
)

pytest_jupyter/echo_kernel.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
import logging
5+
6+
from ipykernel.kernelapp import IPKernelApp
7+
from ipykernel.kernelbase import Kernel
8+
9+
10+
class EchoKernel(Kernel):
11+
implementation = "Echo"
12+
implementation_version = "1.0"
13+
language = "echo"
14+
language_version = "0.1"
15+
language_info = {
16+
"name": "echo",
17+
"mimetype": "text/plain",
18+
"file_extension": ".txt",
19+
}
20+
banner = "Echo kernel - as useful as a parrot"
21+
22+
def do_execute(
23+
self, code, silent, store_history=True, user_expressions=None, allow_stdin=False
24+
):
25+
if not silent:
26+
stream_content = {"name": "stdout", "text": code}
27+
self.send_response(self.iopub_socket, "stream", stream_content)
28+
29+
# Send a input_request if code contains input command.
30+
if allow_stdin and code and code.find("input(") != -1:
31+
self._input_request(
32+
"Echo Prompt",
33+
self._parent_ident["shell"],
34+
self.get_parent(channel="shell"),
35+
password=False,
36+
)
37+
38+
return {
39+
"status": "ok",
40+
# The base class increments the execution count
41+
"execution_count": self.execution_count,
42+
"payload": [],
43+
"user_expressions": {},
44+
}
45+
46+
47+
class EchoKernelApp(IPKernelApp):
48+
kernel_class = EchoKernel
49+
50+
51+
if __name__ == "__main__":
52+
logging.disable(logging.ERROR)
53+
EchoKernelApp.launch_instance()

pytest_jupyter/jupyter_client.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
import json
5+
import os
6+
import sys
7+
from pathlib import Path
8+
9+
import pytest
10+
from jupyter_core import paths
11+
12+
try:
13+
import ipykernel # noqa
14+
from jupyter_client.manager import start_new_async_kernel
15+
except ImportError:
16+
import warnings
17+
18+
warnings.warn(
19+
"The client plugin has not been installed. "
20+
"If you're trying to use this plugin and you've installed "
21+
"`pytest-jupyter`, there is likely one more step "
22+
"you need. Try: `pip install 'pytest-jupyter[client]'`"
23+
)
24+
25+
try:
26+
import resource
27+
except ImportError:
28+
# Windows
29+
resource = None # type: ignore
30+
31+
32+
# Handle resource limit
33+
# Ensure a minimal soft limit of DEFAULT_SOFT if the current hard limit is at least that much.
34+
if resource is not None:
35+
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
36+
37+
DEFAULT_SOFT = 4096
38+
if hard >= DEFAULT_SOFT:
39+
soft = DEFAULT_SOFT
40+
41+
if hard < soft:
42+
hard = soft
43+
44+
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
45+
46+
47+
@pytest.fixture
48+
def zmq_context():
49+
import zmq
50+
51+
ctx = zmq.asyncio.Context()
52+
yield ctx
53+
ctx.term()
54+
55+
56+
@pytest.fixture
57+
async def start_kernel(kernel_spec):
58+
km = None
59+
kc = None
60+
61+
async def inner(kernel_name="echo", **kwargs):
62+
nonlocal km, kc
63+
km, kc = await start_new_async_kernel(kernel_name=kernel_name, **kwargs)
64+
return km, kc
65+
66+
yield inner
67+
68+
if kc and km:
69+
kc.stop_channels()
70+
await km.shutdown_kernel(now=True)
71+
assert km.context.closed
72+
km.context.destroy()
73+
km.context.term()
74+
75+
76+
@pytest.fixture()
77+
def kernel_dir():
78+
return os.path.join(paths.jupyter_data_dir(), "kernels")
79+
80+
81+
@pytest.fixture
82+
def kernel_spec(kernel_dir):
83+
test_dir = Path(kernel_dir) / "echo"
84+
test_dir.mkdir(parents=True, exist_ok=True)
85+
argv = [sys.executable, "-m", "pytest_jupyter.echo_kernel", "-f", "{connection_file}"]
86+
kernel_data = {"argv": argv, "display_name": "echo", "language": "echo"}
87+
spec_file_path = Path(test_dir / "kernel.json")
88+
spec_file_path.write_text(json.dumps(kernel_data), "utf8")
89+
return str(test_dir)

pytest_jupyter/jupyter_server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
)
4646

4747

48+
from .jupyter_client import kernel_spec # noqa
4849
from .utils import mkdir
4950

5051
# List of dependencies needed for this plugin.
@@ -190,6 +191,7 @@ def jp_configurable_serverapp(
190191
jp_logging_stream,
191192
asyncio_loop,
192193
io_loop,
194+
kernel_spec, # noqa
193195
):
194196
"""Starts a Jupyter Server instance based on
195197
the provided configuration values.
@@ -249,7 +251,6 @@ def _configurable_serverapp(
249251
allow_root=True,
250252
**kwargs,
251253
)
252-
253254
app.init_signal = lambda: None
254255
app.log.propagate = True
255256
app.log.handlers = []
@@ -410,6 +411,8 @@ def jp_server_cleanup(asyncio_loop):
410411
asyncio_loop.run_until_complete(app._cleanup())
411412
except (RuntimeError, SystemExit) as e:
412413
print("ignoring cleanup error", e)
414+
if hasattr(app, "kernel_manager"):
415+
app.kernel_manager.context.destroy()
413416
ServerApp.clear_instance()
414417

415418

tests/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import os
22

33
os.environ["JUPYTER_PLATFORM_DIRS"] = "1"
4-
pytest_plugins = ["pytest_jupyter", "pytest_jupyter.jupyter_server"]
4+
pytest_plugins = [
5+
"pytest_jupyter",
6+
"pytest_jupyter.jupyter_server",
7+
"pytest_jupyter.jupyter_client",
8+
]

tests/test_jupyter_client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from unittest.mock import Mock
2+
3+
from jupyter_client.session import Session
4+
5+
from pytest_jupyter.echo_kernel import EchoKernel
6+
7+
8+
def test_zmq_context(zmq_context):
9+
assert isinstance(zmq_context.underlying, int)
10+
11+
12+
async def test_start_kernel(start_kernel):
13+
km, kc = await start_kernel()
14+
assert km.kernel_name == "echo"
15+
msg = await kc.execute("hello", reply=True)
16+
assert msg["content"]["status"] == "ok"
17+
18+
19+
def test_echo_kernel():
20+
kernel = EchoKernel()
21+
kernel.session = Mock(spec=Session)
22+
kernel.do_execute("foo", False)

tests/test_jupyter_server.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import os
33
from unittest.mock import MagicMock
44

5-
from jupyter_client.kernelspec import NATIVE_KERNEL_NAME
65
from jupyter_server.auth import Authorizer
76
from jupyter_server.serverapp import ServerApp
87
from tornado.websocket import WebSocketHandler
@@ -24,9 +23,7 @@ async def test_send_request(send_request):
2423

2524
async def test_connection(jp_fetch, jp_ws_fetch, jp_http_port, jp_auth_header):
2625
# Create kernel
27-
r = await jp_fetch(
28-
"api", "kernels", method="POST", body=json.dumps({"name": NATIVE_KERNEL_NAME})
29-
)
26+
r = await jp_fetch("api", "kernels", method="POST", body=json.dumps({"name": "echo"}))
3027
kid = json.loads(r.body.decode())["id"]
3128

3229
# Get kernel info

0 commit comments

Comments
 (0)