Skip to content

Commit b76c290

Browse files
committed
misc fixes
- Make server extension authenticated - Try to determine if running with adjacent notebook server, and if not, spawn static file server from which to provide web modules. This will probably just be used by VSCode. - rename _jupyter_server_base_url to _import_source_base_url to better indicate that this is a manually specified location where web modules are served rather than a jupyter server (though it might be). - bump model and view versions for everything.
1 parent d3fdaf5 commit b76c290

File tree

8 files changed

+160
-26
lines changed

8 files changed

+160
-26
lines changed

idom_jupyter/__init__.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from ._version import __version__ # noqa
33

44
from . import jupyter_server_extension
5-
from .widget import LayoutWidget, widgetize, run, set_jupyter_server_base_url
5+
from .import_resources import setup_import_resources
6+
from .widget import LayoutWidget, widgetize, run, set_import_source_base_url
67
from .ipython_extension import load_ipython_extension, unload_ipython_extension
78

89

@@ -12,11 +13,14 @@
1213
"run",
1314
"load_ipython_extension",
1415
"unload_ipython_extension",
15-
"set_jupyter_server_base_url",
16+
"set_import_source_base_url",
1617
"jupyter_server_extension",
1718
]
1819

1920

21+
setup_import_resources()
22+
23+
2024
def _jupyter_labextension_paths():
2125
"""Called by Jupyter Lab Server to detect if it is a valid labextension and
2226
to install the widget

idom_jupyter/_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Module version
2-
version_info = (0, 6, 5, "final", 0)
2+
version_info = (0, 7, 0, "final", 0)
33

44
# Module version stage suffix map
55
_specifier_ = {"alpha": "a", "beta": "b", "candidate": "rc", "final": ""}

idom_jupyter/import_resources.py

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import logging
2+
import socket
3+
from uuid import uuid4
4+
from contextlib import closing
5+
from multiprocessing import Process
6+
from http.server import SimpleHTTPRequestHandler, HTTPServer
7+
8+
import requests
9+
from notebook import notebookapp
10+
11+
from .jupyter_server_extension import IDOM_RESOURCE_BASE_PATH, IDOM_WEB_MODULES_DIR
12+
from .widget import set_import_source_base_url
13+
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def setup_import_resources():
19+
if _try_to_set_import_source_base_url():
20+
return None
21+
22+
host = "127.0.0.1"
23+
port = _find_available_port("127.0.0.1")
24+
25+
logger.debug(
26+
f"Serving web modules via local static file server at http://{host}:{port}/"
27+
)
28+
serve_dir = str(IDOM_WEB_MODULES_DIR.current)
29+
30+
proc = Process(
31+
target=lambda: _run_simple_static_file_server(host, port, serve_dir),
32+
daemon=True,
33+
)
34+
proc.start()
35+
36+
37+
def _try_to_set_import_source_base_url() -> bool:
38+
# Try to see if there's a local server we should use. This might happen when running
39+
# in a notebook from within VSCode
40+
_temp_file_name = f"__temp_{uuid4().hex}__"
41+
_temp_file = IDOM_WEB_MODULES_DIR.current / _temp_file_name
42+
_temp_file.touch()
43+
for _server_info in notebookapp.list_running_servers():
44+
if _server_info["hostname"] not in ("localhost", "127.0.0.1"):
45+
continue
46+
47+
_resource_url_parts = [
48+
_server_info["url"].rstrip("/"),
49+
_server_info["base_url"].strip("/"),
50+
IDOM_RESOURCE_BASE_PATH,
51+
]
52+
_resource_url = "/".join(filter(None, _resource_url_parts))
53+
_temp_file_url = _resource_url + "/" + _temp_file_name
54+
55+
response = requests.get(_temp_file_url, params={"token": _server_info["token"]})
56+
57+
if response.status_code == 200:
58+
set_import_source_base_url(_resource_url)
59+
logger.debug(
60+
f"Serving web modules via existing NotebookApp server at {_resource_url!r}"
61+
)
62+
return True
63+
_temp_file.unlink()
64+
return False
65+
66+
67+
def _run_simple_static_file_server(host, port, directory):
68+
class CORSRequestHandler(SimpleHTTPRequestHandler):
69+
def end_headers(self):
70+
self.send_header("Access-Control-Allow-Origin", "*")
71+
SimpleHTTPRequestHandler.end_headers(self)
72+
73+
def log_message(self, format, *args):
74+
logger.info(
75+
"%s - - [%s] %s\n"
76+
% (self.address_string(), self.log_date_time_string(), format % args)
77+
)
78+
79+
def make_cors_handler(*args, **kwargs):
80+
return CORSRequestHandler(*args, directory=directory, **kwargs)
81+
82+
with HTTPServer((host, port), make_cors_handler) as httpd:
83+
httpd.serve_forever()
84+
85+
86+
def _find_available_port(
87+
host: str,
88+
port_min: int = 8000,
89+
port_max: int = 9000,
90+
allow_reuse_waiting_ports: bool = True,
91+
) -> int:
92+
"""Get a port that's available for the given host and port range"""
93+
for port in range(port_min, port_max):
94+
with closing(socket.socket()) as sock:
95+
try:
96+
if allow_reuse_waiting_ports:
97+
# As per this answer: https://stackoverflow.com/a/19247688/3159288
98+
# setting can be somewhat unreliable because we allow the use of
99+
# ports that are stuck in TIME_WAIT. However, not setting the option
100+
# means we're overly cautious and almost always use a different addr
101+
# even if it could have actually been used.
102+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
103+
sock.bind((host, port))
104+
except OSError:
105+
pass
106+
else:
107+
return port
108+
raise RuntimeError(
109+
f"Host {host!r} has no available port in range {port_max}-{port_max}"
110+
)

