Skip to content

Commit 8f6a182

Browse files
authored
Merge pull request #1 from kevin-bates/server-core-plugins
Add initial plugins for core and server
2 parents 60896b3 + c00c579 commit 8f6a182

File tree

7 files changed

+405
-0
lines changed

7 files changed

+405
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
__pycache__/
33
*.py[cod]
44
*$py.class
5+
*.vscode
56

67
# C extensions
78
*.so
@@ -114,6 +115,9 @@ venv.bak/
114115
.spyderproject
115116
.spyproject
116117

118+
# Pycharm settings
119+
.idea/
120+
117121
# Rope project settings
118122
.ropeproject
119123

pytest_jupyter/__init__.py

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

pytest_jupyter/_version.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
__version__ = "0.0.1"
5+

pytest_jupyter/jupyter_core.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
import jupyter_core.paths
5+
import os
6+
import pytest
7+
import sys
8+
9+
from .utils import mkdir
10+
11+
12+
@pytest.fixture
13+
def jp_home_dir(tmp_path):
14+
"""Provides a temporary HOME directory value."""
15+
return mkdir(tmp_path, "home")
16+
17+
18+
@pytest.fixture
19+
def jp_data_dir(tmp_path):
20+
"""Provides a temporary Jupyter data dir directory value."""
21+
return mkdir(tmp_path, "data")
22+
23+
24+
@pytest.fixture
25+
def jp_config_dir(tmp_path):
26+
"""Provides a temporary Jupyter config dir directory value."""
27+
return mkdir(tmp_path, "config")
28+
29+
30+
@pytest.fixture
31+
def jp_runtime_dir(tmp_path):
32+
"""Provides a temporary Jupyter runtime dir directory value."""
33+
return mkdir(tmp_path, "runtime")
34+
35+
36+
@pytest.fixture
37+
def jp_system_jupyter_path(tmp_path):
38+
"""Provides a temporary Jupyter system path value."""
39+
return mkdir(tmp_path, "share", "jupyter")
40+
41+
42+
@pytest.fixture
43+
def jp_env_jupyter_path(tmp_path):
44+
"""Provides a temporary Jupyter env system path value."""
45+
return mkdir(tmp_path, "env", "share", "jupyter")
46+
47+
48+
@pytest.fixture
49+
def jp_system_config_path(tmp_path):
50+
"""Provides a temporary Jupyter config path value."""
51+
return mkdir(tmp_path, "etc", "jupyter")
52+
53+
54+
@pytest.fixture
55+
def jp_env_config_path(tmp_path):
56+
"""Provides a temporary Jupyter env config path value."""
57+
return mkdir(tmp_path, "env", "etc", "jupyter")
58+
59+
60+
@pytest.fixture
61+
def jp_environ(
62+
monkeypatch,
63+
tmp_path,
64+
jp_home_dir,
65+
jp_data_dir,
66+
jp_config_dir,
67+
jp_runtime_dir,
68+
jp_system_jupyter_path,
69+
jp_system_config_path,
70+
jp_env_jupyter_path,
71+
jp_env_config_path,
72+
):
73+
"""Configures a temporary environment based on Jupyter-specific environment variables. """
74+
monkeypatch.setenv("HOME", str(jp_home_dir))
75+
monkeypatch.setenv("PYTHONPATH", os.pathsep.join(sys.path))
76+
# monkeypatch.setenv("JUPYTER_NO_CONFIG", "1")
77+
monkeypatch.setenv("JUPYTER_CONFIG_DIR", str(jp_config_dir))
78+
monkeypatch.setenv("JUPYTER_DATA_DIR", str(jp_data_dir))
79+
monkeypatch.setenv("JUPYTER_RUNTIME_DIR", str(jp_runtime_dir))
80+
monkeypatch.setattr(
81+
jupyter_core.paths, "SYSTEM_JUPYTER_PATH", [str(jp_system_jupyter_path)]
82+
)
83+
monkeypatch.setattr(jupyter_core.paths, "ENV_JUPYTER_PATH", [str(jp_env_jupyter_path)])
84+
monkeypatch.setattr(
85+
jupyter_core.paths, "SYSTEM_CONFIG_PATH", [str(jp_system_config_path)]
86+
)
87+
monkeypatch.setattr(jupyter_core.paths, "ENV_CONFIG_PATH", [str(jp_env_config_path)])

