Skip to content

Make ServerProcess and LauncherEntry a HasTraits, update CLI flags for consistency #521

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/source/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jupyter standaloneproxy --address=localhost --port=8000 ...

### Disable Authentication

For testing, it can be useful to disable the authentication with JupyterHub. Passing `--skip-authentication` will
For testing, it can be useful to disable the authentication with JupyterHub. Passing `--no-authentication` will
not trigger the login process when accessing the application.

```{warning} Disabling authentication will leave the application open to anyone! Be careful with it,
Expand All @@ -76,7 +76,7 @@ c.StandaloneProxyServer.address = "localhost"
c.StandaloneProxyServer.port = 8000

# Disable authentication
c.StandaloneProxyServer.skip_authentication = True
c.StandaloneProxyServer.no_authentication = True
```

A default config file can be emitted by running `jupyter standaloneproxy --generate-config`
Expand Down
9 changes: 3 additions & 6 deletions jupyter_server_proxy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from ._version import __version__ # noqa
from .api import IconHandler, ServersInfoHandler
from .config import ServerProxy as ServerProxyConfig
from .config import get_entrypoint_server_processes, make_handlers, make_server_process
from .config import get_entrypoint_server_processes, make_handlers
from .handlers import setup_handlers


Expand Down Expand Up @@ -41,11 +41,8 @@ def _load_jupyter_server_extension(nbapp):
base_url = nbapp.web_app.settings["base_url"]
serverproxy_config = ServerProxyConfig(parent=nbapp)

server_processes = [
make_server_process(name, server_process_config, serverproxy_config)
for name, server_process_config in serverproxy_config.servers.items()
]
server_processes += get_entrypoint_server_processes(serverproxy_config)
server_processes = list(serverproxy_config.servers.values())
server_processes += get_entrypoint_server_processes()
server_handlers = make_handlers(base_url, server_processes)
nbapp.web_app.add_handlers(".*", server_handlers)

Expand Down
64 changes: 43 additions & 21 deletions jupyter_server_proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Callable,
Dict,
Float,
HasTraits,
Instance,
Int,
List,
Expand All @@ -35,7 +36,7 @@
from .rawsocket import RawSocketHandler, SuperviseAndRawSocketHandler


class LauncherEntry(Configurable):
class LauncherEntry(HasTraits):
enabled = Bool(
True,
help="""
Expand Down Expand Up @@ -76,7 +77,7 @@ class LauncherEntry(Configurable):


class ServerProcess(Configurable):
name = Unicode(help="Name of the server").tag(config=True)
name = Unicode(help="Name of the server")

command = Union(
[List(Unicode()), Callable()],
Expand All @@ -92,7 +93,7 @@ class ServerProcess(Configurable):
process is assumed to be started ahead of time and already available
to be proxied to.
""",
).tag(config=True)
)

environment = Union(
[Dict(Unicode()), Callable()],
Expand All @@ -115,14 +116,14 @@ class ServerProcess(Configurable):
Proxy requests default to being rewritten to ``/``. If this is True,
the absolute URL will be sent to the backend instead.
""",
).tag(config=True)
)

port = Int(
0,
help="""
Set the port that the service will listen on. The default is to automatically select an unused port.
""",
).tag(config=True)
)

unix_socket = Union(
[Bool(False), Unicode()],
Expand All @@ -135,7 +136,7 @@ class ServerProcess(Configurable):

Proxying websockets over a Unix socket requires Tornado >= 6.3.
""",
).tag(config=True)
)