idom_jupyter/jupyter_server_extension.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,33 @@
1+
from typing import Any
12
from urllib.parse import urljoin
23

34
from appdirs import user_data_dir
45
from notebook.notebookapp import NotebookApp
5-
from idom.config import IDOM_WED_MODULES_DIR
6-
from tornado.web import StaticFileHandler
6+
from notebook.base.handlers import AuthenticatedFileHandler
7+
78
from tornado.web import Application
89

10+
try:
11+
from idom.config import IDOM_WEB_MODULES_DIR
12+
except ImportError:
13+
from idom.config import IDOM_WED_MODULES_DIR as IDOM_WEB_MODULES_DIR
14+
915

10-
IDOM_WED_MODULES_DIR.current = user_data_dir("idom-jupyter", "idom-team")
16+
IDOM_WEB_MODULES_DIR.current = user_data_dir("idom-jupyter", "idom-team")
17+
IDOM_RESOURCE_BASE_PATH = "_idom_web_modules"
1118

1219

1320
def _load_jupyter_server_extension(notebook_app: NotebookApp):
1421
web_app: Application = notebook_app.web_app
1522
base_url = web_app.settings["base_url"]
16-
route_pattern = urljoin(base_url, rf"_idom_web_modules/(.*)")
23+
route_pattern = urljoin(base_url, rf"{IDOM_RESOURCE_BASE_PATH}/(.*)")
1724
web_app.add_handlers(
1825
host_pattern=r".*$",
1926
host_handlers=[
2027
(
2128
route_pattern,
22-
StaticFileHandler,
23-
{"path": str(IDOM_WED_MODULES_DIR.current.absolute())},
29+
AuthenticatedFileHandler,
30+
{"path": str(IDOM_WEB_MODULES_DIR.current.absolute())},
2431
),
2532
],
2633
)

idom_jupyter/widget.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
from idom.core.dispatcher import VdomJsonPatch, render_json_patch
1414

1515

16-
_JUPYTER_SERVER_BASE_URL = ""
16+
_IMPORT_SOURCE_BASE_URL = ""
1717

1818