pytest_jupyter/jupyter_server.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
import jupyter_core.paths
5+
import nbformat
6+
import os
7+
import json
8+
import pytest
9+
import shutil
10+
import tornado
11+
import urllib.parse
12+
13+
from binascii import hexlify
14+
from jupyter_server.extension import serverextension
15+
from jupyter_server.serverapp import ServerApp
16+
from jupyter_server.utils import url_path_join
17+
from jupyter_server.services.contents.filemanager import FileContentsManager
18+
from tornado.escape import url_escape
19+
from traitlets.config import Config
20+
21+
from .utils import mkdir
22+
23+
# NOTE: This is a temporary fix for Windows 3.8
24+
# We have to override the io_loop fixture with an
25+
# asyncio patch. This will probably be removed in
26+
# the future.
27+
@pytest.fixture
28+
def jp_asyncio_patch():
29+
"""Appropriately configures the event loop policy if running on Windows w/ Python >= 3.8."""
30+
ServerApp()._init_asyncio_patch()
31+
32+
33+
@pytest.fixture
34+
def io_loop(jp_asyncio_patch):
35+
"""Returns an ioloop instance that includes the asyncio patch for Windows 3.8 platforms."""
36+
loop = tornado.ioloop.IOLoop()
37+
loop.make_current()
38+
yield loop
39+
loop.clear_current()
40+
loop.close(all_fds=True)
41+
42+
43+
@pytest.fixture
44+
def jp_server_config():
45+
"""Allows tests to setup their specific configuration values. """
46+
return {}
47+
48+
49+
@pytest.fixture
50+
def jp_root_dir(tmp_path):
51+
"""Provides a temporary Jupyter root directory value."""
52+
return mkdir(tmp_path, "root_dir")
53+
54+
55+
@pytest.fixture
56+
def jp_template_dir(tmp_path):
57+
"""Provides a temporary Jupyter templates directory value."""
58+
return mkdir(tmp_path, "templates")
59+
60+
61+
@pytest.fixture
62+
def jp_argv():
63+
"""Allows tests to setup specific argv values. """
64+
return []
65+
66+
67+
@pytest.fixture
68+
def jp_extension_environ(jp_env_config_path, monkeypatch):
69+
"""Monkeypatch a Jupyter Extension's config path into each test's environment variable"""
70+
monkeypatch.setattr(serverextension, "ENV_CONFIG_PATH", [str(jp_env_config_path)])
71+
72+
73+
@pytest.fixture
74+
def jp_http_port(http_server_port):
75+
"""Returns the port value from the http_server_port fixture. """
76+
return http_server_port[-1]
77+
78+
79+
@pytest.fixture
80+
def jp_nbconvert_templates(jp_data_dir):
81+
"""Setups up a temporary directory consisting of the nbconvert templates."""
82+
83+
# Get path to nbconvert template directory *before*
84+
# monkeypatching the paths env variable via the jp_environ fixture.
85+
possible_paths = jupyter_core.paths.jupyter_path('nbconvert', 'templates')
86+
nbconvert_path = None
87+
for path in possible_paths:
88+
if os.path.exists(path):
89+
nbconvert_path = path
90+
break
91+
92+
nbconvert_target = jp_data_dir / 'nbconvert' / 'templates'
93+
94+
# copy nbconvert templates to new tmp data_dir.
95+
if nbconvert_path:
96+
shutil.copytree(nbconvert_path, str(nbconvert_target))
97+
98+
99+
@pytest.fixture(scope='function')
100+
def jp_configurable_serverapp(
101+
jp_nbconvert_templates, # this fixture must preceed jp_environ
102+
jp_environ,
103+
jp_server_config,
104+
jp_argv,
105+
jp_http_port,
106+
tmp_path,
107+
jp_root_dir,
108+
io_loop,
109+
):
110+
"""Starts a Jupyter Server instance based on the provided configuration values."""
111+
ServerApp.clear_instance()
112+
113+
def _configurable_serverapp(
114+
config=jp_server_config,
115+
argv=jp_argv,
116+
environ=jp_environ,
117+
http_port=jp_http_port,
118+
tmp_path=tmp_path,
119+
root_dir=jp_root_dir,
120+
**kwargs
121+
):
122+
c = Config(config)
123+
c.NotebookNotary.db_file = ":memory:"
124+
token = hexlify(os.urandom(4)).decode("ascii")
125+
url_prefix = "/"
126+
app = ServerApp.instance(
127+
# Set the log level to debug for testing purposes
128+
log_level='DEBUG',
129+
port=http_port,
130+
port_retries=0,
131+
open_browser=False,
132+
root_dir=str(root_dir),
133+
base_url=url_prefix,
134+
config=c,
135+
allow_root=True,
136+
token=token,
137+
**kwargs
138+
)
139+
140+
app.init_signal = lambda: None
141+
app.log.propagate = True
142+
app.log.handlers = []
143+
# Initialize app without httpserver
144+
app.initialize(argv=argv, new_httpserver=False)
145+
app.log.propagate = True
146+
app.log.handlers = []
147+
# Start app without ioloop
148+
app.start_app()
149+
return app
150+
151+
return _configurable_serverapp
152+
153+
154+
@pytest.fixture(scope="function")
155+
def jp_serverapp(jp_server_config, jp_argv, jp_configurable_serverapp):
156+
"""Starts a Jupyter Server instance based on the established configuration values."""
157+
app = jp_configurable_serverapp(config=jp_server_config, argv=jp_argv)
158+
yield app
159+
app.remove_server_info_file()
160+
app.remove_browser_open_file()
161+
app.cleanup_kernels()
162+
163+
164+
@pytest.fixture
165+
def app(jp_serverapp):
166+
"""app fixture is needed by pytest_tornasync plugin"""
167+
return jp_serverapp.web_app
168+
169+
170+
@pytest.fixture
171+
def jp_auth_header(jp_serverapp):
172+
"""Configures an authorization header using the token from the serverapp fixture."""
173+
return {"Authorization": "token {token}".format(token=jp_serverapp.token)}
174+
175+
176+
@pytest.fixture
177+
def jp_base_url():
178+
"""Returns the base url to use for the test."""
179+
return "/"
180+
181+
182+
@pytest.fixture
183+
def jp_fetch(http_server_client, jp_auth_header, jp_base_url):
184+
"""Performs an HTTP request against the test server."""
185+
def client_fetch(*parts, headers={}, params={}, **kwargs):
186+
# Handle URL strings
187+
path_url = url_escape(url_path_join(jp_base_url, *parts), plus=False)
188+
params_url = urllib.parse.urlencode(params)
189+
url = path_url + "?" + params_url
190+
# Add auth keys to header
191+
headers.update(jp_auth_header)
192+
# Make request.
193+
return http_server_client.fetch(
194+
url, headers=headers, request_timeout=20, **kwargs
195+
)
196+
return client_fetch
197+
198+
199+
@pytest.fixture
200+
def jp_ws_fetch(jp_auth_header, jp_http_port):
201+
"""Performs a websocket request against the test server."""
202+
def client_fetch(*parts, headers={}, params={}, **kwargs):
203+
# Handle URL strings
204+
path = url_escape(url_path_join(*parts), plus=False)
205+
urlparts = urllib.parse.urlparse('ws://localhost:{}'.format(jp_http_port))
206+
urlparts = urlparts._replace(
207+
path=path,
208+
query=urllib.parse.urlencode(params)
209+
)
210+
url = urlparts.geturl()
211+
# Add auth keys to header
212+
headers.update(jp_auth_header)
213+
# Make request.
214+
req = tornado.httpclient.HTTPRequest(
215+
url,
216+
headers=jp_auth_header,
217+
connect_timeout=120
218+
)
219+
return tornado.websocket.websocket_connect(req)
220+
return client_fetch
221+
222+
223+
some_resource = u"The very model of a modern major general"
224+
sample_kernel_json = {
225+
'argv':['cat', '{connection_file}'],
226+
'display_name': 'Test kernel',
227+
}
228+
@pytest.fixture
229+
def jp_kernelspecs(jp_data_dir):
230+
"""Configures some sample kernelspecs in the Jupyter data directory."""
231+
spec_names = ['sample', 'sample 2']
232+
for name in spec_names:
233+
sample_kernel_dir = jp_data_dir.joinpath('kernels', name)
234+
sample_kernel_dir.mkdir(parents=True)
235+
# Create kernel json file
236+
sample_kernel_file = sample_kernel_dir.joinpath('kernel.json')
237+
sample_kernel_file.write_text(json.dumps(sample_kernel_json))
238+
# Create resources text
239+
sample_kernel_resources = sample_kernel_dir.joinpath('resource.txt')
240+
sample_kernel_resources.write_text(some_resource)
241+
242+
243+
@pytest.fixture(params=[True, False])
244+
def jp_contents_manager(request, jp_root_dir):
245+
"""Returns a FileContentsManager instance based on the use_atomic_writing parameter value."""
246+
return FileContentsManager(root_dir=jp_root_dir, use_atomic_writing=request.param)
247+
248+
249+
@pytest.fixture
250+
def jp_create_notebook(jp_root_dir):
251+
"""Creates a notebook in the test's home directory."""
252+
def inner(nbpath):
253+
nbpath = jp_root_dir.joinpath(nbpath)
254+
# Check that the notebook has the correct file extension.
255+
if nbpath.suffix != '.ipynb':
256+
raise Exception("File extension for notebook must be .ipynb")
257+
# If the notebook path has a parent directory, make sure it's created.
258+
parent = nbpath.parent
259+
parent.mkdir(parents=True, exist_ok=True)
260+
# Create a notebook string and write to file.
261+
nb = nbformat.v4.new_notebook()
262+
nbtext = nbformat.writes(nb, version=4)
263+
nbpath.write_text(nbtext)
264+
return inner

pytest_jupyter/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
5+
def mkdir(tmp_path, *parts):
6+
path = tmp_path.joinpath(*parts)
7+
if not path.exists():
8+
path.mkdir(parents=True)
9+
return path

0 commit comments

Comments
 (0)