mappath = Union(
[Dict(Unicode()), Callable()],
Expand All @@ -145,15 +146,15 @@ class ServerProcess(Configurable):
Either a dictionary of request paths to proxied paths,
or a callable that takes parameter ``path`` and returns the proxied path.
""",
).tag(config=True)
)

launcher_entry = Union(
[Instance(LauncherEntry), Dict()],
allow_none=False,
help="""
A dictionary of various options for entries in classic notebook / jupyterlab launchers.
Specify various options for entries in classic notebook / jupyterlab launchers.

Keys recognized are:
Must be an instance of ``LauncherEntry`` or a dictionary with the following keys:

``enabled``
Set to True (default) to make an entry in the launchers. Set to False to have no
Expand All @@ -174,13 +175,18 @@ class ServerProcess(Configurable):
The category for the launcher item. Currently only used by the JupyterLab launcher.
By default it is "Notebook".
""",
).tag(config=True)
)

@validate("launcher_entry")
def _validate_launcher_entry(self, proposal):
kwargs = {"title": self.name, "path_info": self.name + "/"}
kwargs.update(proposal["value"])
return LauncherEntry(**kwargs)
if isinstance(proposal["value"], LauncherEntry):
proposal["value"].title = self.name
proposal["value"].path_info = self.name + "/"
return proposal["value"]
else:
kwargs = {"title": self.name, "path_info": self.name + "/"}
kwargs.update(proposal["value"])
return LauncherEntry(**kwargs)

@default("launcher_entry")
def _default_launcher_entry(self):
Expand All @@ -201,7 +207,7 @@ def _default_launcher_entry(self):
A dictionary of additional HTTP headers for the proxy request. As with
the command traitlet, ``{port}``, ``{unix_socket}`` and ``{base_url}`` will be substituted.
""",
).tag(config=True)
)