19-
def set_jupyter_server_base_url(base_url):
20-
global _JUPYTER_SERVER_BASE_URL
21-
_JUPYTER_SERVER_BASE_URL = base_url
19+
def set_import_source_base_url(base_url):
20+
"""Fallback URL for import sources, if no Jupyter Server is discovered by the client"""
21+
global _IMPORT_SOURCE_BASE_URL
22+
_IMPORT_SOURCE_BASE_URL = base_url
2223

2324

2425
def run(constructor):
@@ -56,14 +57,14 @@ class LayoutWidget(widgets.DOMWidget):
5657
_model_module = Unicode("idom-client-jupyter").tag(sync=True)
5758

5859
# Version of the front-end module containing widget view
59-
_view_module_version = Unicode("^0.4.0").tag(sync=True)
60+
_view_module_version = Unicode("^0.7.0").tag(sync=True)
6061
# Version of the front-end module containing widget model
61-
_model_module_version = Unicode("^0.4.0").tag(sync=True)
62+
_model_module_version = Unicode("^0.7.0").tag(sync=True)
6263

63-
_jupyter_server_base_url = Unicode().tag(sync=True)
64+
_import_source_base_url = Unicode().tag(sync=True)
6465

6566
def __init__(self, component: ComponentType):
66-
super().__init__(_jupyter_server_base_url=_JUPYTER_SERVER_BASE_URL)
67+
super().__init__(_import_source_base_url=_IMPORT_SOURCE_BASE_URL)
6768
self._idom_model = {}
6869
self._idom_views = set()
6970
self._idom_layout = Layout(component)

js/lib/widget.js

+19-7
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ var IdomModel = widgets.DOMWidgetModel.extend({
88
_view_name: "IdomView",
99
_model_module: "idom-client-jupyter",
1010
_view_module: "idom-client-jupyter",
11-
_model_module_version: "0.4.0",
12-
_view_module_version: "0.4.0",
11+
_model_module_version: "0.7.0",
12+
_view_module_version: "0.7.0",
1313
}),
1414
});
1515

@@ -55,12 +55,24 @@ class IdomView extends widgets.DOMWidgetView {
5555
});
5656
};
5757

58-
const importSourceBaseUrl = concatAndResolveUrl(
59-
this.model.attributes._jupyter_server_base_url || jupyterServerBaseUrl,
60-
"_idom_web_modules"
61-
);
58+
let importSourceBaseUrl;
59+
if (jupyterServerBaseUrl) {
60+
importSourceBaseUrl = concatAndResolveUrl(
61+
jupyterServerBaseUrl,
62+
"_idom_web_modules"
63+
);
64+
} else {
65+
importSourceBaseUrl = this.model.attributes._import_source_base_url;
66+
}
67+
if (!importSourceBaseUrl) {
68+
console.error(
69+
"No Jupyter Server base URL could be discovered and no import source base URL was configured."
70+
);
71+
}
72+
6273
var loadImportSource = (source, sourceType) => {
63-
return import( /* webpackIgnore: true */
74+
return import(
75+
/* webpackIgnore: true */
6476
sourceType == "NAME" ? `${importSourceBaseUrl}/${source}` : source
6577
);
6678
};

js/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "idom-client-jupyter",
3-
"version": "0.6.5",
3+
"version": "0.7.0",
44
"description": "A client for IDOM implemented using Jupyter widgets",
55
"author": "Ryan Morshead",
66
"main": "lib/index.js",

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
description="A client for IDOM implemented using Jupyter widgets",
6868
long_description=LONG_DESCRIPTION,
6969
include_package_data=True,
70-
install_requires=["ipywidgets>=7.6.0", "idom>=0.36.0,<0.37", "appdirs"],
70+
install_requires=["ipywidgets>=7.6.0", "idom>=0.36.0,<0.37", "appdirs", "requests"],
7171
packages=find_packages(),
7272
zip_safe=False,
7373
cmdclass=cmdclass,

0 commit comments

Comments
 (0)