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
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-------------------

Expand Down
43 changes: 43 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
2 changes: 1 addition & 1 deletion rpm/quads-lib.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/quads_lib/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "0.1.17"
__version__ = "0.1.18"

from .quads import QuadsApi

Expand Down
20 changes: 20 additions & 0 deletions src/quads_lib/quads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 19 additions & 7 deletions tests/test_quads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading