From 6f7f9c8e15edc9223b7cb5b266b05744b08236ca Mon Sep 17 00:00:00 2001 From: mishaschwartz <4380924+mishaschwartz@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:07:52 -0400 Subject: [PATCH] add devops tools --- .github/dependabot.yml | 9 ++++++++ .github/workflows/precommit.yml | 13 +++++++++++ .pre-commit-config.yaml | 10 +++++++++ README.md | 40 +++++++++++++++++++++++++++++++++ marble_client/__init__.py | 12 +++++++++- marble_client/client.py | 40 +++++++++++++++++++++++---------- marble_client/exceptions.py | 8 +++---- marble_client/node.py | 28 ++++++++++++++++++++++- marble_client/services.py | 26 ++++++++++++++++----- pyproject.toml | 17 +++++++++++++- requirements-dev.txt | 2 ++ tests/conftest.py | 32 ++++++++++++++++---------- tests/test_client.py | 36 +++++++++++++++-------------- tests/test_constants.py | 11 +++++---- tests/test_node.py | 13 +++++------ 15 files changed, 230 insertions(+), 67 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/precommit.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5870d41 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/precommit.yml b/.github/workflows/precommit.yml new file mode 100644 index 0000000..9ec3eca --- /dev/null +++ b/.github/workflows/precommit.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..09d2144 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 + \ No newline at end of file diff --git a/README.md b/README.md index e771302..f67410e 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/marble_client/__init__.py b/marble_client/__init__.py index caeb45f..6817d50 100644 --- a/marble_client/__init__.py +++ b/marble_client/__init__.py @@ -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", +] diff --git a/marble_client/client.py b/marble_client/client.py index 6bf1053..46a75a0 100644 --- a/marble_client/client.py +++ b/marble_client/client.py @@ -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. @@ -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 @@ -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 @@ -87,6 +95,7 @@ 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. @@ -94,8 +103,10 @@ def this_session(self, session: Optional[requests.Session] = None) -> requests.S """ 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: @@ -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 @@ -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 diff --git a/marble_client/exceptions.py b/marble_client/exceptions.py index bc12ce9..69f3dd1 100644 --- a/marble_client/exceptions.py +++ b/marble_client/exceptions.py @@ -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.""" diff --git a/marble_client/node.py b/marble_client/node.py index 0fa252e..46256bd 100644 --- a/marble_client/node.py +++ b/marble_client/node.py @@ -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 @@ -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() @@ -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: @@ -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 @@ -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}')>" diff --git a/marble_client/services.py b/marble_client/services.py index f144dff..1d7188d 100644 --- a/marble_client/services.py +++ b/marble_client/services.py @@ -1,11 +1,17 @@ -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from marble_client.node import MarbleNode __all__ = ["MarbleService"] class MarbleService: + """Service offered by a Marble node.""" + def __init__(self, servicejson: dict[str, Any], node: "MarbleNode") -> None: - """Constructor method + """ + Initialize a marble service instance. :param servicejson: A JSON representing the service according to the schema defined for the Marble node registry :type servicejson: dict[str, Any] @@ -22,7 +28,8 @@ def __init__(self, servicejson: dict[str, Any], node: "MarbleNode") -> None: @property def name(self) -> str: - """Name of the service + """ + Name of the service. :return: Name of the service :rtype: str @@ -31,7 +38,8 @@ def name(self) -> str: @property def keywords(self) -> list[str]: - """Keywords associated with this service + """ + Keywords associated with this service. :return: Keywords associated with this service :rtype: list[str] @@ -40,7 +48,8 @@ def keywords(self) -> list[str]: @property def description(self) -> str: - """A short description of this service + """ + A short description of this service. :return: A short description of this service :rtype: str @@ -49,7 +58,9 @@ def description(self) -> str: @property def url(self) -> str: - """Access the URL for the service itself. Note: the preferred approach to access the service + """ + Access the URL for the service itself. Note: the preferred approach to access the service. + URL is via just using the name of the MarbleService object. E.g.:: @@ -64,10 +75,13 @@ def url(self) -> str: @property def doc_url(self) -> str: + """Return documentation URL.""" return self._service_doc def __str__(self) -> str: + """Return string containing name and node_id.""" return f"<{self.__class__.__name__}(name: '{self.name}', node_id: '{self._node.id}')>" def __repr__(self) -> str: + """Return service URL.""" return self._service diff --git a/pyproject.toml b/pyproject.toml index 5f60bd9..6880741 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,8 +31,23 @@ dependencies = {file = ["requirements.txt"]} optional-dependencies.test = {file = ["requirements-test.txt"]} optional-dependencies.dev = {file = ["requirements-dev.txt"]} -[tool.black] +[tool.ruff] line-length = 120 +target-version = "py312" + +[tool.ruff.format] +docstring-code-format = true +line-ending = "lf" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "D", "I", "ANN"] +ignore = ["D100", "D104", "D417", "ANN002", "ANN003", "ANN401"] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +"tests/**.py" = ["D", "ANN"] [tool.pytest.ini_options] markers = [ diff --git a/requirements-dev.txt b/requirements-dev.txt index f80a7fc..c395d58 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,3 @@ bump2version>=1.0.1 +ruff~=0.9 +pre-commit~=4.1 diff --git a/tests/conftest.py b/tests/conftest.py index 37ddca3..1236dd6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,8 +25,9 @@ def registry_content(): registry_resp.raise_for_status() content = registry_resp.json() except Exception as requests_err: - warnings.warn(f"Cannot access remote registry at {registry_url}. " - f"Trying to load from cache file at {cache_file}.") + warnings.warn( + f"Cannot access remote registry at {registry_url}. Trying to load from cache file at {cache_file}." + ) try: with open(cache_file) as f: content = json.load(f) @@ -46,10 +47,13 @@ def registry_request(request, requests_mock, registry_content, tmp_cache): if "load_from_cache" in request.keywords: requests_mock.get(marble_client.constants.NODE_REGISTRY_URL, status_code=500) with open(marble_client.constants.CACHE_FNAME, "w") as f: - json.dump({ - marble_client.MarbleClient._registry_cache_key: registry_content, - marble_client.MarbleClient._registry_cache_last_updated_key: '1900' - }, f) + json.dump( + { + marble_client.MarbleClient._registry_cache_key: registry_content, + marble_client.MarbleClient._registry_cache_last_updated_key: "1900", + }, + f, + ) else: requests_mock.get(marble_client.constants.NODE_REGISTRY_URL, json=registry_content) yield @@ -82,9 +86,11 @@ def service(client): @pytest.fixture def service_json(service, registry_content): - yield next(service_data - for service_data in registry_content[service._node.id]["services"] - if service_data == service._servicedata) + yield next( + service_data + for service_data in registry_content[service._node.id]["services"] + if service_data == service._servicedata + ) @pytest.fixture @@ -105,7 +111,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(f"{jupyterhub_api_url}/users/{jupyterhub_user}", - json={"auth_state": {"magpie_cookies": cookies}}, - status_code=kwargs.get("jupyterhub_api_response_status_code", 200)) + requests_mock.get( + f"{jupyterhub_api_url}/users/{jupyterhub_user}", + json={"auth_state": {"magpie_cookies": cookies}}, + status_code=kwargs.get("jupyterhub_api_response_status_code", 200), + ) yield diff --git a/tests/test_client.py b/tests/test_client.py index 73b4870..b9bebf1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,15 +23,16 @@ def test_load_from_remote_registry(): def test_load_from_remote_registry_update_cache(client, tmp_cache, registry_content): - """ Test that marble_client initialized using a remote repository saves the repository content to a local cache """ - cache_file = os.path.join(tmp_cache, 'registry.cached.json') + """Test that marble_client initialized using a remote repository saves the repository content to a local cache""" + cache_file = os.path.join(tmp_cache, "registry.cached.json") assert os.path.isfile(cache_file) with open(cache_file) as f: content = json.load(f) assert content.get(client._registry_cache_key) == registry_content last_updated = content.get(client._registry_cache_last_updated_key) - assert (datetime.datetime.now(datetime.timezone.utc) - dateutil.parser.isoparse(last_updated) < - datetime.timedelta(seconds=1)) + assert datetime.datetime.now(datetime.timezone.utc) - dateutil.parser.isoparse(last_updated) < datetime.timedelta( + seconds=1 + ) @pytest.mark.load_from_cache @@ -57,7 +58,7 @@ def test_load_from_cache_no_fallback(): def test_nodes(client, registry_content): - """ Test that `MarbleClient.nodes` returns all nodes from the repository """ + """Test that `MarbleClient.nodes` returns all nodes from the repository""" assert client.nodes assert all(isinstance(n, marble_client.MarbleNode) for n in client.nodes.values()) assert len(client.nodes) == len(registry_content) @@ -65,13 +66,13 @@ def test_nodes(client, registry_content): @pytest.mark.jupyterlab_environment def test_this_node_in_jupyter_env(client, first_url): - """ Test that `MarbleClient.this_node` returns the current node when in a jupyterlab environment """ + """Test that `MarbleClient.this_node` returns the current node when in a jupyterlab environment""" node = client.this_node assert node.url == first_url def test_this_node_not_in_jupyter_env(client): - """ Test that `MarbleClient.this_node` raises an error when not in a jupyterlab environment """ + """Test that `MarbleClient.this_node` raises an error when not in a jupyterlab environment""" with pytest.raises(marble_client.JupyterEnvironmentError): client.this_node @@ -97,7 +98,7 @@ def test_this_node_in_unknown_node(client): @pytest.mark.jupyterlab_environment(cookies={"auth_example": "cookie_example"}) -def test_this_session_in_jupyter_env(client, first_url): +def test_this_session_in_jupyter_env(client): """ Test that `MarbleClient.this_session` sets the login cookies of the current user into a session object when in a jupyterlab environment. @@ -107,7 +108,7 @@ def test_this_session_in_jupyter_env(client, first_url): @pytest.mark.jupyterlab_environment(cookies={"auth_example": "cookie_example"}) -def test_this_session_in_jupyter_env(client, first_url): +def test_this_session_in_jupyter_env_session_exists(client): """ Test that `MarbleClient.this_session` sets the login cookies of the current user into a pre-existing session object when in a jupyterlab environment. @@ -118,7 +119,7 @@ def test_this_session_in_jupyter_env(client, first_url): def test_this_session_not_in_jupyter_env(client): - """ Test that `MarbleClient.this_session` raises an error when not in a jupyterlab environment """ + """Test that `MarbleClient.this_session` raises an error when not in a jupyterlab environment""" with pytest.raises(marble_client.JupyterEnvironmentError): client.this_session() @@ -135,28 +136,29 @@ def test_this_session_in_invalid_jupyter_env(client): @pytest.mark.jupyterlab_environment(jupyterhub_api_response_status_code=500) def test_this_session_handles_api_error(client): - """ Test that `MarbleClient.this_session` raises an appropriate error when the JupyterHub API call fails """ + """Test that `MarbleClient.this_session` raises an appropriate error when the JupyterHub API call fails""" with pytest.raises(marble_client.JupyterEnvironmentError): client.this_session() def test_getitem(client, registry_content): - """ Test that __getitem__ can be used to access the nodes in the nodes list """ - assert ({client.nodes[node_id].id for node_id in registry_content} == - {client[node_id].id for node_id in registry_content}) + """Test that __getitem__ can be used to access the nodes in the nodes list""" + assert {client.nodes[node_id].id for node_id in registry_content} == { + client[node_id].id for node_id in registry_content + } def test_getitem_no_such_node(client, registry_content): - """ Test that __getitem__ raises an appropriate error if a node is not found """ + """Test that __getitem__ raises an appropriate error if a node is not found""" with pytest.raises(marble_client.UnknownNodeError): client["".join(registry_content)] def test_contains(client, registry_content): - """ Test that __contains__ returns True when a node is available for the current client """ + """Test that __contains__ returns True when a node is available for the current client""" assert all(node_id in client for node_id in registry_content) def test_not_contains(client, registry_content): - """ Test that __contains__ returns False when a node is not available for the current client """ + """Test that __contains__ returns False when a node is not available for the current client""" assert "".join(registry_content) not in client diff --git a/tests/test_constants.py b/tests/test_constants.py index b363f05..d0455d7 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,15 +1,15 @@ import importlib import os -from platformdirs import user_cache_dir - import marble_client.constants def test_node_registry_url_default(): importlib.reload(marble_client.constants) - assert (marble_client.constants.NODE_REGISTRY_URL == - "https://raw.githubusercontent.com/DACCS-Climate/DACCS-node-registry/current-registry/node_registry.json") + assert ( + marble_client.constants.NODE_REGISTRY_URL + == "https://raw.githubusercontent.com/DACCS-Climate/DACCS-node-registry/current-registry/node_registry.json" + ) def test_node_registry_url_settable(monkeypatch): @@ -21,8 +21,7 @@ def test_node_registry_url_settable(monkeypatch): def test_cache_fname_default(tmp_cache): importlib.reload(marble_client.constants) - assert (os.path.realpath(marble_client.constants.CACHE_FNAME) == - os.path.join(tmp_cache, "registry.cached.json")) + assert os.path.realpath(marble_client.constants.CACHE_FNAME) == os.path.join(tmp_cache, "registry.cached.json") def test_cache_fname_settable(monkeypatch): diff --git a/tests/test_node.py b/tests/test_node.py index 0fa649d..e28dcb7 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -37,13 +37,11 @@ def test_url(node, node_json): def test_services_url(node, node_json): - assert (node.services_url == - next(link["href"] for link in node_json["links"] if link["rel"] == "collection")) + assert node.services_url == next(link["href"] for link in node_json["links"] if link["rel"] == "collection") def test_version_url(node, node_json): - assert (node.version_url == - next(link["href"] for link in node_json["links"] if link["rel"] == "version")) + assert node.version_url == next(link["href"] for link in node_json["links"] if link["rel"] == "version") def test_date_added(node, node_json): @@ -79,12 +77,13 @@ def test_links(node, node_json): def test_getitem(node, node_json): - assert ({node[service_["name"]].name for service_ in node_json["services"]} == - {service_["name"] for service_ in node_json["services"]}) + assert {node[service_["name"]].name for service_ in node_json["services"]} == { + service_["name"] for service_ in node_json["services"] + } def test_getitem_no_such_service(node, node_json): - """ Test that __getitem__ raises an appropriate error if a service is not found """ + """Test that __getitem__ raises an appropriate error if a service is not found""" with pytest.raises(marble_client.ServiceNotAvailableError): node["".join(service_["name"] for service_ in node_json["services"])]