From d1cfeaf9a9a733555d49ea0929d498931f1c23a6 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Sun, 11 Oct 2026 17:39:30 +0100 Subject: [PATCH] docs: make free-cloud detection canonical in get_free_clouds Document get_free_clouds as the canonical free-cloud API: server-side semantics (no active assignment, future-scheduled clouds excluded via their active assignment, spare pool excluded via server config), the response contract including the last_redefined reservation-lock field, and APINotFound on servers older than 2.1 lacking /clouds/free/. Pin the contract in tests (full payload passthrough, 404 handling) and add a usage docs section with the reservation-lock hint recipe. Version bump to 0.1.18. fixes: https://github.com/quadsproject/python-quads-lib/issues/76 --- CHANGELOG.rst | 8 ++++++++ docs/usage.rst | 43 +++++++++++++++++++++++++++++++++++++++ rpm/quads-lib.spec | 2 +- setup.py | 2 +- src/quads_lib/__init__.py | 2 +- src/quads_lib/quads.py | 20 ++++++++++++++++++ tests/test_quads.py | 26 ++++++++++++++++------- 7 files changed, 93 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a13fba8..5e3d695 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,14 @@ Changelog ========= +0.1.18 (2026-10-11) +------------------- + +* Document ``get_free_clouds`` as the canonical free-cloud API (server-side + semantics: no active assignment, future-scheduled clouds excluded, spare + pool excluded via server config) and pin its response contract, including + the ``last_redefined`` reservation-lock field, in tests. + 0.1.17 (2026-06-08) ------------------- diff --git a/docs/usage.rst b/docs/usage.rst index 60283d8..7d9ea6f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -51,3 +51,46 @@ Query the 12-stage provisioning pipeline for active host moves: # Single host status progress = quads.get_move_status("host01.example.com") print(f"{progress['host']}: {progress['status']}") + +Finding Free Clouds +------------------- + +``get_free_clouds()`` is the canonical free-cloud detection API. It calls the +server's ``GET /clouds/free/`` and returns the raw JSON list, so the server +decides what "free" means instead of each client reimplementing schedule +logic. A cloud is free when it has no active assignment, which also excludes +clouds with future schedules, and the spare pool is excluded server-side via +the server's ``spare_pool_name`` config (no client-side cloud name to keep +in sync): + +.. code-block:: python + + with QuadsApi(username, password, base_url) as quads: + for cloud in quads.get_free_clouds(): + print(cloud["name"]) + +Each cloud dict carries ``last_redefined`` as an RFC 1123 GMT string, so a +client can render the same reservation-lock hint the quads server CLI shows. +The lock window itself (``cloud_reservation_lock`` hours) is server-side +config and is not exposed over the API, so keep it in client config: + +.. code-block:: python + + from datetime import datetime, timedelta + from email.utils import parsedate_to_datetime + + lock_hours = 48 # mirror the server's cloud_reservation_lock + for cloud in quads.get_free_clouds(): + last_redefined = parsedate_to_datetime(cloud["last_redefined"]) + lock_release = last_redefined + timedelta(hours=lock_hours) + if lock_release > datetime.now(tz=last_redefined.tzinfo): + remaining = lock_release - datetime.now(tz=last_redefined.tzinfo) + hours = remaining.total_seconds() // 3600 + minutes = (remaining.total_seconds() % 3600) // 60 + print(f"{cloud['name']} (reserved: {hours:.0f}hr {minutes:.0f}min remaining)") + +Both snippets assume a live ``quads`` session, e.g. from the ``with`` block +above. + +Older QUADS servers (before 2.1) do not expose ``/clouds/free/``; the call +then raises ``APINotFound``. diff --git a/rpm/quads-lib.spec b/rpm/quads-lib.spec index fa7b3fa..b488244 100644 --- a/rpm/quads-lib.spec +++ b/rpm/quads-lib.spec @@ -12,7 +12,7 @@ %define name quads-lib %define reponame python-quads-lib %define branch development -%define version 0.1.17 +%define version 0.1.18 %define build_timestamp %{lua: print(os.date("%Y%m%d"))} Summary: Python client library for interacting with the QUADS API diff --git a/setup.py b/setup.py index 12ee05d..65c1801 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name="quads-lib", - version="0.1.17", + version="0.1.18", 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 901f216..10f4ba4 100644 --- a/src/quads_lib/__init__.py +++ b/src/quads_lib/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.17" +__version__ = "0.1.18" from .quads import QuadsApi diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index 5ea56ba..5c3bdda 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -106,6 +106,26 @@ def get_clouds(self) -> dict: @returns("List[Cloud]") def get_free_clouds(self) -> dict: + """Retrieve clouds with no active assignment, excluding the spare pool. + + Canonical free-cloud detection, delegating to the server's + ``GET /clouds/free/`` (available since quads-lib 0.1.0). A cloud is + free when it has no assignment with ``active`` set, which also + excludes clouds with future schedules (their assignments are active), + and when it is not the spare pool (excluded server-side via the + server's ``spare_pool_name`` config, not a client-side name). + + Returns: + The raw server response: a JSON list of cloud dicts, each with + ``id``, ``name`` and ``last_redefined`` (RFC 1123 GMT string, + e.g. ``Tue, 19 May 2026 12:00:00 GMT``). Use ``last_redefined`` + plus the server's ``cloud_reservation_lock`` hours to display a + reservation-lock hint. An empty result is ``[]``. + + Raises: + APINotFound: If the server has no such endpoint (QUADS servers + older than 2.1 do not expose ``/clouds/free/``). + """ json_response = self.get("clouds/free/") return json_response diff --git a/tests/test_quads.py b/tests/test_quads.py index 33c79c5..19beec5 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -639,12 +639,10 @@ def test_get_clouds_bad_request(self, mock_get): @patch("requests.Session.request") def test_get_free_clouds(self, mock_get): - expected_response = { - "clouds": [ - {"name": "cloud1", "owner": "user1", "status": "free"}, - {"name": "cloud2", "owner": "user2", "status": "free"}, - ] - } + expected_response = [ + {"id": 2, "name": "cloud02", "last_redefined": "Wed, 07 Oct 2026 11:36:53 GMT"}, + {"id": 3, "name": "cloud03", "last_redefined": "Wed, 07 Oct 2026 09:12:01 GMT"}, + ] mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -654,10 +652,14 @@ def test_get_free_clouds(self, mock_get): mock_get.assert_called_once() assert str(mock_get.call_args[0][1]).endswith("/clouds/free/") assert result == expected_response + # Contract: server response passes through unchanged, including the + # reservation-lock fields the server guarantees on every cloud dict. + assert [cloud["name"] for cloud in result] == ["cloud02", "cloud03"] + assert all(cloud["last_redefined"] for cloud in result) @patch("requests.Session.request") def test_get_free_clouds_empty(self, mock_get): - expected_response = {"clouds": []} + expected_response = [] mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -687,6 +689,16 @@ def test_get_free_clouds_bad_request(self, mock_get): with pytest.raises(APIBadRequest, match="Invalid request"): self.api.get_free_clouds() + @patch("requests.Session.request") + def test_get_free_clouds_not_found(self, mock_get): + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.side_effect = JSONDecodeError("", "", 0) + mock_get.return_value = mock_response + + with pytest.raises(APINotFound, match="Resource not found"): + self.api.get_free_clouds() + @patch("requests.Session.request") def test_get_cloud(self, mock_get): cloud_name = "test-cloud"