From 83ef861db220fe3dd0280f1320e55b199ca62073 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 2 Jun 2026 16:00:17 +0100 Subject: [PATCH 1/5] feat: add support for polling/status API. related-to: https://github.com/quadsproject/quads/issues/661 --- .readthedocs.yml | 2 +- src/quads_lib/quads.py | 29 +++++++- tests/test_quads.py | 159 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 175 insertions(+), 15 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 009a913..7e3cf27 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,4 +1,4 @@ -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +# See https://docs.readthedocs.io/en/latest/config-file/v2.html for details version: 2 sphinx: configuration: docs/conf.py diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index 450020d..c3a6d2a 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -214,7 +214,9 @@ def filter_available(self, data: dict) -> dict: def create_assignment(self, data: dict) -> dict: response = self.post("assignments", data) if response and {"id", "cloud"} <= response.keys(): - print(f"Assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}") + print( + f"Assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}" + ) return response @returns("Assignment") @@ -222,7 +224,9 @@ def create_self_assignment(self, data: dict) -> dict: endpoint = Path("assignments") / "self" response = self.post(str(endpoint), data) if response and {"id", "cloud"} <= response.keys(): - print(f"Self-assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}") + print( + f"Self-assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}" + ) return response @returns("Assignment") @@ -367,6 +371,27 @@ def get_moves(self, date: Optional[str] = None) -> dict: json_response = self.get(url) return json_response + # Move Progress + def get_all_move_progress(self, cloud: Optional[str] = None) -> dict: + url = "moves/progress" + if cloud: + url = f"{url}?{urlencode({'cloud': cloud})}" + return self.get(url) + + def get_move_progress(self, hostname: str) -> dict: + endpoint = Path("moves") / "progress" / hostname + return self.get(str(endpoint)) + + def create_move_progress(self, data: dict) -> dict: + return self.post("moves/progress", data) + + def create_move_progress_batch(self, records: list) -> dict: + return self.post("moves/progress/batch", {"records": records}) + + def update_move_progress(self, progress_id: int, data: dict) -> dict: + endpoint = Path("moves") / "progress" / str(progress_id) + return self.patch(str(endpoint), data) + def get_version(self) -> dict: json_response = self.get("version") return json_response diff --git a/tests/test_quads.py b/tests/test_quads.py index 05eb153..7930bf4 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -933,7 +933,11 @@ def test_get_schedules(self, mock_get): @patch("requests.Session.request") def test_get_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1", "start": "2024-03-20"} - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -941,14 +945,20 @@ def test_get_schedules_with_params(self, mock_get): result = self.api.get_schedules(query_data) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith("/schedules?cloud=cloud1&start=2024-03-20") or str(mock_get.call_args[0][1]).endswith( + assert str(mock_get.call_args[0][1]).endswith( + "/schedules?cloud=cloud1&start=2024-03-20" + ) or str(mock_get.call_args[0][1]).endswith( "/schedules?start=2024-03-20&cloud=cloud1" ) assert result == expected_response @patch("requests.Session.request") def test_get_current_schedules(self, mock_get): - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -962,7 +972,11 @@ def test_get_current_schedules(self, mock_get): @patch("requests.Session.request") def test_get_current_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1041,7 +1055,11 @@ def test_get_future_schedules(self, mock_get): @patch("requests.Session.request") def test_get_future_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1293,7 +1311,9 @@ def test_update_notification(self, mock_patch): result = self.api.update_notification(notification_id, update_data) mock_patch.assert_called_once() - assert str(mock_patch.call_args[0][1]).endswith(f"/notifications/{notification_id}") + assert str(mock_patch.call_args[0][1]).endswith( + f"/notifications/{notification_id}" + ) assert mock_patch.call_args[1]["json"] == update_data assert result == update_data @@ -1358,7 +1378,9 @@ def test_get_active_cloud_assignment(self, mock_get): result = self.api.get_active_cloud_assignment(cloud_name) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith(f"/assignments/active/{cloud_name}") + assert str(mock_get.call_args[0][1]).endswith( + f"/assignments/active/{cloud_name}" + ) assert result == expected_response @patch("requests.Session.request") @@ -1444,7 +1466,9 @@ def test_remove_interface(self, mock_delete): result = self.api.remove_interface(hostname, if_name) mock_delete.assert_called_once() - assert str(mock_delete.call_args[0][1]).endswith(f"/interfaces/{hostname}/{if_name}") + assert str(mock_delete.call_args[0][1]).endswith( + f"/interfaces/{hostname}/{if_name}" + ) assert result == {} @patch("requests.Session.request") @@ -1752,7 +1776,11 @@ def test_get_moves(self, mock_get): @patch("requests.Session.request") def test_get_moves_with_date(self, mock_get): date = "2024-03-20" - expected_response = {"moves": [{"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"}]} + expected_response = { + "moves": [ + {"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1763,6 +1791,107 @@ def test_get_moves_with_date(self, mock_get): assert str(mock_get.call_args[0][1]).endswith(f"/moves?date={date}") assert result == expected_response + # Move Progress + @patch("requests.Session.request") + def test_get_all_move_progress(self, mock_get): + expected_response = [ + {"id": 1, "host": "host1", "status": "pending"}, + {"id": 2, "host": "host2", "status": "ipmi_config"}, + ] + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_get.return_value = mock_response + + result = self.api.get_all_move_progress() + + mock_get.assert_called_once() + assert str(mock_get.call_args[0][1]).endswith("/moves/progress") + assert result == expected_response + + @patch("requests.Session.request") + def test_get_all_move_progress_with_cloud(self, mock_get): + expected_response = [{"id": 1, "host": "host1", "status": "pending"}] + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_get.return_value = mock_response + + result = self.api.get_all_move_progress(cloud="cloud02") + + mock_get.assert_called_once() + assert "cloud=cloud02" in str(mock_get.call_args[0][1]) + assert result == expected_response + + @patch("requests.Session.request") + def test_get_move_progress(self, mock_get): + expected_response = {"id": 1, "host": "host1", "status": "provisioning"} + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_get.return_value = mock_response + + result = self.api.get_move_progress("host1") + + mock_get.assert_called_once() + assert str(mock_get.call_args[0][1]).endswith("/moves/progress/host1") + assert result == expected_response + + @patch("requests.Session.request") + def test_create_move_progress(self, mock_post): + data = { + "hostname": "host1", + "source_cloud": "cloud01", + "target_cloud": "cloud02", + } + expected_response = {"id": 1, "host": "host1", "status": "pending"} + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_post.return_value = mock_response + + result = self.api.create_move_progress(data) + + mock_post.assert_called_once() + assert str(mock_post.call_args[0][1]).endswith("/moves/progress") + assert result == expected_response + + @patch("requests.Session.request") + def test_create_move_progress_batch(self, mock_post): + records = [ + {"hostname": "host1", "source_cloud": "cloud01", "target_cloud": "cloud02"}, + {"hostname": "host2", "source_cloud": "cloud01", "target_cloud": "cloud03"}, + ] + expected_response = {"host1": 1, "host2": 2} + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_post.return_value = mock_response + + result = self.api.create_move_progress_batch(records) + + mock_post.assert_called_once() + assert str(mock_post.call_args[0][1]).endswith("/moves/progress/batch") + assert result == expected_response + + @patch("requests.Session.request") + def test_update_move_progress(self, mock_patch): + data = {"status": "ipmi_config", "message": "IPMI configured"} + expected_response = {"id": 1, "host": "host1", "status": "ipmi_config"} + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_patch.return_value = mock_response + + result = self.api.update_move_progress(1, data) + + mock_patch.assert_called_once() + assert str(mock_patch.call_args[0][1]).endswith("/moves/progress/1") + assert result == expected_response + + @patch("requests.Session.request") + def test_get_move_progress_error(self, mock_get): + mock_response = Mock() + mock_response.status_code = 500 + mock_get.return_value = mock_response + + with pytest.raises(APIServerException, match="Check the flask server logs"): + self.api.get_move_progress("host1") + @patch("requests.Session.request") def test_get_version(self, mock_get): expected_response = {"version": "1.0.0", "api_version": "2.0"} @@ -1843,7 +1972,9 @@ def test_create_self_assignment(self, mock_post): with patch.object(self.api, "post") as mock_post: mock_post.return_value = expected_response result = self.api.create_self_assignment(test_data) - mock_post.assert_called_once_with(str(Path("assignments") / "self"), test_data) + mock_post.assert_called_once_with( + str(Path("assignments") / "self"), test_data + ) assert result == expected_response @patch("requests.Session.request") @@ -2019,7 +2150,9 @@ def test_create_self_assignment_logging(self, mock_request, mock_print): self.api.create_self_assignment(assignment_data) - mock_print.assert_called_once_with("Self-assignment created - ID: 123, Cloud: cloud1") + mock_print.assert_called_once_with( + "Self-assignment created - ID: 123, Cloud: cloud1" + ) @patch("builtins.print") @patch("requests.Session.request") @@ -2127,7 +2260,9 @@ def setup(self): @pytest.fixture def quads_base(self): - return QuadsBase(username=self.username, password=self.password, base_url=self.base_url) + return QuadsBase( + username=self.username, password=self.password, base_url=self.base_url + ) def test_context_manager_enter(self, quads_base): quads_base.login = Mock() From 18213b3b65948841ac3d1d6a7ceb3a2a435ebb85 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 2 Jun 2026 16:40:39 +0100 Subject: [PATCH 2/5] feat: docs sweep, bump version. * proper napoleon style RST docstring / references --- CHANGELOG.rst | 6 ++++++ docs/usage.rst | 15 ++++++++++++++ setup.py | 2 +- src/quads_lib/__init__.py | 2 +- src/quads_lib/quads.py | 42 +++++++++++++++++++++++++++++++++++---- tests/test_quads.py | 18 +++++++++++++++-- 6 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f6b010b..879b5e7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Changelog ========= +0.1.16 (2026-06-02) +------------------- + +* Add move progress API methods: ``get_all_move_progress``, ``get_move_progress``, + ``create_move_progress``, ``create_move_progress_batch``, ``update_move_progress``. + 0.0.0 (2025-01-07) ------------------ diff --git a/docs/usage.rst b/docs/usage.rst index c84cc47..93973c6 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -36,3 +36,18 @@ You can control certificate verification using the ``verify`` parameter: # Use a custom CA bundle file quads = QuadsApi(username, password, base_url, verify="/path/to/ca-bundle.pem") + +Tracking Move Progress +---------------------- + +Query the 12-stage provisioning pipeline for active host moves: + +.. code-block:: python + + with QuadsApi(username, password, base_url) as quads: + # All active moves, optionally filtered by cloud or status + moves = quads.get_all_move_progress(cloud="cloud02") + + # Single host progress + progress = quads.get_move_progress("host01.example.com") + print(f"{progress['host']}: {progress['status']}") diff --git a/setup.py b/setup.py index 8196c03..821bf44 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name="quads-lib", - version="0.1.15", + version="0.1.16", license="LGPL-3.0-only", description="Python client library for interacting with the QUADS API", long_description="{}\n{}".format( diff --git a/src/quads_lib/__init__.py b/src/quads_lib/__init__.py index 9e04bab..38252f5 100644 --- a/src/quads_lib/__init__.py +++ b/src/quads_lib/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.15" +__version__ = "0.1.16" from .quads import QuadsApi diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index c3a6d2a..dde9452 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -372,23 +372,57 @@ def get_moves(self, date: Optional[str] = None) -> dict: return json_response # Move Progress - def get_all_move_progress(self, cloud: Optional[str] = None) -> dict: - url = "moves/progress" + def get_all_move_progress( + self, cloud: Optional[str] = None, status: Optional[str] = None + ) -> dict: + """Retrieve all active move progress records. + + Args: + cloud: Filter by target cloud name. + status: Filter by progress status (e.g. provisioning, failed). + """ + url = "moves/progress/" + params = {} if cloud: - url = f"{url}?{urlencode({'cloud': cloud})}" + params["cloud"] = cloud + if status: + params["status"] = status + if params: + url = f"{url}?{urlencode(params)}" return self.get(url) def get_move_progress(self, hostname: str) -> dict: + """Retrieve move progress for a specific host. + + Args: + hostname: The host to query progress for. + """ endpoint = Path("moves") / "progress" / hostname return self.get(str(endpoint)) def create_move_progress(self, data: dict) -> dict: - return self.post("moves/progress", data) + """Create a move progress record. Requires admin auth. + + Args: + data: Dict with hostname, source_cloud, target_cloud, and optional schedule_id. + """ + return self.post("moves/progress/", data) def create_move_progress_batch(self, records: list) -> dict: + """Batch-create move progress records. Requires admin auth. + + Args: + records: List of dicts, each with hostname, source_cloud, target_cloud. + """ return self.post("moves/progress/batch", {"records": records}) def update_move_progress(self, progress_id: int, data: dict) -> dict: + """Update a move progress record. Requires admin auth. + + Args: + progress_id: The progress record ID to update. + data: Dict with any of status, message, error_message. + """ endpoint = Path("moves") / "progress" / str(progress_id) return self.patch(str(endpoint), data) diff --git a/tests/test_quads.py b/tests/test_quads.py index 7930bf4..aaca938 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -1805,7 +1805,7 @@ def test_get_all_move_progress(self, mock_get): result = self.api.get_all_move_progress() mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith("/moves/progress") + assert str(mock_get.call_args[0][1]).endswith("/moves/progress/") assert result == expected_response @patch("requests.Session.request") @@ -1821,6 +1821,19 @@ def test_get_all_move_progress_with_cloud(self, mock_get): assert "cloud=cloud02" in str(mock_get.call_args[0][1]) assert result == expected_response + @patch("requests.Session.request") + def test_get_all_move_progress_with_status(self, mock_get): + expected_response = [{"id": 1, "host": "host1", "status": "provisioning"}] + mock_response = Mock() + mock_response.json.return_value = expected_response + mock_get.return_value = mock_response + + result = self.api.get_all_move_progress(status="provisioning") + + mock_get.assert_called_once() + assert "status=provisioning" in str(mock_get.call_args[0][1]) + assert result == expected_response + @patch("requests.Session.request") def test_get_move_progress(self, mock_get): expected_response = {"id": 1, "host": "host1", "status": "provisioning"} @@ -1849,7 +1862,7 @@ def test_create_move_progress(self, mock_post): result = self.api.create_move_progress(data) mock_post.assert_called_once() - assert str(mock_post.call_args[0][1]).endswith("/moves/progress") + assert str(mock_post.call_args[0][1]).endswith("/moves/progress/") assert result == expected_response @patch("requests.Session.request") @@ -1867,6 +1880,7 @@ def test_create_move_progress_batch(self, mock_post): mock_post.assert_called_once() assert str(mock_post.call_args[0][1]).endswith("/moves/progress/batch") + assert mock_post.call_args[1]["json"] == {"records": records} assert result == expected_response @patch("requests.Session.request") From 213c7a205b9e0d8373da584897d27405ba228c7f Mon Sep 17 00:00:00 2001 From: Will Foster Date: Tue, 2 Jun 2026 16:52:57 +0100 Subject: [PATCH 3/5] chore: fix test linting --- src/quads_lib/quads.py | 12 +++------ tests/test_quads.py | 58 +++++++++--------------------------------- 2 files changed, 15 insertions(+), 55 deletions(-) diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index dde9452..c3297a1 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -214,9 +214,7 @@ def filter_available(self, data: dict) -> dict: def create_assignment(self, data: dict) -> dict: response = self.post("assignments", data) if response and {"id", "cloud"} <= response.keys(): - print( - f"Assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}" - ) + print(f"Assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}") return response @returns("Assignment") @@ -224,9 +222,7 @@ def create_self_assignment(self, data: dict) -> dict: endpoint = Path("assignments") / "self" response = self.post(str(endpoint), data) if response and {"id", "cloud"} <= response.keys(): - print( - f"Self-assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}" - ) + print(f"Self-assignment created - ID: {response['id']}, Cloud: {response['cloud']['name']}") return response @returns("Assignment") @@ -372,9 +368,7 @@ def get_moves(self, date: Optional[str] = None) -> dict: return json_response # Move Progress - def get_all_move_progress( - self, cloud: Optional[str] = None, status: Optional[str] = None - ) -> dict: + def get_all_move_progress(self, cloud: Optional[str] = None, status: Optional[str] = None) -> dict: """Retrieve all active move progress records. Args: diff --git a/tests/test_quads.py b/tests/test_quads.py index aaca938..a591b7d 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -933,11 +933,7 @@ def test_get_schedules(self, mock_get): @patch("requests.Session.request") def test_get_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1", "start": "2024-03-20"} - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -945,20 +941,14 @@ def test_get_schedules_with_params(self, mock_get): result = self.api.get_schedules(query_data) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith( - "/schedules?cloud=cloud1&start=2024-03-20" - ) or str(mock_get.call_args[0][1]).endswith( + assert str(mock_get.call_args[0][1]).endswith("/schedules?cloud=cloud1&start=2024-03-20") or str(mock_get.call_args[0][1]).endswith( "/schedules?start=2024-03-20&cloud=cloud1" ) assert result == expected_response @patch("requests.Session.request") def test_get_current_schedules(self, mock_get): - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -972,11 +962,7 @@ def test_get_current_schedules(self, mock_get): @patch("requests.Session.request") def test_get_current_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1055,11 +1041,7 @@ def test_get_future_schedules(self, mock_get): @patch("requests.Session.request") def test_get_future_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1311,9 +1293,7 @@ def test_update_notification(self, mock_patch): result = self.api.update_notification(notification_id, update_data) mock_patch.assert_called_once() - assert str(mock_patch.call_args[0][1]).endswith( - f"/notifications/{notification_id}" - ) + assert str(mock_patch.call_args[0][1]).endswith(f"/notifications/{notification_id}") assert mock_patch.call_args[1]["json"] == update_data assert result == update_data @@ -1378,9 +1358,7 @@ def test_get_active_cloud_assignment(self, mock_get): result = self.api.get_active_cloud_assignment(cloud_name) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith( - f"/assignments/active/{cloud_name}" - ) + assert str(mock_get.call_args[0][1]).endswith(f"/assignments/active/{cloud_name}") assert result == expected_response @patch("requests.Session.request") @@ -1466,9 +1444,7 @@ def test_remove_interface(self, mock_delete): result = self.api.remove_interface(hostname, if_name) mock_delete.assert_called_once() - assert str(mock_delete.call_args[0][1]).endswith( - f"/interfaces/{hostname}/{if_name}" - ) + assert str(mock_delete.call_args[0][1]).endswith(f"/interfaces/{hostname}/{if_name}") assert result == {} @patch("requests.Session.request") @@ -1776,11 +1752,7 @@ def test_get_moves(self, mock_get): @patch("requests.Session.request") def test_get_moves_with_date(self, mock_get): date = "2024-03-20" - expected_response = { - "moves": [ - {"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"} - ] - } + expected_response = {"moves": [{"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1986,9 +1958,7 @@ def test_create_self_assignment(self, mock_post): with patch.object(self.api, "post") as mock_post: mock_post.return_value = expected_response result = self.api.create_self_assignment(test_data) - mock_post.assert_called_once_with( - str(Path("assignments") / "self"), test_data - ) + mock_post.assert_called_once_with(str(Path("assignments") / "self"), test_data) assert result == expected_response @patch("requests.Session.request") @@ -2164,9 +2134,7 @@ def test_create_self_assignment_logging(self, mock_request, mock_print): self.api.create_self_assignment(assignment_data) - mock_print.assert_called_once_with( - "Self-assignment created - ID: 123, Cloud: cloud1" - ) + mock_print.assert_called_once_with("Self-assignment created - ID: 123, Cloud: cloud1") @patch("builtins.print") @patch("requests.Session.request") @@ -2274,9 +2242,7 @@ def setup(self): @pytest.fixture def quads_base(self): - return QuadsBase( - username=self.username, password=self.password, base_url=self.base_url - ) + return QuadsBase(username=self.username, password=self.password, base_url=self.base_url) def test_context_manager_enter(self, quads_base): quads_base.login = Mock() From 5876599507df2d136ab705348c85736fe33681d8 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Thu, 4 Jun 2026 11:16:08 +0100 Subject: [PATCH 4/5] fix: refactor for simplifying MoveProgress DB structure. --- src/quads_lib/quads.py | 36 +++++++++++----------------- tests/test_quads.py | 53 +++++++++++++----------------------------- 2 files changed, 30 insertions(+), 59 deletions(-) diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index c3297a1..4af6862 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -368,12 +368,12 @@ def get_moves(self, date: Optional[str] = None) -> dict: return json_response # Move Progress - def get_all_move_progress(self, cloud: Optional[str] = None, status: Optional[str] = None) -> dict: - """Retrieve all active move progress records. + def get_all_move_status(self, cloud: Optional[str] = None, status: Optional[str] = None) -> dict: + """Retrieve all active move status records. Args: cloud: Filter by target cloud name. - status: Filter by progress status (e.g. provisioning, failed). + status: Filter by move status (e.g. provisioning, failed). """ url = "moves/progress/" params = {} @@ -385,39 +385,31 @@ def get_all_move_progress(self, cloud: Optional[str] = None, status: Optional[st url = f"{url}?{urlencode(params)}" return self.get(url) - def get_move_progress(self, hostname: str) -> dict: - """Retrieve move progress for a specific host. + def get_move_status(self, hostname: str) -> dict: + """Retrieve move status for a specific host. Args: - hostname: The host to query progress for. + hostname: The host to query status for. """ endpoint = Path("moves") / "progress" / hostname return self.get(str(endpoint)) - def create_move_progress(self, data: dict) -> dict: - """Create a move progress record. Requires admin auth. + def start_move_batch(self, hostnames: list) -> dict: + """Start move tracking for a batch of hosts. Requires admin auth. Args: - data: Dict with hostname, source_cloud, target_cloud, and optional schedule_id. + hostnames: List of hostnames to start tracking. """ - return self.post("moves/progress/", data) + return self.post("moves/progress/batch", {"hostnames": hostnames}) - def create_move_progress_batch(self, records: list) -> dict: - """Batch-create move progress records. Requires admin auth. + def update_move_status(self, schedule_id: int, data: dict) -> dict: + """Update move status on a schedule. Requires admin auth. Args: - records: List of dicts, each with hostname, source_cloud, target_cloud. - """ - return self.post("moves/progress/batch", {"records": records}) - - def update_move_progress(self, progress_id: int, data: dict) -> dict: - """Update a move progress record. Requires admin auth. - - Args: - progress_id: The progress record ID to update. + schedule_id: The schedule ID to update. data: Dict with any of status, message, error_message. """ - endpoint = Path("moves") / "progress" / str(progress_id) + endpoint = Path("moves") / "progress" / str(schedule_id) return self.patch(str(endpoint), data) def get_version(self) -> dict: diff --git a/tests/test_quads.py b/tests/test_quads.py index a591b7d..8bec887 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -1765,7 +1765,7 @@ def test_get_moves_with_date(self, mock_get): # Move Progress @patch("requests.Session.request") - def test_get_all_move_progress(self, mock_get): + def test_get_all_move_status(self, mock_get): expected_response = [ {"id": 1, "host": "host1", "status": "pending"}, {"id": 2, "host": "host2", "status": "ipmi_config"}, @@ -1774,109 +1774,88 @@ def test_get_all_move_progress(self, mock_get): mock_response.json.return_value = expected_response mock_get.return_value = mock_response - result = self.api.get_all_move_progress() + result = self.api.get_all_move_status() mock_get.assert_called_once() assert str(mock_get.call_args[0][1]).endswith("/moves/progress/") assert result == expected_response @patch("requests.Session.request") - def test_get_all_move_progress_with_cloud(self, mock_get): + def test_get_all_move_status_with_cloud(self, mock_get): expected_response = [{"id": 1, "host": "host1", "status": "pending"}] mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response - result = self.api.get_all_move_progress(cloud="cloud02") + result = self.api.get_all_move_status(cloud="cloud02") mock_get.assert_called_once() assert "cloud=cloud02" in str(mock_get.call_args[0][1]) assert result == expected_response @patch("requests.Session.request") - def test_get_all_move_progress_with_status(self, mock_get): + def test_get_all_move_status_with_status(self, mock_get): expected_response = [{"id": 1, "host": "host1", "status": "provisioning"}] mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response - result = self.api.get_all_move_progress(status="provisioning") + result = self.api.get_all_move_status(status="provisioning") mock_get.assert_called_once() assert "status=provisioning" in str(mock_get.call_args[0][1]) assert result == expected_response @patch("requests.Session.request") - def test_get_move_progress(self, mock_get): + def test_get_move_status(self, mock_get): expected_response = {"id": 1, "host": "host1", "status": "provisioning"} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response - result = self.api.get_move_progress("host1") + result = self.api.get_move_status("host1") mock_get.assert_called_once() assert str(mock_get.call_args[0][1]).endswith("/moves/progress/host1") assert result == expected_response @patch("requests.Session.request") - def test_create_move_progress(self, mock_post): - data = { - "hostname": "host1", - "source_cloud": "cloud01", - "target_cloud": "cloud02", - } - expected_response = {"id": 1, "host": "host1", "status": "pending"} - mock_response = Mock() - mock_response.json.return_value = expected_response - mock_post.return_value = mock_response - - result = self.api.create_move_progress(data) - - mock_post.assert_called_once() - assert str(mock_post.call_args[0][1]).endswith("/moves/progress/") - assert result == expected_response - - @patch("requests.Session.request") - def test_create_move_progress_batch(self, mock_post): - records = [ - {"hostname": "host1", "source_cloud": "cloud01", "target_cloud": "cloud02"}, - {"hostname": "host2", "source_cloud": "cloud01", "target_cloud": "cloud03"}, - ] + def test_start_move_batch(self, mock_post): + hostnames = ["host1", "host2"] expected_response = {"host1": 1, "host2": 2} mock_response = Mock() mock_response.json.return_value = expected_response mock_post.return_value = mock_response - result = self.api.create_move_progress_batch(records) + result = self.api.start_move_batch(hostnames) mock_post.assert_called_once() assert str(mock_post.call_args[0][1]).endswith("/moves/progress/batch") - assert mock_post.call_args[1]["json"] == {"records": records} + assert mock_post.call_args[1]["json"] == {"hostnames": hostnames} assert result == expected_response @patch("requests.Session.request") - def test_update_move_progress(self, mock_patch): + def test_update_move_status(self, mock_patch): data = {"status": "ipmi_config", "message": "IPMI configured"} expected_response = {"id": 1, "host": "host1", "status": "ipmi_config"} mock_response = Mock() mock_response.json.return_value = expected_response mock_patch.return_value = mock_response - result = self.api.update_move_progress(1, data) + result = self.api.update_move_status(1, data) mock_patch.assert_called_once() assert str(mock_patch.call_args[0][1]).endswith("/moves/progress/1") assert result == expected_response @patch("requests.Session.request") - def test_get_move_progress_error(self, mock_get): + def test_get_move_status_error(self, mock_get): mock_response = Mock() mock_response.status_code = 500 mock_get.return_value = mock_response with pytest.raises(APIServerException, match="Check the flask server logs"): - self.api.get_move_progress("host1") + self.api.get_move_status("host1") @patch("requests.Session.request") def test_get_version(self, mock_get): From 76f38333466baf52644425b9c6c1b8bd57026c8a Mon Sep 17 00:00:00 2001 From: Will Foster Date: Thu, 4 Jun 2026 16:55:55 +0100 Subject: [PATCH 5/5] fix: re-align with MoveStatus enum refactor --- CHANGELOG.rst | 4 ++-- docs/usage.rst | 10 +++++----- src/quads_lib/quads.py | 2 +- tests/test_quads.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 879b5e7..7b12f76 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,8 +5,8 @@ Changelog 0.1.16 (2026-06-02) ------------------- -* Add move progress API methods: ``get_all_move_progress``, ``get_move_progress``, - ``create_move_progress``, ``create_move_progress_batch``, ``update_move_progress``. +* Add move status API methods: ``get_all_move_status``, ``get_move_status``, + ``start_move_batch``, ``update_move_status``. 0.0.0 (2025-01-07) ------------------ diff --git a/docs/usage.rst b/docs/usage.rst index 93973c6..60283d8 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -37,8 +37,8 @@ You can control certificate verification using the ``verify`` parameter: # Use a custom CA bundle file quads = QuadsApi(username, password, base_url, verify="/path/to/ca-bundle.pem") -Tracking Move Progress ----------------------- +Tracking Move Status +-------------------- Query the 12-stage provisioning pipeline for active host moves: @@ -46,8 +46,8 @@ Query the 12-stage provisioning pipeline for active host moves: with QuadsApi(username, password, base_url) as quads: # All active moves, optionally filtered by cloud or status - moves = quads.get_all_move_progress(cloud="cloud02") + moves = quads.get_all_move_status(cloud="cloud02") - # Single host progress - progress = quads.get_move_progress("host01.example.com") + # Single host status + progress = quads.get_move_status("host01.example.com") print(f"{progress['host']}: {progress['status']}") diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index 4af6862..5ea56ba 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -367,7 +367,7 @@ def get_moves(self, date: Optional[str] = None) -> dict: json_response = self.get(url) return json_response - # Move Progress + # Move Status def get_all_move_status(self, cloud: Optional[str] = None, status: Optional[str] = None) -> dict: """Retrieve all active move status records. diff --git a/tests/test_quads.py b/tests/test_quads.py index 8bec887..9bedc57 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -1763,7 +1763,7 @@ def test_get_moves_with_date(self, mock_get): assert str(mock_get.call_args[0][1]).endswith(f"/moves?date={date}") assert result == expected_response - # Move Progress + # Move Status @patch("requests.Session.request") def test_get_all_move_status(self, mock_get): expected_response = [