diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 7390f9c..cb67ccc 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.0 +current_version = 1.3.0 commit = True tag = False tag_name = {new_version} @@ -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} diff --git a/Makefile b/Makefile index 96bd425..048b3e5 100644 --- a/Makefile +++ b/Makefile @@ -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") diff --git a/README.md b/README.md index f67410e..88b0b7f 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/RELEASE.txt b/RELEASE.txt index 32627ba..8834d73 100644 --- a/RELEASE.txt +++ b/RELEASE.txt @@ -1 +1 @@ -1.2.0 2024-06-20T15:09:17Z \ No newline at end of file +1.3.0 2025-08-07T15:15:34Z \ No newline at end of file diff --git a/marble_client/client.py b/marble_client/client.py index 46a75a0..30305f0 100644 --- a/marble_client/client.py +++ b/marble_client/client.py @@ -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 @@ -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.""" @@ -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]: diff --git a/marble_client/node.py b/marble_client/node.py index 46256bd..db66a8b 100644 --- a/marble_client/node.py +++ b/marble_client/node.py @@ -1,12 +1,17 @@ +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"] @@ -14,10 +19,11 @@ 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 @@ -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 diff --git a/marble_client/utils.py b/marble_client/utils.py new file mode 100644 index 0000000..3786aca --- /dev/null +++ b/marble_client/utils.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6880741..a015501 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/requirements-dev.txt b/requirements-dev.txt index c395d58..7225bcb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ -bump2version>=1.0.1 +bump2version~=1.0 ruff~=0.9 pre-commit~=4.1 diff --git a/requirements-test.txt b/requirements-test.txt index e5a4a8d..dc516ce 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ -pytest>=8.2.1 -requests-mock>=1.12.1 +pytest~=8.2 +responses~=0.25 +ipywidgets~=8.1 diff --git a/requirements.txt b/requirements.txt index 5dc797f..d607ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 1236dd6..3a135d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() @@ -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( { @@ -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 @@ -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)) @@ -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 diff --git a/tests/test_node.py b/tests/test_node.py index e28dcb7..03b667a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock, patch + import dateutil.parser import pytest import requests @@ -5,18 +7,18 @@ import marble_client -def test_is_online(node, requests_mock): - requests_mock.get(node.url) +def test_is_online(node, responses): + responses.get(node.url) assert node.is_online() -def test_is_online_returns_error_status(node, requests_mock): - requests_mock.get(node.url, status_code=500) +def test_is_online_returns_error_status(node, responses): + responses.get(node.url, status=500) assert not node.is_online() -def test_is_online_offline(node, requests_mock): - requests_mock.get(node.url, exc=requests.exceptions.ConnectionError) +def test_is_online_offline(node, responses): + responses.get(node.url, body=requests.exceptions.ConnectionError()) assert not node.is_online() @@ -94,3 +96,55 @@ def test_contains(node, node_json): def test_not_contains(node, node_json): assert "".join(service_["name"] for service_ in node_json["services"]) not in node + + +@pytest.mark.parametrize("input_type", ["stdin", None]) +@pytest.mark.parametrize("detail", ["some info here", None]) +def test_login_stdin_success(input_type, detail, node, capsys, monkeypatch, responses): + monkeypatch.setattr("builtins.input", lambda *a, **kw: "test") + monkeypatch.setattr("getpass.getpass", lambda *a, **kw: "testpass") + responses.post( + node.url.rstrip("/") + "/magpie/signin", + json=({"detail": detail} if detail else {}), + headers={"Set-Cookie": "cookie=test"}, + ) + if input_type is None: + with patch("marble_client.node.check_rich_output_shell", Mock(return_value=False)): + session = node.login(input_type=input_type) + else: + session = node.login(input_type=input_type) + assert capsys.readouterr().out.strip() == (detail or "Success") + assert session.cookies.get_dict() == {"cookie": "test"} + + +@pytest.mark.parametrize("input_type", ["stdin", None]) +@pytest.mark.parametrize("detail", ["some info here", None]) +def test_login_stdin_failure(input_type, detail, node, capsys, monkeypatch, responses): + monkeypatch.setattr("builtins.input", lambda *a, **kw: "test") + monkeypatch.setattr("getpass.getpass", lambda *a, **kw: "testpass") + responses.post( + node.url.rstrip("/") + "/magpie/signin", + status=401, + json=({"detail": detail} if detail else {}), + ) + with pytest.raises(RuntimeError) as e: + if input_type is None: + with patch("marble_client.node.check_rich_output_shell", Mock(return_value=False)): + node.login(input_type=input_type) + else: + node.login(input_type=input_type) + assert str(e.value) == (detail or "Unable to log in") + + +@pytest.mark.parametrize("input_type", ["widget", None]) +def test_login_widget_display(input_type, node, capsys): + """ + Only test for display because interaction with widgets requires a jvascript testing + framework like galata. + """ + if input_type is None: + with patch("marble_client.node.check_rich_output_shell", Mock(return_value=True)): + node.login(input_type=input_type) + else: + node.login(input_type=input_type) + assert capsys.readouterr().out.startswith("VBox")