Skip to content
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 .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.2.0
current_version = 1.3.0
commit = True
tag = False
tag_name = {new_version}
Expand All @@ -16,5 +16,5 @@ search = APP_VERSION := {current_version}
replace = APP_VERSION := {new_version}

[bumpversion:file:RELEASE.txt]
search = {current_version} 2024-06-20T15:09:17Z
search = {current_version} 2025-08-07T15:15:34Z
replace = {new_version} {utcnow:%Y-%m-%dT%H:%M:%SZ}
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
override SHELL := bash
override APP_NAME := marble_client
override APP_VERSION := 1.2.0
override APP_VERSION := 1.3.0

# utility to remove comments after value of an option variable
override clean_opt = $(shell echo "$(1)" | $(_SED) -r -e "s/[ '$'\t'']+$$//g")
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ access the resource if you have permission:
>>> session.get(f"{client.this_node.url}/some/protected/subpath")
```

## Interactively logging in to a node

In order to login to a different node or if you're running a script or notebook from outside a Marble
Jupyterlab environment, use the `MarbleNode.login` function to generate a `requests.Session` object.

This will prompt you to input your credentials to `stdin` or an input widget if you're in a compatible
Jupyter environment.

## Contributing

We welcome any contributions to this codebase. To submit suggested changes, please do the following:
Expand Down
2 changes: 1 addition & 1 deletion RELEASE.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.0 2024-06-20T15:09:17Z
1.3.0 2025-08-07T15:15:34Z
32 changes: 4 additions & 28 deletions marble_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import os
import shutil
import warnings
from functools import cache, wraps
from typing import Any, Callable, Optional
from functools import cache
from typing import Any, Optional
from urllib.parse import urlparse

import dateutil.parser
Expand All @@ -13,35 +13,11 @@
from marble_client.constants import CACHE_FNAME, NODE_REGISTRY_URL
from marble_client.exceptions import JupyterEnvironmentError, UnknownNodeError
from marble_client.node import MarbleNode
from marble_client.utils import check_jupyterlab

__all__ = ["MarbleClient"]


def check_jupyterlab(f: Callable) -> Callable:
"""
Raise an error if not running in a Jupyterlab instance.

Wraps the function f by first checking if the current script is running in a
Marble Jupyterlab environment and raising a JupyterEnvironmentError if not.

This is used as a pre-check for functions that only work in a Marble Jupyterlab
environment.

Note that this checks if either the BIRDHOUSE_HOST_URL or PAVICS_HOST_URL are present to support
versions of birdhouse-deploy prior to 2.4.0.
"""

@wraps(f)
def wrapper(*args, **kwargs) -> Any:
birdhouse_host_var = ("PAVICS_HOST_URL", "BIRDHOUSE_HOST_URL")
jupyterhub_env_vars = ("JUPYTERHUB_API_URL", "JUPYTERHUB_USER", "JUPYTERHUB_API_TOKEN")
if any(os.getenv(var) for var in birdhouse_host_var) and all(os.getenv(var) for var in jupyterhub_env_vars):
return f(*args, **kwargs)
raise JupyterEnvironmentError("Not in a Marble jupyterlab environment")

return wrapper


class MarbleClient:
"""Client object representing the information in the Marble registry."""

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

for node_id, node_details in self._registry.items():
self._nodes[node_id] = MarbleNode(node_id, node_details)
self._nodes[node_id] = MarbleNode(node_id, node_details, client=self)

@property
def nodes(self) -> dict[str, MarbleNode]:
Expand Down
109 changes: 107 additions & 2 deletions marble_client/node.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import getpass
import warnings
from datetime import datetime
from typing import Optional
from typing import TYPE_CHECKING, Literal, Optional

import dateutil.parser
import requests

from marble_client.exceptions import ServiceNotAvailableError
from marble_client.services import MarbleService
from marble_client.utils import check_rich_output_shell

if TYPE_CHECKING:
from marble_client.client import MarbleClient

__all__ = ["MarbleNode"]


class MarbleNode:
"""A node in the Marble network."""

def __init__(self, nodeid: str, jsondata: dict[str]) -> None:
def __init__(self, nodeid: str, jsondata: dict[str], client: "MarbleClient") -> None:
self._nodedata = jsondata
self._id = nodeid
self._name = jsondata["name"]
self._client = client

self._links_service = None
self._links_collection = None
Expand Down Expand Up @@ -159,3 +165,102 @@ def __contains__(self, service: str) -> bool:
def __repr__(self) -> str:
"""Return a repr containing id and name."""
return f"<{self.__class__.__name__}(id: '{self.id}', name: '{self.name}')>"

def _login(self, session: requests.Session, user_name: str | None, password: str | None) -> None:
if user_name is None or not user_name.strip():
raise RuntimeError("Username or email is required")
if password is None or not password.strip():
raise RuntimeError("Password is required")
response = session.post(
self.url.rstrip("/") + "/magpie/signin",
json={"user_name": user_name, "password": password},
)
if response.ok:
return response.json().get("detail", "Success")
try:
raise RuntimeError(response.json().get("detail", "Unable to log in"))
except requests.exceptions.JSONDecodeError as e:
raise RuntimeError("Unable to log in") from e

def _widget_login(self, session: requests.Session) -> tuple[str, str]:
import ipywidgets # type: ignore
from IPython.display import display # type: ignore

font_family = "Helvetica Neue"
font_size = "16px"
primary_colour = "#304FFE"
label_style = {"font_family": font_family, "font_size": font_size, "text_color": primary_colour}
input_style = {"description_width": "initial"}
button_style = {
"font_family": font_family,
"font_size": font_size,
"button_color": primary_colour,
"text_color": "white",
}
credentials = {}

username_label = ipywidgets.Label(value="Username or email", style=label_style)
username_input = ipywidgets.Text(style=input_style)
password_label = ipywidgets.Label(value="Password", style=label_style)
password_input = ipywidgets.Password(style=input_style)
login_button = ipywidgets.Button(description="Login", tooltip="Login", style=button_style)
output = ipywidgets.Output()
widgets = ipywidgets.VBox(
[username_label, username_input, password_label, password_input, login_button, output]
)

def _on_username_change(change: dict) -> None:
try:
credentials["user_name"] = change["new"]
except KeyError as e:
raise Exception(str(e), change)

username_input.observe(_on_username_change, names="value")

def _on_password_change(change: dict) -> None:
credentials["password"] = change["new"]

password_input.observe(_on_password_change, names="value")

def _on_login_click(*_) -> None:
output.clear_output()
with output:
try:
message = self._login(session, credentials.get("user_name"), credentials.get("password"))
except RuntimeError as e:
display(ipywidgets.Label(value=str(e), style={**label_style, "text_color": "red"}))
else:
display(ipywidgets.Label(value=message, style={**label_style, "text_color": "green"}))

login_button.on_click(_on_login_click)
display(widgets)

def _stdin_login(self, session: requests.Session) -> tuple[str, str]:
message = self._login(session, input("Username or email: "), getpass.getpass("Password: "))
print(message)

def login(
self, session: requests.Session | None = None, input_type: Literal["stdin", "widget"] | None = None
) -> requests.Session:
"""
Return a requests session containing login cookies for this node.

This will get user name and password using user input using jupyter widgets
if available. Otherwise it will prompt the user to input details from stdin.

If you want to force the function to use either stdin or widgets specify "stdin"
or "widget" as the input type. Otherwise, this function will make its best guess
which one to use.
"""
if session is None:
session = requests.Session()
if input_type is None:
input_type = "widget" if check_rich_output_shell() else "stdin"
if input_type == "widget":
self._widget_login(session)
elif input_type == "stdin":
self._stdin_login(session)
else:
raise TypeError("input_type must be one of 'stdin', 'widget' or None.")

return session
47 changes: 47 additions & 0 deletions marble_client/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import os
from functools import cache, wraps
from typing import Any, Callable

from marble_client.exceptions import JupyterEnvironmentError


@cache
def check_rich_output_shell() -> bool:
"""Return True iff running in an ipython compatible environment that can display rich outputs like widgets."""
try:
from IPython import get_ipython # type: ignore

ipython_class = get_ipython().__class__
except (ImportError, NameError):
return False
else:
full_path = f"{ipython_class.__module__}.{ipython_class.__qualname__}"
return full_path in {
"ipykernel.zmqshell.ZMQInteractiveShell",
"google.colab._shell.Shell",
} # TODO: add more shells as needed


def check_jupyterlab(f: Callable) -> Callable:
"""
Raise an error if not running in a Jupyterlab instance.

Wraps the function f by first checking if the current script is running in a
Marble Jupyterlab environment and raising a JupyterEnvironmentError if not.

This is used as a pre-check for functions that only work in a Marble Jupyterlab
environment.

Note that this checks if either the BIRDHOUSE_HOST_URL or PAVICS_HOST_URL are present to support
versions of birdhouse-deploy prior to 2.4.0.
"""

@wraps(f)
def wrapper(*args, **kwargs) -> Any:
birdhouse_host_var = ("PAVICS_HOST_URL", "BIRDHOUSE_HOST_URL")
jupyterhub_env_vars = ("JUPYTERHUB_API_URL", "JUPYTERHUB_USER", "JUPYTERHUB_API_TOKEN")
if any(os.getenv(var) for var in birdhouse_host_var) and all(os.getenv(var) for var in jupyterhub_env_vars):
return f(*args, **kwargs)
raise JupyterEnvironmentError("Not in a Marble jupyterlab environment")

return wrapper
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ license = {file = "LICENSE"}
name = "marble_client"
readme = "README.md"
requires-python = ">=3.9"
version = "1.2.0"
version = "1.3.0"

[project.urls]
# Homepage will change to Marble homepage when that goes live
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
bump2version>=1.0.1
bump2version~=1.0
ruff~=0.9
pre-commit~=4.1
5 changes: 3 additions & 2 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pytest>=8.2.1
requests-mock>=1.12.1
pytest~=8.2
responses~=0.25
ipywidgets~=8.1
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
platformdirs==4.2.0
python-dateutil==2.8.2
requests==2.31.0
platformdirs~=4.2
python-dateutil~=2.8
requests~=2.31
19 changes: 13 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@

import pytest
import requests
import responses as responses_

import marble_client


@pytest.fixture
def responses():
with responses_.RequestsMock(assert_all_requests_are_fired=False) as rsps:
yield rsps


@pytest.fixture
def client():
yield marble_client.MarbleClient()
Expand Down Expand Up @@ -43,9 +50,9 @@ def registry_content():


@pytest.fixture(autouse=True)
def registry_request(request, requests_mock, registry_content, tmp_cache):
def registry_request(request, responses, registry_content, tmp_cache):
if "load_from_cache" in request.keywords:
requests_mock.get(marble_client.constants.NODE_REGISTRY_URL, status_code=500)
responses.get(marble_client.constants.NODE_REGISTRY_URL, status=500)
with open(marble_client.constants.CACHE_FNAME, "w") as f:
json.dump(
{
Expand All @@ -55,7 +62,7 @@ def registry_request(request, requests_mock, registry_content, tmp_cache):
f,
)
else:
requests_mock.get(marble_client.constants.NODE_REGISTRY_URL, json=registry_content)
responses.get(marble_client.constants.NODE_REGISTRY_URL, json=registry_content)
yield


Expand Down Expand Up @@ -99,7 +106,7 @@ def first_url(registry_content):


@pytest.fixture(autouse=True)
def jupyterlab_environment(request, monkeypatch, first_url, requests_mock):
def jupyterlab_environment(request, monkeypatch, first_url, responses):
if "jupyterlab_environment" in request.keywords:
kwargs = request.keywords["jupyterlab_environment"].kwargs
monkeypatch.setenv("BIRDHOUSE_HOST_URL", kwargs.get("url", first_url))
Expand All @@ -111,9 +118,9 @@ def jupyterlab_environment(request, monkeypatch, first_url, requests_mock):
monkeypatch.setenv("JUPYTERHUB_USER", jupyterhub_user)
monkeypatch.setenv("JUPYTERHUB_API_TOKEN", jupyterhub_api_token)
cookies = kwargs.get("cookies", {})
requests_mock.get(
responses.get(
f"{jupyterhub_api_url}/users/{jupyterhub_user}",
json={"auth_state": {"magpie_cookies": cookies}},
status_code=kwargs.get("jupyterhub_api_response_status_code", 200),
status=kwargs.get("jupyterhub_api_response_status_code", 200),
)
yield
Loading