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
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
13 changes: 13 additions & 0 deletions .github/workflows/precommit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: pre-commit

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v3
- uses: pre-commit/action@v3.0.1
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.9.5
hooks:
# Run the linter.
- id: ruff
# Run the formatter.
- id: ruff-format

40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,43 @@ access the resource if you have permission:
```python
>>> session.get(f"{client.this_node.url}/some/protected/subpath")
```

## Contributing

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

- create a new feature branch off of `main`
- update the code, write/update tests, write/update documentation
- submit a pull request targetting the `main` branch

To develop this project locally, first clone this repository and install the testing and development
dependencies:

```sh
pip install -e .[dev,test]
```

### Testing

Tests are located in the `tests/` folder can be run using `pytest`:

```sh
pytest tests/
```

### Coding Style

This codebase uses the [`ruff`](https://docs.astral.sh/ruff/) formatter and linter to enforce style policies.

To check that your changes conform to these policies please run:

```sh
ruff format
ruff check
```

You can also set up pre-commit hooks that will run these checks before you create any commit in this repo:

```sh
pre-commit install
```
12 changes: 11 additions & 1 deletion marble_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
from .client import MarbleClient
from .exceptions import MarbleBaseError, ServiceNotAvailableError, UnknownNodeError, JupyterEnvironmentError
from .exceptions import JupyterEnvironmentError, MarbleBaseError, ServiceNotAvailableError, UnknownNodeError
from .node import MarbleNode
from .services import MarbleService

__all__ = [
"MarbleClient",
"JupyterEnvironmentError",
"MarbleBaseError",
"ServiceNotAvailableError",
"UnknownNodeError",
"MarbleNode",
"MarbleService",
]
40 changes: 28 additions & 12 deletions marble_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@
import os
import shutil
import warnings
from functools import wraps, cache
from typing import Optional, Any
from functools import cache, wraps
from typing import Any, Callable, Optional
from urllib.parse import urlparse

import dateutil.parser
import requests

from marble_client.constants import CACHE_FNAME, NODE_REGISTRY_URL
from marble_client.exceptions import UnknownNodeError, JupyterEnvironmentError
from marble_client.exceptions import JupyterEnvironmentError, UnknownNodeError
from marble_client.node import MarbleNode

__all__ = ["MarbleClient"]


def check_jupyterlab(f):
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.

Expand All @@ -28,22 +30,27 @@ def check_jupyterlab(f):
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):
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."""

_registry_cache_key = "marble_client_python:cached_registry"
_registry_cache_last_updated_key = "marble_client_python:last_updated"

def __init__(self, fallback: bool = True) -> None:
"""Constructor method
"""
Initialize a MarbleClient instance.

:param fallback: If True, then fall back to a cached version of the registry
if the cloud registry cannot be accessed, defaults to True
Expand All @@ -64,6 +71,7 @@ def __init__(self, fallback: bool = True) -> None:

@property
def nodes(self) -> dict[str, MarbleNode]:
"""Return nodes in the current registry."""
return self._nodes

@property
Expand All @@ -87,15 +95,18 @@ def this_node(self) -> MarbleNode:
def this_session(self, session: Optional[requests.Session] = None) -> requests.Session:
"""
Add the login session cookies of the user who is currently logged in to the session object.

If a session object is not passed as an argument to this function, create a new session
object as well.

