Skip to content
Open
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
21 changes: 10 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,29 +67,28 @@ step is to create a new core client and then create a new node.

```python
core_client = flame_hub.CoreClient(base_url="http://localhost:3000/core/", auth=auth)
my_node = core_client.create_node(name="my-node", realm_id=master_realm)
my_node = core_client.create_node(name="my-node", realmId=master_realm)

print(my_node.model_dump_json(indent=2))
```

```console
{
"external_name": null,
"externalName": null,
"hidden": false,
"name": "my-node",
"realm_id": "794f2375-f043-4789-bd0c-e5534e8deeaa",
"registry_id": null,
"realmId": "794f2375-f043-4789-bd0c-e5534e8deeaa",
"registryId": null,
"type": "default",
"id": "03636152-e6a8-4e01-994e-18b2b0c3a935",
"public_key": null,
"publicKey": null,
"online": false,
"registry": null,
"registry_project_id": null,
"registry_project": null,
"robot_id": null,
"client_id": "2d3e19b4-6708-4279-b2a7-34ad42638e4b",
"created_at": "2025-05-19T15:43:57.859000Z",
"updated_at": "2025-05-19T15:43:57.859000Z"
"registryProjectId": null,
"registryProject": null,
"clientId": "2d3e19b4-6708-4279-b2a7-34ad42638e4b",
"createdAt": "2025-05-19T15:43:57.859000Z",
"updatedAt": "2025-05-19T15:43:57.859000Z"
}
```

Expand Down
2 changes: 2 additions & 0 deletions docs/clients_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Clients
.. autoclass:: flame_hub.CoreClient
:members:
:undoc-members:
:private-members: _unwrap_single_resource

.. autoclass:: flame_hub.StorageClient
:members:
:undoc-members:
:private-members: _unwrap_single_resource
18 changes: 9 additions & 9 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@ left unset, the client will sort in ascending order by default.

.. code-block:: python

nodes = core_client.find_nodes(sort={"by": "created_at"})
sedon = core_client.find_nodes(sort={"by": "created_at", "order": "descending"})
nodes = core_client.find_nodes(sort={"by": "createdAt"})
sedon = core_client.find_nodes(sort={"by": "createdAt", "order": "descending"})

assert nodes == sedon[::-1]

Expand Down Expand Up @@ -322,7 +322,7 @@ It is also possible to retrieve all names of includable properties for a specifi

.. code-block::

('registry', 'registry_project')
('registry', 'registryProject')


Overriding authentication per request
Expand All @@ -343,7 +343,7 @@ token as a string.
# Or use a dedicated authenticator for this request only.
core_client.create_node(
name="my-node",
realm_id=master_realm,
realmId=master_realm,
auth=flame_hub.auth.PasswordAuth(
username="admin",
password="start123",
Expand Down Expand Up @@ -372,18 +372,18 @@ possible, including status code and additional information in the response body.
core_client = flame_hub.CoreClient(base_url="http://localhost:3000/core/", auth=auth)

try:
core_client.create_node(name="my-new-node", realm_id=str(uuid4()))
core_client.create_node(name="my-new-node", realmId=str(uuid4()))
except flame_hub.HubAPIError as e:
print(e)
print(e.error_response.model_dump_json(indent=2))

.. code-block:: console

received status code 400 (undefined): Can't find realm entity by realm_id
received status code 400 (undefined): Can't find realm entity by realmId
{
"status_code": 400,
"code": "undefined",
"message": "Can't find realm entity by realm_id"
"message": "Can't find realm entity by realmId"
}

In this example a :py:exc:`.HubAPIError` is raised because there is no realm with an ID that matches the dynamically
Expand All @@ -406,14 +406,14 @@ Hub instance.

from flame_hub.models import UpdateNode, UNSET

update_node = UpdateNode(hidden=False, external_name=None, type=UNSET)
update_node = UpdateNode(hidden=False, externalName=None, type=UNSET)
print(update_node.model_dump_json(indent=2, exclude_defaults=True))

.. code-block:: console

{
"hidden": false,
"external_name": null
"externalName": null
}

Check out all implemented models :doc:`here <models_api>`.
Expand Down
2 changes: 2 additions & 0 deletions docs/utility_functions_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ Utility functions
.. autofunction:: flame_hub._base_client.obtain_uuid_from

.. autofunction:: flame_hub._base_client.uuid_validator

.. autofunction:: flame_hub._base_client.unwrap_enveloped_resource
8 changes: 3 additions & 5 deletions flame_hub/_auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ResourceListResult,
AuthParam,
BaseKwargs,
unwrap_enveloped_resource,
)
from flame_hub._defaults import DEFAULT_AUTH_BASE_URL

Expand Down Expand Up @@ -261,12 +262,9 @@ def _unwrap_single_resource(self, body: t.Any) -> t.Any:

See Also
--------
:py:meth:`.BaseClient._unwrap_single_resource`
:py:meth:`.BaseClient._unwrap_single_resource`, :py:func:`.unwrap_enveloped_resource`
"""
if not isinstance(body, dict) or "data" not in body:
raise ValueError("response body is not wrapped in a data property, authup 1.0.0-beta.57 or newer required")

return body["data"]
return unwrap_enveloped_resource(body, "authup 1.0.0-beta.57")

def get_realms(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[Realm]:
return self._get_all_resources(Realm, "realms", **params)
Expand Down
61 changes: 54 additions & 7 deletions flame_hub/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,39 @@ def resolve_auth(auth: AuthParam) -> ClientAuth | PasswordAuth | StaticAuth | No
return auth


def unwrap_enveloped_resource(body: t.Any, requirement: str) -> t.Any:
"""Extract the resource object from a :python:`{"data": ..., "meta": ...}` record envelope.

``meta`` holds response-scoped extras such as the queryable schema of the endpoint and is discarded.

Parameters
----------
body : :py:obj:`~typing.Any`
Deserialized response body of a request which targets a single resource.
requirement : :py:class:`str`
Name and version of the service which introduced the envelope. Named in the error message so that an
outdated deployment is recognizable from the exception alone.

Returns
-------
:py:obj:`~typing.Any`
The object which is validated with the resource model.

Raises
------
:py:exc:`ValueError`
If ``body`` does not carry a ``data`` property, which is the case for services older than ``requirement``.

See Also
--------
:py:meth:`.BaseClient._unwrap_single_resource`
"""
if not isinstance(body, dict) or "data" not in body:
raise ValueError(f"response body is not wrapped in a data property, {requirement} or newer required")

return body["data"]


class BaseClient(object):
"""The base class for other client classes.

Expand Down Expand Up @@ -447,9 +480,8 @@ def __init__(
def _unwrap_single_resource(self, body: t.Any) -> t.Any:
"""Extract the resource object from the body of a single-resource response.

The FLAME Hub core and storage services respond to single-resource requests with the resource object itself,
which is why this implementation returns ``body`` unchanged. Clients whose service wraps the resource in an
envelope override this method.
This implementation returns ``body`` unchanged. Clients whose service wraps the resource in an envelope
override this method, usually by delegating to :py:func:`.unwrap_enveloped_resource`.

Parameters
----------
Expand All @@ -464,7 +496,7 @@ def _unwrap_single_resource(self, body: t.Any) -> t.Any:
See Also
--------
:py:meth:`._get_single_resource`, :py:meth:`._create_resource`, :py:meth:`._update_resource`,\
:py:meth:`.AuthClient._unwrap_single_resource`
:py:func:`.unwrap_enveloped_resource`, :py:meth:`.AuthClient._unwrap_single_resource`
"""
return body

Expand Down Expand Up @@ -624,6 +656,7 @@ def _create_resource(
resource: BaseModel,
*path: str,
expected_code: int = httpx.codes.CREATED.value,
envelope: bool = True,
**params: te.Unpack[BaseKwargs],
) -> ResourceT:
"""Create a resource of a certain type at the specified path.
Expand All @@ -645,6 +678,10 @@ def _create_resource(
Path to the endpoint where the resource should be created.
expected_code : :py:class:`int`
The expected status code of the response from the ``POST`` request. This defaults to ``201``.
envelope : :py:class:`bool`
Whether the endpoint wraps its response in a record envelope. This defaults to :any:`True`. Pass
:any:`False` for the endpoints which deliberately respond with a bare object, such as the client and
registry credential routes.

Returns
-------
Expand All @@ -661,14 +698,15 @@ def _create_resource(

r = self._request("POST", *path, expected_code=expected_code, json=resource.model_dump(mode="json"), **params)

return resource_type(**self._unwrap_single_resource(r.json()))
return resource_type(**(self._unwrap_single_resource(r.json()) if envelope else r.json()))

def _get_single_resource(
self,
resource_type: type[ResourceT],
*path: str | UuidIdentifiable,
include: IncludeParams | None = None,
expected_code: int = httpx.codes.OK.value,
envelope: bool = True,
**params: te.Unpack[GetKwargs],
) -> ResourceT | None:
"""Get a single resource of a certain type at the specified path.
Expand All @@ -693,6 +731,10 @@ def _get_single_resource(
:doc:`model specifications <models_api>` which resources can be included in other resources.
expected_code : :py:class:`int`
The expected status code of the response from the ``GET`` request. This defaults to ``200``.
envelope : :py:class:`bool`
Whether the endpoint wraps its response in a record envelope. This defaults to :any:`True`. Pass
:any:`False` for the endpoints which deliberately respond with a bare object, such as the client and
registry credential routes.
**params : :py:obj:`~typing.Unpack` [:py:class:`.GetKwargs`]
Further keyword arguments for adding optional fields to a response and returning meta information.

Expand Down Expand Up @@ -730,14 +772,15 @@ def _get_single_resource(
else:
raise

return resource_type(**self._unwrap_single_resource(r.json()))
return resource_type(**(self._unwrap_single_resource(r.json()) if envelope else r.json()))

def _update_resource(
self,
resource_type: type[ResourceT],
resource: BaseModel,
*path: str | UuidIdentifiable,
expected_code: int = httpx.codes.ACCEPTED.value,
envelope: bool = True,
**params: te.Unpack[BaseKwargs],
) -> ResourceT:
"""Update a resource of a certain type at the specified path.
Expand All @@ -761,6 +804,10 @@ def _update_resource(
``id`` attribute.
expected_code : :py:class:`int`
The expected status code of the response from the ``POST`` request. This defaults to ``202``.
envelope : :py:class:`bool`
Whether the endpoint wraps its response in a record envelope. This defaults to :any:`True`. Pass
:any:`False` for the endpoints which deliberately respond with a bare object, such as the client and
registry credential routes.

Returns
-------
Expand All @@ -784,7 +831,7 @@ def _update_resource(
**params,
)

return resource_type(**self._unwrap_single_resource(r.json()))
return resource_type(**(self._unwrap_single_resource(r.json()) if envelope else r.json()))

def _delete_resource(
self,
Expand Down
Loading