diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index f3db987..c508372 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -14,6 +14,21 @@ # Initialize a Connection using an explicit Settings instance. connection = xcc.Connection.load(settings=xcc.Settings()) ``` + +* Following an update to the Xanadu Cloud 0.4.0 API, job lists can now be filtered by ID. + [(#21)](https://github.com/XanaduAI/xanadu-cloud-client/pull/21) + + Using the CLI: + + ```bash + xcc job list --ids '["", "", ...]' + ``` + + Using the Python API: + + ```python + xcc.Job.list(connection, ids=["", "", ...]) + ``` ### Breaking Changes diff --git a/tests/test_commands.py b/tests/test_commands.py index 5f80f01..38d0eb3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -47,7 +47,7 @@ class MockJob(xcc.Job): """ @staticmethod - def list(connection, limit=10): + def list(connection, limit=10, ids=None): connection = xcc.commands.load_connection() return [MockJob(id_, connection) for id_ in ("foo", "bar", "baz")][:limit] diff --git a/tests/test_connection.py b/tests/test_connection.py index fa49ba9..86eeb57 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -175,16 +175,42 @@ def test_request_failure_due_to_invalid_json(self, connection): with pytest.raises(HTTPError, match=r"400 Client Error: Bad Request"): connection.request("GET", "/healthz") + @pytest.mark.parametrize( + "meta, match", + [ + ( + { + "_schema": ["Foo", "Bar"], + "size": ["Baz"], + }, + r"Foo; Bar; Baz", + ), + ( + { + "head": {"0": ["Foo", "Bar"]}, + "tail": {"0": ["Baz"], "1": ["Bud"]}, + }, + r"Foo; Bar; Baz; Bud", + ), + ( + { + "List": ["Foo"], + "Dict": {"0": ["Bar"]}, + }, + r"Foo; Bar", + ), + ], + ) @responses.activate - def test_request_failure_due_to_validation_error(self, connection): + def test_request_failure_due_to_validation_error(self, connection, meta, match): """Tests that an HTTPError is raised when the HTTP response of a connection request indicates that a validation error occurred and the body of the response contains a non-empty "meta" field. """ - body = {"code": "validation-error", "meta": {"_schema": ["Foo", "Bar"], "size": ["Baz"]}} + body = {"code": "validation-error", "meta": meta} responses.add(responses.GET, connection.url("healthz"), status=400, body=json.dumps(body)) - with pytest.raises(HTTPError, match=r"Foo; Bar; Baz"): + with pytest.raises(HTTPError, match=match): connection.request("GET", "/healthz") @responses.activate diff --git a/tests/test_job.py b/tests/test_job.py index f4a0598..254cc79 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -54,15 +54,50 @@ class TestJob: """Tests the :class:`xcc.Job` class.""" @pytest.mark.parametrize( - "limit, want_names", + "limit, ids, want_params, want_names", [ - (1, ["foo"]), - (2, ["foo", "bar"]), + ( + 1, + None, + {"size": "1"}, + ["foo"], + ), + ( + 2, + None, + {"size": "2"}, + ["foo", "bar"], + ), + ( + 1, + ["00000000-0000-0000-0000-000000000001"], + {"size": "1", "id": "00000000-0000-0000-0000-000000000001"}, + ["foo"], + ), + ( + 2, + [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003", + ], + { + "size": "3", + "id": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003", + ], + }, + ["foo", "bar"], + ), ], ) @responses.activate - def test_list(self, connection, add_response, limit, want_names): - """Tests that the correct jobs are listed.""" + def test_list(self, connection, add_response, limit, ids, want_params, want_names): + """Tests that the correct jobs are listed and that the correct query + parameters are encoded in the HTTP request to the Xanadu Cloud platform. + """ data = [ { "id": "00000000-0000-0000-0000-000000000001", @@ -82,11 +117,10 @@ def test_list(self, connection, add_response, limit, want_names): ][:limit] add_response(body={"data": data}, path="/jobs") - have_names = [job.name for job in xcc.Job.list(connection, limit=limit)] + have_names = [job.name for job in xcc.Job.list(connection, limit=limit, ids=ids)] assert have_names == want_names have_params = responses.calls[0].request.params # pyright: reportGeneralTypeIssues=false - want_params = {"size": str(limit)} assert have_params == want_params @pytest.mark.parametrize("name", [None, "foo"]) diff --git a/xcc/commands.py b/xcc/commands.py index 32cc0d2..8372c9a 100644 --- a/xcc/commands.py +++ b/xcc/commands.py @@ -5,7 +5,7 @@ import json import sys from functools import wraps -from typing import Any, Callable, Mapping, Sequence, Tuple, Union +from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union import fire from fire.core import FireError @@ -257,16 +257,18 @@ def get_job( @beautify -def list_jobs(limit: int = 10) -> Sequence[Mapping]: +def list_jobs(limit: int = 5, ids: List[str] = None) -> Sequence[Mapping]: """Lists jobs submitted to the Xanadu Cloud. Args: limit (int): Maximum number of jobs to display. + ids (List[str], optional): IDs of the jobs to display. If at least one + ID is specified, the limit flag will be set to the number of IDs. Returns: Sequence[Mapping]: Overview of each job submitted to the Xanadu Cloud. """ - jobs = Job.list(connection=load_connection(), limit=limit) + jobs = Job.list(connection=load_connection(), limit=limit, ids=ids) return [job.overview for job in jobs] diff --git a/xcc/connection.py b/xcc/connection.py index 64dfc29..bcac06c 100644 --- a/xcc/connection.py +++ b/xcc/connection.py @@ -4,7 +4,7 @@ from __future__ import annotations from itertools import chain -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union import requests @@ -253,9 +253,16 @@ def request(self, method: str, path: str, **kwargs) -> requests.Response: else: # The details of a validation error are encoded in the "meta" field. if response.status_code == 400 and body.get("code", "") == "validation-error": - errors: Dict[str, List[str]] = body.get("meta", {}) - if errors: - message = "; ".join(chain.from_iterable(errors.values())) + meta: Dict[str, Union[List[str], Dict[str, List[str]]]] = body.get("meta", {}) + if meta: + errors = [] + for entry in meta.values(): + if isinstance(entry, list): + errors.extend(entry) + else: + errors.extend(chain.from_iterable(entry.values())) + + message = "; ".join(errors) raise requests.exceptions.HTTPError(message, response=response) # Otherwise, the details of the error may be encoded in the "detail" field. diff --git a/xcc/job.py b/xcc/job.py index e0f4bff..275d6d5 100644 --- a/xcc/job.py +++ b/xcc/job.py @@ -8,7 +8,7 @@ import time from datetime import datetime, timedelta from itertools import count, takewhile -from typing import Any, List, Mapping, Optional, Sequence, Union +from typing import Any, Collection, List, Mapping, Optional, Sequence, Union import dateutil.parser import numpy as np @@ -92,18 +92,24 @@ class Job: """ @staticmethod - def list(connection: Connection, limit: int = 5) -> Sequence[Job]: + def list( + connection: Connection, limit: int = 5, ids: Optional[Collection[str]] = None + ) -> Sequence[Job]: """Returns jobs submitted to the Xanadu Cloud. Args: connection (Connection): connection to the Xanadu Cloud limit (int): maximum number of jobs to retrieve + ids (Collection[str], optional): IDs of the jobs to retrieve; if at + least one ID is specified, ``limit`` will be set to the length + of the ID collection Returns: Sequence[Job]: jobs which were submitted on the Xanadu Cloud by the user associated with the Xanadu Cloud connection """ - response = connection.request("GET", "/jobs", params={"size": limit}) + size = len(ids) if ids else limit + response = connection.request("GET", "/jobs", params={"size": size, "id": ids}) jobs = []