Skip to content

Commit 75e0f02

Browse files
Merge pull request #27 from DACCS-Climate/interactive-login
Add login option for marble nodes so that users don't have to hard code credentials into scripts/notebooks or rely on environment variables or other custom configuration. Also changes: - moves some functions to a utils.py file for general use - replaces the requests_mock library with responses for testing as the former is no longer supported and cannot be used to mock setting cookies in responses - loosen strict dependency restrictions
2 parents 36bd2dc + 6ed00b0 commit 75e0f02

File tree

13 files changed

+251
-53
lines changed

13 files changed

+251
-53
lines changed

.bumpversion.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 1.2.0
2+
current_version = 1.3.0
33
commit = True
44
tag = False
55
tag_name = {new_version}
@@ -16,5 +16,5 @@ search = APP_VERSION := {current_version}
1616
replace = APP_VERSION := {new_version}
1717

1818
[bumpversion:file:RELEASE.txt]
19-
search = {current_version} 2024-06-20T15:09:17Z
19+
search = {current_version} 2025-08-07T15:15:34Z
2020
replace = {new_version} {utcnow:%Y-%m-%dT%H:%M:%SZ}

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
override SHELL := bash
22
override APP_NAME := marble_client
3-
override APP_VERSION := 1.2.0
3+
override APP_VERSION := 1.3.0
44