rewrite_response = Union(
[Callable(), List(Callable())],
Expand Down Expand Up @@ -260,7 +266,7 @@ def cats_only(response, path):
similar to running a websockify layer (https://github.com/novnc/websockify).
All other HTTP requests return 405 (and thus this will also bypass rewrite_response).
""",
).tag(config=True)
)

def get_proxy_base_class(self) -> tuple[type | None, dict]:
"""
Expand Down Expand Up @@ -341,17 +347,17 @@ def get_timeout(self):
return _Proxy, proxy_kwargs


def get_entrypoint_server_processes(serverproxy_config):
sps = []
def get_entrypoint_server_processes():
processes = []
for entry_point in entry_points(group="jupyter_serverproxy_servers"):
name = entry_point.name
try:
server_process_config = entry_point.load()()
except Exception as e:
warn(f"entry_point {name} was unable to be loaded: {str(e)}")
continue
sps.append(make_server_process(name, server_process_config, serverproxy_config))
return sps
processes.append(ServerProcess(name=name, **server_process_config))
return processes


def make_handlers(base_url: str, server_processes: list[ServerProcess]):
Expand Down Expand Up @@ -384,20 +390,36 @@ def _serverproxy_servers_help():

class ServerProxy(Configurable):
servers = Dict(
{},
key_trait=Unicode(),
value_trait=Union([Dict(), Instance(ServerProcess)]),
help="""
Dictionary of processes to supervise & proxy.

Key should be the name of the process. This is also used by default as
the URL prefix, and all requests matching this prefix are routed to this process.

Value should be a dictionary with the following keys:
Value should be an instance of ``ServerProcess`` or a dictionary with the following keys:

"""
+ indent(_serverproxy_servers_help(), " "),
config=True,
)

@validate("servers")
def _validate_servers(self, proposal):
servers = {}

for name, server_process in proposal["value"].items():
if isinstance(server_process, ServerProcess):
server_process.name = server_process.name or name
servers[name] = server_process
else:
kwargs = {"name": name}
kwargs.update(**server_process)
servers[name] = ServerProcess(**kwargs)

return servers

non_service_rewrite_response = Union(
default_value=tuple(),
trait_types=[List(), Tuple(), Callable()],
Expand Down
27 changes: 16 additions & 11 deletions jupyter_server_proxy/standalone/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def _validate_prefix(self, proposal):
prefix = prefix[:-1]
return prefix

skip_authentication = Bool(
no_authentication = Bool(
default=False,
help="""
Do not authenticate access to the server via JupyterHub. When set,
Expand Down Expand Up @@ -146,11 +146,16 @@ def __init__(self, **kwargs):
{"ServerProcess": {"raw_socket_proxy": True}},
dedent(ServerProcess.raw_socket_proxy.help),
),
"skip-authentication": (
{"StandaloneProxyServer": {"skip_authentication": True}},
dedent(self.__class__.skip_authentication.help),
"no-authentication": (
{"StandaloneProxyServer": {"no_authentication": True}},
dedent(self.__class__.no_authentication.help),
),
}
self.flags.pop("y")

# Some traits in ServerProcess are not defined to be configurable, but we need that for the standalone proxy
for name, trait in ServerProcess.class_own_traits().items():
trait.tag(config=True)

# Create an Alias to all Traits defined in ServerProcess, with some
# exceptions we do not need, for easier use of the CLI
Expand All @@ -164,20 +169,20 @@ def __init__(self, **kwargs):
"command",
]
server_process_aliases = {
trait: f"StandaloneProxyServer.{trait}"
trait.replace("_", "-"): f"StandaloneProxyServer.{trait}"
for trait in ServerProcess.class_traits(config=True)
if trait not in ignore_traits and trait not in self.flags
}

self.aliases = {
**super().aliases,
**server_process_aliases,
"base_url": "StandaloneProxyServer.base_url",
"base-url": "StandaloneProxyServer.base_url",
"address": "StandaloneProxyServer.address",
"port": "StandaloneProxyServer.port",
"server_port": "StandaloneProxyServer.server_port",
"activity_interval": "StandaloneProxyServer.activity_interval",
"websocket_max_message_size": "StandaloneProxyServer.websocket_max_message_size",
"server-port": "StandaloneProxyServer.server_port",
"activity-interval": "StandaloneProxyServer.activity_interval",
"websocket-max-message-size": "StandaloneProxyServer.websocket_max_message_size",
}

def emit_alias_help(self):
Expand Down Expand Up @@ -206,7 +211,7 @@ def get_proxy_attributes(self) -> dict:
attributes["proxy_base"] = "/"

attributes["requested_port"] = self.server_port
attributes["skip_authentication"] = self.skip_authentication
attributes["no_authentication"] = self.no_authentication

return attributes

Expand Down Expand Up @@ -278,7 +283,7 @@ def _configure_ssl(self) -> dict | None:
return ssl_options

def start(self):
if self.skip_authentication:
if self.no_authentication:
self.log.warn("Disabling Authentication with JuypterHub Server!")

app = self.create_app()
Expand Down
4 changes: 2 additions & 2 deletions jupyter_server_proxy/standalone/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.environment = {}
self.timeout = 60
self.skip_authentication = False
self.no_authentication = False

@property
def log(self) -> Logger:
Expand Down Expand Up @@ -69,7 +69,7 @@ def write_error(self, status_code: int, **kwargs):
return RequestHandler.write_error(self, status_code, **kwargs)

async def proxy(self, port, path):
if self.skip_authentication:
if self.no_authentication:
return await super().proxy(port, path)
else:
return await ensure_async(self.oauth_proxy(port, path))
Expand Down
10 changes: 5 additions & 5 deletions tests/test_standalone.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class _TestStandaloneBase(testing.AsyncHTTPTestCase):
runTest = None # Required for Tornado 6.1

unix_socket: bool
skip_authentication: bool
no_authentication: bool

def get_app(self):
command = [
Expand All @@ -55,7 +55,7 @@ def get_app(self):
base_url="/some/prefix",
unix_socket=self.unix_socket,
timeout=60,
skip_authentication=self.skip_authentication,
no_authentication=self.no_authentication,
log_level=logging.DEBUG,
)

Expand All @@ -69,7 +69,7 @@ class TestStandaloneProxyRedirect(_TestStandaloneBase):
"""

unix_socket = False
skip_authentication = True
no_authentication = True

def test_add_slash(self):
response = self.fetch("/some/prefix", follow_redirects=False)
Expand Down Expand Up @@ -97,7 +97,7 @@ def test_on_prefix(self):
)
class TestStandaloneProxyWithUnixSocket(_TestStandaloneBase):
unix_socket = True
skip_authentication = True
no_authentication = True

def test_with_unix_socket(self):
response = self.fetch("/some/prefix/")
Expand All @@ -115,7 +115,7 @@ class TestStandaloneProxyLogin(_TestStandaloneBase):
"""

unix_socket = False
skip_authentication = False
no_authentication = False

def test_redirect_to_login_url(self):
response = self.fetch("/some/prefix/", follow_redirects=False)
Expand Down