Note that this function only works in a Marble Jupyterlab environment.
"""
if session is None:
session = requests.Session()
r = requests.get(f"{os.getenv('JUPYTERHUB_API_URL')}/users/{os.getenv('JUPYTERHUB_USER')}",
headers={"Authorization": f"token {os.getenv('JUPYTERHUB_API_TOKEN')}"})
r = requests.get(
f"{os.getenv('JUPYTERHUB_API_URL')}/users/{os.getenv('JUPYTERHUB_USER')}",
headers={"Authorization": f"token {os.getenv('JUPYTERHUB_API_TOKEN')}"},
)
try:
r.raise_for_status()
except requests.HTTPError as err:
Expand All @@ -105,17 +116,20 @@ def this_session(self, session: Optional[requests.Session] = None) -> requests.S
return session

@property
def registry_uri(self):
def registry_uri(self) -> str:
"""Return the URL of the currently used Marble registry."""
return self._registry_uri

def __getitem__(self, node: str) -> MarbleNode:
"""Return the node with the given name."""
try:
return self.nodes[node]
except KeyError as err:
raise UnknownNodeError(f"No node named '{node}' in the Marble network.") from err

def __contains__(self, node: str) -> bool:
"""Check if a node is available
"""
Check if a node is available.

:param node: ID of the Marble node
:type node: str
Expand Down Expand Up @@ -175,8 +189,10 @@ def _save_registry_as_cache(self, registry: dict[str, Any]) -> None:

try:
with open(CACHE_FNAME, "w") as f:
data = {self._registry_cache_key: registry,
self._registry_cache_last_updated_key: datetime.datetime.now(tz=datetime.timezone.utc).isoformat()}
data = {
self._registry_cache_key: registry,
self._registry_cache_last_updated_key: datetime.datetime.now(tz=datetime.timezone.utc).isoformat(),
}
json.dump(data, f)
except OSError:
# If the cache file cannot be written, then restore from backup files
Expand Down
8 changes: 4 additions & 4 deletions marble_client/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
class MarbleBaseError(Exception):
pass
"""Base Error for all exceptions for this package."""


class ServiceNotAvailableError(MarbleBaseError):
pass
"""Indicates that a given service is not available."""


class UnknownNodeError(MarbleBaseError):
pass
"""Indicates that the given node cannot be found."""


class JupyterEnvironmentError(MarbleBaseError):
pass
"""Indicates that there is an issue detecting features only available in Jupyterlab."""
28 changes: 27 additions & 1 deletion marble_client/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@


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

def __init__(self, nodeid: str, jsondata: dict[str]) -> None:
self._nodedata = jsondata
self._id = nodeid
Expand All @@ -34,6 +36,7 @@ def __init__(self, nodeid: str, jsondata: dict[str]) -> None:
self._services[s.name] = s

def is_online(self) -> bool:
"""Return True iff the node is currently online."""
try:
registry = requests.get(self.url)
registry.raise_for_status()
Expand All @@ -43,68 +46,89 @@ def is_online(self) -> bool:

@property
def id(self) -> str:
"""Return the unique id for this node in the Marble network."""
return self._id

@property
def name(self) -> str:
"""
Return the name of the node.

Note that this is not guarenteed to be unique (like the id) but represents
how the node is often referred to in other documentation.
"""
return self._name

@property
def description(self) -> str:
"""Return a description of the node."""
return self._nodedata["description"]

@property
def url(self) -> Optional[str]:
"""Return the root URL of the node."""
return self._links_service

@property
def collection_url(self) -> Optional[str]:
"""Return a URL to the node's services endpoint."""
warnings.warn("collection_url has been renamed to services_url", DeprecationWarning, 2)
return self._links_collection

@property
def services_url(self) -> Optional[str]:
"""Return a URL to the node's services endpoint."""
return self._links_collection

@property
def version_url(self) -> Optional[str]:
"""Return a URL to the node's version endpoint."""
return self._links_version

@property
def date_added(self) -> datetime:
"""Return datetime representing when the node was added to the Marble network."""
return dateutil.parser.isoparse(self._nodedata["date_added"])

@property
def affiliation(self) -> str:
"""Return affiliation information for the node."""
return self._nodedata["affiliation"]

@property
def location(self) -> dict[str, float]:
"""Return the geographical location of the node."""
return self._nodedata["location"]

@property
def contact(self) -> str:
"""Return contact information for the node."""
return self._nodedata["contact"]

@property
def last_updated(self) -> datetime:
"""Return datetime representing the last time the node's metadata was updated."""
return dateutil.parser.isoparse(self._nodedata["last_updated"])

@property
def marble_version(self) -> str:
"""Return node version."""
warnings.warn("marble_version has been renamed to version", DeprecationWarning, 2)
return self._nodedata["version"]

@property
def version(self) -> str:
"""Return node version."""
return self._nodedata["version"]

@property
def services(self) -> list[str]:
"""Return node services."""
return list(self._services)

@property
def links(self) -> list[dict[str, str]]:
"""Return node links."""
return self._nodedata["links"]

def __getitem__(self, service: str) -> MarbleService:
Expand All @@ -122,7 +146,8 @@ def __getitem__(self, service: str) -> MarbleService:
raise ServiceNotAvailableError(f"A service named '{service}' is not available on this node.") from e

def __contains__(self, service: str) -> bool:
"""Check if a service is available at a node
"""
Check if a service is available at a node.

:param service: Name of the Marble service
:type service: str
Expand All @@ -132,4 +157,5 @@ def __contains__(self, service: str) -> bool:
return service in self._services

def __repr__(self) -> str:
"""Return a repr containing id and name."""
return f"<{self.__class__.__name__}(id: '{self.id}', name: '{self.name}')>"
Loading