55
# utility to remove comments after value of an option variable
66
override clean_opt = $(shell echo "$(1)" | $(_SED) -r -e "s/[ '$'\t'']+$$//g")

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ access the resource if you have permission:
152152
>>> session.get(f"{client.this_node.url}/some/protected/subpath")
153153
```
154154

155+
## Interactively logging in to a node
156+
157+
In order to login to a different node or if you're running a script or notebook from outside a Marble
158+
Jupyterlab environment, use the `MarbleNode.login` function to generate a `requests.Session` object.
159+
160+
This will prompt you to input your credentials to `stdin` or an input widget if you're in a compatible
161+
Jupyter environment.
162+
155163
## Contributing
156164

157165
We welcome any contributions to this codebase. To submit suggested changes, please do the following:

RELEASE.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.0 2024-06-20T15:09:17Z
1+
1.3.0 2025-08-07T15:15:34Z

marble_client/client.py

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import os
44
import shutil
55
import warnings
6-
from functools import cache, wraps
7-
from typing import Any, Callable, Optional
6+
from functools import cache
7+
from typing import Any, Optional
88
from urllib.parse import urlparse
99

1010
import dateutil.parser
@@ -13,35 +13,11 @@
1313
from marble_client.constants import CACHE_FNAME, NODE_REGISTRY_URL
1414
from marble_client.exceptions import JupyterEnvironmentError, UnknownNodeError
1515
from marble_client.node import MarbleNode
16+
from marble_client.utils import check_jupyterlab
1617

1718
__all__ = ["MarbleClient"]
1819

1920

20-
def check_jupyterlab(f: Callable) -> Callable:
21-
"""
22-
Raise an error if not running in a Jupyterlab instance.
23-
24-
Wraps the function f by first checking if the current script is running in a
25-
Marble Jupyterlab environment and raising a JupyterEnvironmentError if not.
26-
27-
This is used as a pre-check for functions that only work in a Marble Jupyterlab
28-
environment.
29-
30-
Note that this checks if either the BIRDHOUSE_HOST_URL or PAVICS_HOST_URL are present to support
31-
versions of birdhouse-deploy prior to 2.4.0.
32-
"""
33-
34-
@wraps(f)
35-
def wrapper(*args, **kwargs) -> Any:
36-
birdhouse_host_var = ("PAVICS_HOST_URL", "BIRDHOUSE_HOST_URL")
37-
jupyterhub_env_vars = ("JUPYTERHUB_API_URL", "JUPYTERHUB_USER", "JUPYTERHUB_API_TOKEN")
38-
if any(os.getenv(var) for var in birdhouse_host_var) and all(os.getenv(var) for var in jupyterhub_env_vars):
39-
return f(*args, **kwargs)
40-
raise JupyterEnvironmentError("Not in a Marble jupyterlab environment")
41-
42-
return wrapper
43-
44-
4521
class MarbleClient:
4622
"""Client object representing the information in the Marble registry."""
4723

@@ -67,7 +43,7 @@ def __init__(self, fallback: bool = True) -> None:
6743
self._registry_uri, self._registry = self._load_registry(fallback)
6844

6945
for node_id, node_details in self._registry.items():
70-
self._nodes[node_id] = MarbleNode(node_id, node_details)
46+
self._nodes[node_id] = MarbleNode(node_id, node_details, client=self)
7147

7248
@property
7349
def nodes(self) -> dict[str, MarbleNode]:

marble_client/node.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
1+
import getpass
12
import warnings
23
from datetime import datetime
3-
from typing import Optional
4+
from typing import TYPE_CHECKING, Literal, Optional
45

56
import dateutil.parser
67
import requests
78

89
from marble_client.exceptions import ServiceNotAvailableError
910
from marble_client.services import MarbleService
11+
from marble_client.utils import check_rich_output_shell
12+
13+
if TYPE_CHECKING:
14+
from marble_client.client import MarbleClient
1015

1116
__all__ = ["MarbleNode"]
1217

1318

1419
class MarbleNode:
1520
"""A node in the Marble network."""
1621

17-
def __init__(self, nodeid: str, jsondata: dict[str]) -> None:
22+
def __init__(self, nodeid: str, jsondata: dict[str], client: "MarbleClient") -> None:
1823
self._nodedata = jsondata
1924
self._id = nodeid
2025
self._name = jsondata["name"]
26+
self._client = client
2127

2228
self._links_service = None
2329
self._links_collection = None
@@ -159,3 +165,102 @@ def __contains__(self, service: str) -> bool:
159165
def __repr__(self) -> str:
160166
"""Return a repr containing id and name."""
161167
return f"<{self.__class__.__name__}(id: '{self.id}', name: '{self.name}')>"
168+
169+
def _login(self, session: requests.Session, user_name: str | None, password: str | None) -> None:
170+
if user_name is None or not user_name.strip():
171+
raise RuntimeError("Username or email is required")
172+
if password is None or not password.strip():
173+
raise RuntimeError("Password is required")
174+
response = session.post(
175+
self.url.rstrip("/") + "/magpie/signin",
176+
json={"user_name": user_name, "password": password},
177+
)
178+
if response.ok:
179+
return response.json().get("detail", "Success")
180+
try:
181+
raise RuntimeError(response.json().get("detail", "Unable to log in"))
182+
except requests.exceptions.JSONDecodeError as e:
183+
raise RuntimeError("Unable to log in") from e
184+
185+
def _widget_login(self, session: requests.Session) -> tuple[str, str]:
186+
import ipywidgets # type: ignore
187+
from IPython.display import display # type: ignore
188+
189+
font_family = "Helvetica Neue"
190+
font_size = "16px"
191+
primary_colour = "#304FFE"
192+
label_style = {"font_family": font_family, "font_size": font_size, "text_color": primary_colour}
193+
input_style = {"description_width": "initial"}
194+
button_style = {
195+
"font_family": font_family,
196+
"font_size": font_size,
197+
"button_color": primary_colour,
198+
"text_color": "white",
199+
}
200+
credentials = {}
201+
202+
username_label = ipywidgets.Label(value="Username or email", style=label_style)
203+
username_input = ipywidgets.Text(style=input_style)
204+
password_label = ipywidgets.Label(value="Password", style=label_style)
205+
password_input = ipywidgets.Password(style=input_style)
206+
login_button = ipywidgets.Button(description="Login", tooltip="Login", style=button_style)
207+
output = ipywidgets.Output()
208+
widgets = ipywidgets.VBox(
209+
[username_label, username_input, password_label, password_input, login_button, output]
210+
)
211+
212+
def _on_username_change(change: dict) -> None:
213+
try:
214+
credentials["user_name"] = change["new"]
215+
except KeyError as e:
216+
raise Exception(str(e), change)
217+
218+
username_input.observe(_on_username_change, names="value")
219+
220+
def _on_password_change(change: dict) -> None:
221+
credentials["password"] = change["new"]
222+
223+
password_input.observe(_on_password_change, names="value")
224+
225+
def _on_login_click(*_) -> None:
226+
output.clear_output()
227+
with output:
228+
try:
229+
message = self._login(session, credentials.get("user_name"), credentials.get("password"))
230+
except RuntimeError as e:
231+
display(ipywidgets.Label(value=str(e), style={**label_style, "text_color": "red"}))
232+
else:
233+
display(ipywidgets.Label(value=message, style={**label_style, "text_color": "green"}))
234+
235+
login_button.on_click(_on_login_click)
236+
display(widgets)
237+
238+
def _stdin_login(self, session: requests.Session) -> tuple[str, str]:
239+
message = self._login(session, input("Username or email: "), getpass.getpass("Password: "))
240+
print(message)
241+
242+
def login(
243+
self, session: requests.Session | None = None, input_type: Literal["stdin", "widget"] | None = None
244+
) -> requests.Session:
245+
"""
246+
Return a requests session containing login cookies for this node.
247+
248+
This will get user name and password using user input using jupyter widgets
249+
if available. Otherwise it will prompt the user to input details from stdin.
250+
251+
If you want to force the function to use either stdin or widgets specify "stdin"
252+
or "widget" as the input type. Otherwise, this function will make its best guess
253+
which one to use.
254+
"""
255+
if session is None:
256+
session = requests.Session()
257+
if input_type is None:
258+
input_type = "widget" if check_rich_output_shell() else "stdin"
259+
if input_type == "widget":
260+
self._widget_login(session)
261+
elif input_type == "stdin":
262+
self._stdin_login(session)
263+
else:
264+
raise TypeError("input_type must be one of 'stdin', 'widget' or None.")
265+
266+
return session

marble_client/utils.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import os
2+
from functools import cache, wraps
3+
from typing import Any, Callable
4+
5+
from marble_client.exceptions import JupyterEnvironmentError
6+
7+
8+
@cache
9+
def check_rich_output_shell() -> bool:
10+
"""Return True iff running in an ipython compatible environment that can display rich outputs like widgets."""
11+
try:
12+
from IPython import get_ipython # type: ignore
13+
14+
ipython_class = get_ipython().__class__
15+
except (ImportError, NameError):
16+
return False
17+
else:
18+
full_path = f"{ipython_class.__module__}.{ipython_class.__qualname__}"
19+
return full_path in {
20+
"ipykernel.zmqshell.ZMQInteractiveShell",
21+
"google.colab._shell.Shell",
22+
} # TODO: add more shells as needed
23+
24+
25+
def check_jupyterlab(f: Callable) -> Callable:
26+
"""
27+
Raise an error if not running in a Jupyterlab instance.
28+
29+
Wraps the function f by first checking if the current script is running in a
30+
Marble Jupyterlab environment and raising a JupyterEnvironmentError if not.
31+
32+
This is used as a pre-check for functions that only work in a Marble Jupyterlab
33+
environment.
34+
35+
Note that this checks if either the BIRDHOUSE_HOST_URL or PAVICS_HOST_URL are present to support
36+
versions of birdhouse-deploy prior to 2.4.0.
37+
"""
38+
39+
@wraps(f)
40+
def wrapper(*args, **kwargs) -> Any:
41+
birdhouse_host_var = ("PAVICS_HOST_URL", "BIRDHOUSE_HOST_URL")
42+
jupyterhub_env_vars = ("JUPYTERHUB_API_URL", "JUPYTERHUB_USER", "JUPYTERHUB_API_TOKEN")
43+
if any(os.getenv(var) for var in birdhouse_host_var) and all(os.getenv(var) for var in jupyterhub_env_vars):
44+
return f(*args, **kwargs)
45+
raise JupyterEnvironmentError("Not in a Marble jupyterlab environment")
46+
47+
return wrapper

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ license = {file = "LICENSE"}
1616
name = "marble_client"
1717
readme = "README.md"
1818
requires-python = ">=3.9"
19-
version = "1.2.0"
19+
version = "1.3.0"
2020

2121
[project.urls]
2222
# Homepage will change to Marble homepage when that goes live

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
bump2version>=1.0.1
1+
bump2version~=1.0
22
ruff~=0.9
33
pre-commit~=4.1

requirements-test.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
pytest>=8.2.1
2-
requests-mock>=1.12.1
1+
pytest~=8.2
2+
responses~=0.25
3+
ipywidgets~=8.1

0 commit comments

Comments
 (0)