From 7f8a3147245a754ac97416611640fae815a767ab Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 30 Jul 2026 17:03:53 +0200 Subject: [PATCH] feat!: adapt core and storage clients to the FLAME Hub 0.13.0 API The FLAME Hub renamed its entity/HTTP-API vocabulary from snake_case to camelCase (PrivateAIM/hub@de5770437, "refactor!: camelCase entity properties, domain types & HTTP API", #1806) and wrapped every entity record response in a data/meta envelope (PrivateAIM/hub@a509e932c, "feat!: record data/meta envelope, meta.schema discovery and dependency bump", #1801). Both ship in 0.13.0. Its database column names, permission names, OAuth2/OIDC parameters and token introspection payloads stay snake_case. camelCase Mirror the rename on the CoreClient and the StorageClient exactly as c356a64 did for the AuthClient: model fields, method keyword arguments and the rapiq filter/sort/field/include vocabulary move to camelCase (display_name -> displayName, realm_id -> realmId, master_image -> masterImage, build_status -> buildStatus, group_virtual_path -> groupVirtualPath, the *_realm_id fields -> *RealmId, ...). A pydantic field name is the wire key, so renaming the field is the whole change; there are no aliases. Four parameters deliberately keep snake_case because they name no hub field -- node_type, bucket_type and registry_project_type disambiguate a hub field called `type`, and is_entrypoint disambiguates one called `root`. So do the transport level ones (base_url, chunk_size, UploadFile's file_name/content_type) and the whole _auth_flows OAuth2 surface (client_id, client_secret, grant_type). Two hard-coded filter keys moved with the models: delete_analysis_logs and delete_analysis_node_logs built filter[analysis_id] / filter[node_id]. rapiq drops an unknown filter key silently rather than rejecting it, so against 0.13.0 the stale keys would have widened both DELETEs to an unfiltered selection. Record envelope Hub wraps records as {"data": ..., "meta": ...} but keeps a handful of endpoints flat, so unlike authup the envelope is per-endpoint rather than per-client. _create_resource, _get_single_resource and _update_resource take a new `envelope` keyword defaulting to True; the five credential routes (get_node_registry_credentials, get/update_node_client_credentials, get/update_analysis_client_credentials) pass envelope=False. send_analysis_command reads the body itself and now unwraps it too. CoreClient and StorageClient override _unwrap_single_resource. The three overrides now delegate to a shared unwrap_enveloped_resource() helper, which names the required service version in the ValueError so an outdated deployment is recognizable from the exception alone. BaseClient keeps returning the body unchanged. _delete_resource is unaffected: it never reads the body, and hub still answers DELETE with 202. Collection responses were already validated as an envelope by ResourceList, and the bucket upload endpoint answers with a collection rather than a record, so it stays as it is. BREAKING CHANGE: CoreClient and StorageClient require FLAME Hub 0.13.0 or newer. Their model attributes, method keyword arguments and rapiq filter/sort/field/include keys are now camelCase. --- README.md | 21 +- docs/clients_api.rst | 2 + docs/user_guide.rst | 18 +- docs/utility_functions_api.rst | 2 + flame_hub/_auth_client.py | 8 +- flame_hub/_base_client.py | 61 +++- flame_hub/_core_client.py | 623 +++++++++++++++++---------------- flame_hub/_storage_client.py | 72 ++-- tests/test_base_client.py | 103 +++++- tests/test_core.py | 148 ++++---- tests/test_storage.py | 2 +- 11 files changed, 617 insertions(+), 443 deletions(-) diff --git a/README.md b/README.md index eab466b..b6bccca 100644 --- a/README.md +++ b/README.md @@ -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" } ``` diff --git a/docs/clients_api.rst b/docs/clients_api.rst index 323ca0e..fd9f16b 100644 --- a/docs/clients_api.rst +++ b/docs/clients_api.rst @@ -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 diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 6d2dfc6..b1ed2e9 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -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] @@ -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 @@ -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", @@ -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 @@ -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 `. diff --git a/docs/utility_functions_api.rst b/docs/utility_functions_api.rst index 5646b0c..10067a7 100644 --- a/docs/utility_functions_api.rst +++ b/docs/utility_functions_api.rst @@ -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 diff --git a/flame_hub/_auth_client.py b/flame_hub/_auth_client.py index 1102018..5f87375 100644 --- a/flame_hub/_auth_client.py +++ b/flame_hub/_auth_client.py @@ -19,6 +19,7 @@ ResourceListResult, AuthParam, BaseKwargs, + unwrap_enveloped_resource, ) from flame_hub._defaults import DEFAULT_AUTH_BASE_URL @@ -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) diff --git a/flame_hub/_base_client.py b/flame_hub/_base_client.py index 401660f..788c240 100644 --- a/flame_hub/_base_client.py +++ b/flame_hub/_base_client.py @@ -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. @@ -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 ---------- @@ -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 @@ -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. @@ -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 ------- @@ -661,7 +698,7 @@ 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, @@ -669,6 +706,7 @@ def _get_single_resource( *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. @@ -693,6 +731,10 @@ def _get_single_resource( :doc:`model specifications ` 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. @@ -730,7 +772,7 @@ 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, @@ -738,6 +780,7 @@ def _update_resource( 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. @@ -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 ------- @@ -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, diff --git a/flame_hub/_core_client.py b/flame_hub/_core_client.py index 55164b1..fb67b5f 100644 --- a/flame_hub/_core_client.py +++ b/flame_hub/_core_client.py @@ -24,6 +24,7 @@ ResourceListResult, AuthParam, BaseKwargs, + unwrap_enveloped_resource, ) from flame_hub._defaults import DEFAULT_CORE_BASE_URL from flame_hub._storage_client import Bucket, BucketFile @@ -34,21 +35,21 @@ class CreateRegistry(BaseModel): name: str host: str - account_name: str | None - account_secret: t.Annotated[str | None, IsOptionalField] = None + accountName: str | None + accountSecret: t.Annotated[str | None, IsOptionalField] = None class Registry(CreateRegistry): id: uuid.UUID - created_at: datetime - updated_at: datetime + createdAt: datetime + updatedAt: datetime class UpdateRegistry(BaseModel): name: str | UNSET_T = UNSET host: str | UNSET_T = UNSET - account_name: str | None | UNSET_T = UNSET - account_secret: str | None | UNSET_T = UNSET + accountName: str | None | UNSET_T = UNSET + accountSecret: str | None | UNSET_T = UNSET RegistryProjectType = t.Literal["default", "aggregator", "incoming", "outgoing", "masterImages", "node"] @@ -57,94 +58,94 @@ class UpdateRegistry(BaseModel): class CreateRegistryProject(BaseModel): name: str type: RegistryProjectType - registry_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - external_name: str - account_name: str | None - account_secret: t.Annotated[str | None, IsOptionalField] = None + registryId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + externalName: str + accountName: str | None + accountSecret: t.Annotated[str | None, IsOptionalField] = None class RegistryProject(CreateRegistryProject): id: uuid.UUID public: bool - external_id: str | None - account_id: str | None - webhook_name: str | None - webhook_exists: bool | None - realm_id: uuid.UUID | None + externalId: str | None + accountId: str | None + webhookName: str | None + webhookExists: bool | None + realmId: uuid.UUID | None registry: t.Annotated[Registry, IsIncludable] = None - created_at: datetime - updated_at: datetime + createdAt: datetime + updatedAt: datetime class UpdateRegistryProject(BaseModel): name: str | UNSET_T = UNSET type: RegistryProjectType | UNSET_T = UNSET - registry_id: t.Annotated[uuid.UUID | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET - external_name: str | UNSET_T = UNSET - account_name: str | None | UNSET_T = UNSET - account_secret: str | None | UNSET_T = UNSET + registryId: t.Annotated[uuid.UUID | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET + externalName: str | UNSET_T = UNSET + accountName: str | None | UNSET_T = UNSET + accountSecret: str | None | UNSET_T = UNSET NodeType = t.Literal["aggregator", "default"] class CreateNode(BaseModel): - external_name: str | None + externalName: str | None hidden: bool | None name: str - realm_id: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] - registry_id: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] + realmId: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] + registryId: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] type: NodeType | None class Node(CreateNode): id: uuid.UUID - public_key: str | None + publicKey: str | None online: bool registry: t.Annotated[Registry | None, IsIncludable] = None - registry_project_id: uuid.UUID | None - registry_project: t.Annotated[RegistryProject | None, IsIncludable] = None - client_id: uuid.UUID | None - created_at: datetime - updated_at: datetime + registryProjectId: uuid.UUID | None + registryProject: t.Annotated[RegistryProject | None, IsIncludable] = None + clientId: uuid.UUID | None + createdAt: datetime + updatedAt: datetime class UpdateNode(BaseModel): hidden: bool | UNSET_T = UNSET - external_name: str | None | UNSET_T = UNSET + externalName: str | None | UNSET_T = UNSET type: NodeType | UNSET_T = UNSET - public_key: str | None | UNSET_T = UNSET - realm_id: t.Annotated[uuid.UUID | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET - registry_id: t.Annotated[uuid.UUID | None | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET + publicKey: str | None | UNSET_T = UNSET + realmId: t.Annotated[uuid.UUID | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET + registryId: t.Annotated[uuid.UUID | None | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET class NodeRegistryCredentials(BaseModel): host: str - external_name: str - account_name: str | None - account_secret: str | None + externalName: str + accountName: str | None + accountSecret: str | None class ClientCredentials(BaseModel): id: uuid.UUID secret: str | None name: str - display_name: str + displayName: str class UpdateClientCredentials(BaseModel): secret: str | None | UNSET_T = UNSET name: str | UNSET_T = UNSET - display_name: str | UNSET_T = UNSET + displayName: str | UNSET_T = UNSET class MasterImageGroup(BaseModel): id: uuid.UUID name: str path: str - virtual_path: str - created_at: datetime - updated_at: datetime + virtualPath: str + createdAt: datetime + updatedAt: datetime class MasterImageCommandArgument(te.TypedDict): @@ -173,65 +174,65 @@ def ensure_position_none(value: t.Any) -> t.Any: class MasterImage(BaseModel): id: uuid.UUID path: str | None - virtual_path: str - group_virtual_path: str - build_status: ProcessStatus | None - build_progress: int | None + virtualPath: str + groupVirtualPath: str + buildStatus: ProcessStatus | None + buildProgress: int | None name: str command: str | None - command_arguments: t.Annotated[list[MasterImageCommandArgument] | None, BeforeValidator(ensure_position_none)] - created_at: datetime - updated_at: datetime + commandArguments: t.Annotated[list[MasterImageCommandArgument] | None, BeforeValidator(ensure_position_none)] + createdAt: datetime + updatedAt: datetime class CreateProject(BaseModel): description: str | None - master_image_id: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] + masterImageId: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] name: str - display_name: str | None + displayName: str | None class Project(CreateProject): id: uuid.UUID analyses: int nodes: int - master_image: t.Annotated[MasterImage | None, IsIncludable] = None - created_at: datetime - updated_at: datetime - realm_id: uuid.UUID - user_id: uuid.UUID | None + masterImage: t.Annotated[MasterImage | None, IsIncludable] = None + createdAt: datetime + updatedAt: datetime + realmId: uuid.UUID + userId: uuid.UUID | None class UpdateProject(BaseModel): description: str | None | UNSET_T = UNSET - master_image_id: t.Annotated[uuid.UUID | None | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET + masterImageId: t.Annotated[uuid.UUID | None | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET name: str | UNSET_T = UNSET - display_name: str | None | UNSET_T = UNSET + displayName: str | None | UNSET_T = UNSET ProjectNodeApprovalStatus = t.Literal["rejected", "approved"] class CreateProjectNode(BaseModel): - node_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - project_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + nodeId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + projectId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] class ProjectNode(CreateProjectNode): id: uuid.UUID - approval_status: ProjectNodeApprovalStatus | None + approvalStatus: ProjectNodeApprovalStatus | None comment: str | None - created_at: datetime - updated_at: datetime + createdAt: datetime + updatedAt: datetime node: t.Annotated[Node, IsIncludable] = None project: t.Annotated[Project, IsIncludable] = None - project_realm_id: uuid.UUID - node_realm_id: uuid.UUID + projectRealmId: uuid.UUID + nodeRealmId: uuid.UUID class UpdateProjectNode(BaseModel): comment: str | None | UNSET_T = UNSET - approval_status: ProjectNodeApprovalStatus | None | UNSET_T = UNSET + approvalStatus: ProjectNodeApprovalStatus | None | UNSET_T = UNSET LogLevel = t.Literal["emerg", "alert", "crit", "error", "warn", "notice", "info", "debug"] @@ -250,11 +251,11 @@ class Log(BaseModel): class CreateAnalysis(BaseModel): description: str | None name: str | None - display_name: str | None - project_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - master_image_id: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] - registry_id: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] - image_command_arguments: t.Annotated[ + displayName: str | None + projectId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + masterImageId: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] + registryId: t.Annotated[uuid.UUID | None, Field(), WrapValidator(uuid_validator)] + imageCommandArguments: t.Annotated[ list[MasterImageCommandArgument], Field(default_factory=list), BeforeValidator(lambda args: [] if args is None else ensure_position_none(args)), @@ -264,40 +265,40 @@ class CreateAnalysis(BaseModel): class Analysis(CreateAnalysis): id: uuid.UUID nodes: int - nodes_approved: int - configuration_locked: bool - configuration_entrypoint_valid: bool - configuration_image_valid: bool - configuration_node_aggregator_valid: bool - configuration_node_default_valid: bool - configuration_nodes_valid: bool - build_status: ProcessStatus | None - build_nodes_valid: bool - build_progress: int | None - build_hash: str | None - build_os: str | None - build_size: int | None - distribution_status: ProcessStatus | None - distribution_progress: int | None - execution_status: ProcessStatus | None - execution_progress: int | None - created_at: datetime - updated_at: datetime + nodesApproved: int + configurationLocked: bool + configurationEntrypointValid: bool + configurationImageValid: bool + configurationNodeAggregatorValid: bool + configurationNodeDefaultValid: bool + configurationNodesValid: bool + buildStatus: ProcessStatus | None + buildNodesValid: bool + buildProgress: int | None + buildHash: str | None + buildOs: str | None + buildSize: int | None + distributionStatus: ProcessStatus | None + distributionProgress: int | None + executionStatus: ProcessStatus | None + executionProgress: int | None + createdAt: datetime + updatedAt: datetime registry: t.Annotated[Registry | None, IsIncludable] = None - realm_id: uuid.UUID - user_id: uuid.UUID - client_id: uuid.UUID | None - project_id: uuid.UUID + realmId: uuid.UUID + userId: uuid.UUID + clientId: uuid.UUID | None + projectId: uuid.UUID project: t.Annotated[Project, IsIncludable] = None - master_image: t.Annotated[MasterImage | None, IsIncludable] = None + masterImage: t.Annotated[MasterImage | None, IsIncludable] = None class UpdateAnalysis(BaseModel): description: str | None | UNSET_T = UNSET name: str | UNSET_T = UNSET - display_name: str | None | UNSET_T = UNSET - master_image_id: t.Annotated[uuid.UUID | None | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET - image_command_arguments: ( + displayName: str | None | UNSET_T = UNSET + masterImageId: t.Annotated[uuid.UUID | None | UNSET_T, Field(), WrapValidator(uuid_validator)] = UNSET + imageCommandArguments: ( t.Annotated[ list[MasterImageCommandArgument], BeforeValidator(lambda args: ensure_position_none(args)), @@ -318,8 +319,8 @@ class UpdateAnalysis(BaseModel): class CreateAnalysisNode(BaseModel): - analysis_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - node_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + analysisId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + nodeId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] AnalysisNodeApprovalStatus = t.Literal["rejected", "approved"] @@ -327,30 +328,30 @@ class CreateAnalysisNode(BaseModel): class AnalysisNode(CreateAnalysisNode): id: uuid.UUID - approval_status: AnalysisNodeApprovalStatus | None - execution_status: ProcessStatus | None - execution_progress: int | None + approvalStatus: AnalysisNodeApprovalStatus | None + executionStatus: ProcessStatus | None + executionProgress: int | None comment: str | None - artifact_tag: str | None - artifact_digest: str | None - created_at: datetime - updated_at: datetime + artifactTag: str | None + artifactDigest: str | None + createdAt: datetime + updatedAt: datetime analysis: t.Annotated[Analysis, IsIncludable] = None node: t.Annotated[Node, IsIncludable] = None - analysis_realm_id: uuid.UUID - node_realm_id: uuid.UUID + analysisRealmId: uuid.UUID + nodeRealmId: uuid.UUID class UpdateAnalysisNode(BaseModel): comment: str | None | UNSET_T = UNSET - approval_status: AnalysisNodeApprovalStatus | None | UNSET_T = UNSET - execution_status: ProcessStatus | None | UNSET_T = UNSET - execution_progress: int | None | UNSET_T = UNSET + approvalStatus: AnalysisNodeApprovalStatus | None | UNSET_T = UNSET + executionStatus: ProcessStatus | None | UNSET_T = UNSET + executionProgress: int | None | UNSET_T = UNSET class CreateAnalysisNodeLog(BaseModel): - analysis_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - node_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + analysisId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + nodeId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] code: str | None status: str | None message: str @@ -365,35 +366,35 @@ class AnalysisBucketType(str, Enum): class CreateAnalysisBucket(BaseModel): type: AnalysisBucketType - bucket_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - analysis_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + bucketId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + analysisId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] class AnalysisBucket(CreateAnalysisBucket): id: uuid.UUID - created_at: datetime - updated_at: datetime + createdAt: datetime + updatedAt: datetime analysis: t.Annotated[Analysis, IsIncludable] = None - realm_id: uuid.UUID + realmId: uuid.UUID class CreateAnalysisBucketFile(BaseModel): path: str - bucket_file_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - bucket_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] - analysis_bucket_id: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + bucketFileId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + bucketId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] + analysisBucketId: t.Annotated[uuid.UUID, Field(), WrapValidator(uuid_validator)] root: bool class AnalysisBucketFile(CreateAnalysisBucketFile): id: uuid.UUID - created_at: datetime - updated_at: datetime - analysis_bucket: t.Annotated[AnalysisBucket, IsIncludable] = None - realm_id: uuid.UUID - user_id: uuid.UUID | None - client_id: uuid.UUID | None - analysis_id: uuid.UUID + createdAt: datetime + updatedAt: datetime + analysisBucket: t.Annotated[AnalysisBucket, IsIncludable] = None + realmId: uuid.UUID + userId: uuid.UUID | None + clientId: uuid.UUID | None + analysisId: uuid.UUID analysis: t.Annotated[Analysis, IsIncludable] = None @@ -420,6 +421,27 @@ def __init__( ): super().__init__(base_url, auth, **kwargs) + def _unwrap_single_resource(self, body: t.Any) -> t.Any: + """Extract the resource object from the core service's record envelope. + + Since ``0.13.0`` the FLAME Hub responds to record requests with :python:`{"data": ..., "meta": ...}` instead + of the resource object itself, mirroring the envelope that list responses have always used. ``meta`` holds + response-scoped extras such as the queryable schema of the endpoint and is discarded. + + The credential endpoints keep responding with a bare object and pass :python:`envelope=False` instead of + going through this method. + + Raises + ------ + :py:exc:`ValueError` + If ``body`` does not carry a ``data`` property, which is the case for FLAME Hub versions before ``0.13.0``. + + See Also + -------- + :py:meth:`.BaseClient._unwrap_single_resource`, :py:func:`.unwrap_enveloped_resource` + """ + return unwrap_enveloped_resource(body, "FLAME Hub 0.13.0") + def get_nodes(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[Node]: return self._get_all_resources(Node, "nodes", include=get_includable_names(Node), **params) @@ -429,9 +451,9 @@ def find_nodes(self, **params: te.Unpack[FindAllKwargs]) -> ResourceListResult[N def create_node( self, name: str, - realm_id: Realm | str | uuid.UUID | None = None, - registry_id: Registry | uuid.UUID | str | None = None, - external_name: str | None = None, + realmId: Realm | str | uuid.UUID | None = None, + registryId: Registry | uuid.UUID | str | None = None, + externalName: str | None = None, node_type: NodeType = "default", hidden: bool = False, **params: te.Unpack[BaseKwargs], @@ -440,55 +462,55 @@ def create_node( Node, CreateNode( name=name, - realm_id=realm_id, - external_name=external_name, + realmId=realmId, + externalName=externalName, hidden=hidden, - registry_id=registry_id, + registryId=registryId, type=node_type, ), "nodes", **params, ) - def get_node(self, node_id: Node | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Node | None: - return self._get_single_resource(Node, "nodes", node_id, include=get_includable_names(Node), **params) + def get_node(self, nodeId: Node | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Node | None: + return self._get_single_resource(Node, "nodes", nodeId, include=get_includable_names(Node), **params) def delete_node( self, - node_id: Node | uuid.UUID | str, + nodeId: Node | uuid.UUID | str, **params: te.Unpack[BaseKwargs], ): - self._delete_resource("nodes", node_id, **params) + self._delete_resource("nodes", nodeId, **params) def update_node( self, - node_id: Node | uuid.UUID | str, - external_name: str | None | UNSET_T = UNSET, + nodeId: Node | uuid.UUID | str, + externalName: str | None | UNSET_T = UNSET, hidden: bool | UNSET_T = UNSET, node_type: NodeType | UNSET_T = UNSET, - realm_id: Realm | str | uuid.UUID | UNSET_T = UNSET, - registry_id: Registry | str | uuid.UUID | None | UNSET_T = UNSET, - public_key: str | None | UNSET_T = UNSET, + realmId: Realm | str | uuid.UUID | UNSET_T = UNSET, + registryId: Registry | str | uuid.UUID | None | UNSET_T = UNSET, + publicKey: str | None | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> Node: return self._update_resource( Node, UpdateNode( - external_name=external_name, + externalName=externalName, hidden=hidden, type=node_type, - public_key=public_key, - realm_id=realm_id, - registry_id=registry_id, + publicKey=publicKey, + realmId=realmId, + registryId=registryId, ), "nodes", - node_id, + nodeId, **params, ) def get_node_registry_credentials( self, - node_id: Node | uuid.UUID | str, + nodeId: Node | uuid.UUID | str, **params: te.Unpack[GetKwargs], ) -> NodeRegistryCredentials | None: """Returns the node's registry project credentials.""" @@ -496,15 +518,16 @@ def get_node_registry_credentials( return self._get_single_resource( NodeRegistryCredentials, "nodes", - node_id, + nodeId, "registry", "credentials", + envelope=False, **params, ) def get_node_client_credentials( self, - node_id: Node | uuid.UUID | str, + nodeId: Node | uuid.UUID | str, **params: te.Unpack[GetKwargs], ) -> ClientCredentials | None: """Returns the node's client credentials.""" @@ -512,18 +535,19 @@ def get_node_client_credentials( return self._get_single_resource( ClientCredentials, "nodes", - node_id, + nodeId, "client", "credentials", + envelope=False, **params, ) def update_node_client_credentials( self, - node_id: Node | uuid.UUID | str, + nodeId: Node | uuid.UUID | str, secret: str | None | UNSET_T = UNSET, name: str | UNSET_T = UNSET, - display_name: str | UNSET_T = UNSET, + displayName: str | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> ClientCredentials: """Update the node's client credentials. If ``secret`` is set to :any:`None`, then the Hub will create and set @@ -534,13 +558,14 @@ def update_node_client_credentials( UpdateClientCredentials( secret=secret, name=name, - display_name=display_name, + displayName=displayName, ), "nodes", - node_id, + nodeId, "client", "credentials", expected_code=httpx.codes.OK.value, + envelope=False, **params, ) @@ -548,9 +573,9 @@ def get_master_image_groups(self, **params: te.Unpack[GetKwargs]) -> ResourceLis return self._get_all_resources(MasterImageGroup, "master-image-groups", **params) def get_master_image_group( - self, master_image_group_id: MasterImageGroup | uuid.UUID | str, **params: te.Unpack[GetKwargs] + self, masterImageGroupId: MasterImageGroup | uuid.UUID | str, **params: te.Unpack[GetKwargs] ) -> MasterImageGroup | None: - return self._get_single_resource(MasterImageGroup, "master-image-groups", master_image_group_id, **params) + return self._get_single_resource(MasterImageGroup, "master-image-groups", masterImageGroupId, **params) def find_master_image_groups(self, **params: te.Unpack[FindAllKwargs]) -> ResourceListResult[MasterImageGroup]: return self._find_all_resources(MasterImageGroup, "master-image-groups", **params) @@ -559,9 +584,9 @@ def get_master_images(self, **params: te.Unpack[GetKwargs]) -> ResourceListResul return self._get_all_resources(MasterImage, "master-images", **params) def get_master_image( - self, master_image_id: MasterImage | uuid.UUID | str, **params: te.Unpack[GetKwargs] + self, masterImageId: MasterImage | uuid.UUID | str, **params: te.Unpack[GetKwargs] ) -> MasterImage | None: - return self._get_single_resource(MasterImage, "master-images", master_image_id, **params) + return self._get_single_resource(MasterImage, "master-images", masterImageId, **params) def find_master_images(self, **params: te.Unpack[FindAllKwargs]) -> ResourceListResult[MasterImage]: return self._find_all_resources(MasterImage, "master-images", **params) @@ -586,7 +611,7 @@ def sync_master_images(self, **params: te.Unpack[BaseKwargs]): **params, ) - def build_master_image(self, master_image_id: MasterImage | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): + def build_master_image(self, masterImageId: MasterImage | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): """This method will command the Hub to start building a master image. Note that building a master image could take some time. """ @@ -596,15 +621,15 @@ def build_master_image(self, master_image_id: MasterImage | uuid.UUID | str, **p "master-images", "command", expected_code=httpx.codes.ACCEPTED.value, - json={"command": "build", "id": str(obtain_uuid_from(master_image_id))}, + json={"command": "build", "id": str(obtain_uuid_from(masterImageId))}, **params, ) def create_project( self, name: str, - display_name: str | None = None, - master_image_id: MasterImage | uuid.UUID | str | None = None, + displayName: str | None = None, + masterImageId: MasterImage | uuid.UUID | str | None = None, description: str | None = None, **params: te.Unpack[BaseKwargs], ) -> Project: @@ -612,53 +637,51 @@ def create_project( Project, CreateProject( name=name, - master_image_id=master_image_id, + masterImageId=masterImageId, description=description, - display_name=display_name, + displayName=displayName, ), "projects", **params, ) - def delete_project(self, project_id: Project | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): - self._delete_resource("projects", project_id, **params) + def delete_project(self, projectId: Project | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): + self._delete_resource("projects", projectId, **params) - def get_project(self, project_id: Project | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Project | None: + def get_project(self, projectId: Project | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Project | None: return self._get_single_resource( - Project, "projects", project_id, include=get_includable_names(Project), **params + Project, "projects", projectId, include=get_includable_names(Project), **params ) def update_project( self, - project_id: Project | uuid.UUID | str, + projectId: Project | uuid.UUID | str, description: str | None | UNSET_T = UNSET, - master_image_id: MasterImage | str | uuid.UUID | None | UNSET_T = UNSET, + masterImageId: MasterImage | str | uuid.UUID | None | UNSET_T = UNSET, name: str | UNSET_T = UNSET, - display_name: str | None | UNSET_T = UNSET, + displayName: str | None | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> Project: return self._update_resource( Project, - UpdateProject( - description=description, master_image_id=master_image_id, name=name, display_name=display_name - ), + UpdateProject(description=description, masterImageId=masterImageId, name=name, displayName=displayName), "projects", - project_id, + projectId, **params, ) def create_project_node( - self, project_id: Project | uuid.UUID | str, node_id: Node | uuid.UUID | str, **params: te.Unpack[BaseKwargs] + self, projectId: Project | uuid.UUID | str, nodeId: Node | uuid.UUID | str, **params: te.Unpack[BaseKwargs] ) -> ProjectNode: return self._create_resource( ProjectNode, - CreateProjectNode(project_id=project_id, node_id=node_id), + CreateProjectNode(projectId=projectId, nodeId=nodeId), "project-nodes", **params, ) - def delete_project_node(self, project_node_id: ProjectNode | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): - self._delete_resource("project-nodes", project_node_id, **params) + def delete_project_node(self, projectNodeId: ProjectNode | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): + self._delete_resource("project-nodes", projectNodeId, **params) def get_project_nodes(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[ProjectNode]: return self._get_all_resources( @@ -671,55 +694,55 @@ def find_project_nodes(self, **params: te.Unpack[FindAllKwargs]) -> ResourceList ) def get_project_node( - self, project_node_id: ProjectNode | uuid.UUID | str, **params: te.Unpack[GetKwargs] + self, projectNodeId: ProjectNode | uuid.UUID | str, **params: te.Unpack[GetKwargs] ) -> ProjectNode | None: return self._get_single_resource( - ProjectNode, "project-nodes", project_node_id, include=get_includable_names(ProjectNode), **params + ProjectNode, "project-nodes", projectNodeId, include=get_includable_names(ProjectNode), **params ) def update_project_node( self, - project_node_id: ProjectNode | uuid.UUID | str, + projectNodeId: ProjectNode | uuid.UUID | str, comment: str | None | UNSET_T = UNSET, - approval_status: ProjectNodeApprovalStatus | None | UNSET_T = UNSET, + approvalStatus: ProjectNodeApprovalStatus | None | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ): return self._update_resource( ProjectNode, - UpdateProjectNode(comment=comment, approval_status=approval_status), + UpdateProjectNode(comment=comment, approvalStatus=approvalStatus), "project-nodes", - project_node_id, + projectNodeId, **params, ) def create_analysis( self, - project_id: Project | uuid.UUID | str, + projectId: Project | uuid.UUID | str, name: str | None = None, - display_name: str | None = None, + displayName: str | None = None, description: str | None = None, - master_image_id: MasterImage | uuid.UUID | str | None = None, - registry_id: Registry | uuid.UUID | str | None = None, - image_command_arguments: list[MasterImageCommandArgument] | None = None, + masterImageId: MasterImage | uuid.UUID | str | None = None, + registryId: Registry | uuid.UUID | str | None = None, + imageCommandArguments: list[MasterImageCommandArgument] | None = None, **params: te.Unpack[BaseKwargs], ) -> Analysis: return self._create_resource( Analysis, CreateAnalysis( - project_id=project_id, + projectId=projectId, name=name, - display_name=display_name, + displayName=displayName, description=description, - master_image_id=master_image_id, - registry_id=registry_id, - image_command_arguments=image_command_arguments, + masterImageId=masterImageId, + registryId=registryId, + imageCommandArguments=imageCommandArguments, ), "analyses", **params, ) - def delete_analysis(self, analysis_id: Analysis | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): - self._delete_resource("analyses", analysis_id, **params) + def delete_analysis(self, analysisId: Analysis | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): + self._delete_resource("analyses", analysisId, **params) def get_analyses(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[Analysis]: return self._get_all_resources(Analysis, "analyses", include=get_includable_names(Analysis), **params) @@ -727,56 +750,56 @@ def get_analyses(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[Ana def find_analyses(self, **params: te.Unpack[FindAllKwargs]) -> ResourceListResult[Analysis]: return self._find_all_resources(Analysis, "analyses", include=get_includable_names(Analysis), **params) - def get_analysis(self, analysis_id: Analysis | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Analysis | None: + def get_analysis(self, analysisId: Analysis | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Analysis | None: return self._get_single_resource( - Analysis, "analyses", analysis_id, include=get_includable_names(Analysis), **params + Analysis, "analyses", analysisId, include=get_includable_names(Analysis), **params ) def update_analysis( self, - analysis_id: Analysis | uuid.UUID | str, + analysisId: Analysis | uuid.UUID | str, name: str | None | UNSET_T = UNSET, - display_name: str | None | UNSET_T = UNSET, + displayName: str | None | UNSET_T = UNSET, description: str | None | UNSET_T = UNSET, - master_image_id: MasterImage | uuid.UUID | str | None | UNSET_T = UNSET, - image_command_arguments: list[MasterImageCommandArgument] | UNSET_T = UNSET, + masterImageId: MasterImage | uuid.UUID | str | None | UNSET_T = UNSET, + imageCommandArguments: list[MasterImageCommandArgument] | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> Analysis: return self._update_resource( Analysis, UpdateAnalysis( name=name, - display_name=display_name, + displayName=displayName, description=description, - master_image_id=master_image_id, - image_command_arguments=image_command_arguments, + masterImageId=masterImageId, + imageCommandArguments=imageCommandArguments, ), "analyses", - analysis_id, + analysisId, **params, ) def send_analysis_command( self, - analysis_id: Analysis | uuid.UUID | str, + analysisId: Analysis | uuid.UUID | str, command: AnalysisCommand, **params: te.Unpack[BaseKwargs], ) -> Analysis: r = self._request( "POST", "analyses", - obtain_uuid_from(analysis_id), + obtain_uuid_from(analysisId), "command", expected_code=httpx.codes.ACCEPTED.value, json={"command": command}, **params, ) - return Analysis(**r.json()) + return Analysis(**self._unwrap_single_resource(r.json())) def get_analysis_client_credentials( self, - analysis_id: Analysis | uuid.UUID | str, + analysisId: Analysis | uuid.UUID | str, **params: te.Unpack[GetKwargs], ) -> ClientCredentials | None: """Returns the client credentials of the analysis.""" @@ -784,18 +807,19 @@ def get_analysis_client_credentials( return self._get_single_resource( ClientCredentials, "analyses", - analysis_id, + analysisId, "client", "credentials", + envelope=False, **params, ) def update_analysis_client_credentials( self, - analysis_id: Analysis | uuid.UUID | str, + analysisId: Analysis | uuid.UUID | str, secret: str | None | UNSET_T = UNSET, name: str | UNSET_T = UNSET, - display_name: str | UNSET_T = UNSET, + displayName: str | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> ClientCredentials: """Update the client credentials of the analysis. If ``secret`` is set to :any:`None`, then the Hub will create @@ -806,56 +830,57 @@ def update_analysis_client_credentials( UpdateClientCredentials( secret=secret, name=name, - display_name=display_name, + displayName=displayName, ), "analyses", - analysis_id, + analysisId, "client", "credentials", expected_code=httpx.codes.OK.value, + envelope=False, **params, ) def create_analysis_node( - self, analysis_id: Analysis | uuid.UUID | str, node_id: Node | uuid.UUID | str, **params: te.Unpack[BaseKwargs] + self, analysisId: Analysis | uuid.UUID | str, nodeId: Node | uuid.UUID | str, **params: te.Unpack[BaseKwargs] ) -> AnalysisNode: return self._create_resource( AnalysisNode, - CreateAnalysisNode(analysis_id=analysis_id, node_id=node_id), + CreateAnalysisNode(analysisId=analysisId, nodeId=nodeId), "analysis-nodes", **params, ) - def delete_analysis_node(self, analysis_node_id: AnalysisNode | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): - self._delete_resource("analysis-nodes", analysis_node_id, **params) + def delete_analysis_node(self, analysisNodeId: AnalysisNode | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): + self._delete_resource("analysis-nodes", analysisNodeId, **params) def update_analysis_node( self, - analysis_node_id: AnalysisNode | uuid.UUID | str, + analysisNodeId: AnalysisNode | uuid.UUID | str, comment: str | None | UNSET_T = UNSET, - approval_status: AnalysisNodeApprovalStatus | None | UNSET_T = UNSET, - execution_status: ProcessStatus | None | UNSET_T = UNSET, - execution_progress: int | None | UNSET_T = UNSET, + approvalStatus: AnalysisNodeApprovalStatus | None | UNSET_T = UNSET, + executionStatus: ProcessStatus | None | UNSET_T = UNSET, + executionProgress: int | None | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> AnalysisNode: return self._update_resource( AnalysisNode, UpdateAnalysisNode( comment=comment, - approval_status=approval_status, - execution_status=execution_status, - execution_progress=execution_progress, + approvalStatus=approvalStatus, + executionStatus=executionStatus, + executionProgress=executionProgress, ), "analysis-nodes", - analysis_node_id, + analysisNodeId, **params, ) def get_analysis_node( - self, analysis_node_id: AnalysisNode | uuid.UUID | str, **params: te.Unpack[GetKwargs] + self, analysisNodeId: AnalysisNode | uuid.UUID | str, **params: te.Unpack[GetKwargs] ) -> AnalysisNode | None: return self._get_single_resource( - AnalysisNode, "analysis-nodes", analysis_node_id, include=get_includable_names(AnalysisNode), **params + AnalysisNode, "analysis-nodes", analysisNodeId, include=get_includable_names(AnalysisNode), **params ) def get_analysis_nodes(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[AnalysisNode]: @@ -870,8 +895,8 @@ def find_analysis_nodes(self, **params: te.Unpack[FindAllKwargs]) -> ResourceLis def create_analysis_node_log( self, - analysis_id: Analysis | uuid.UUID | str, - node_id: Node | uuid.UUID | str, + analysisId: Analysis | uuid.UUID | str, + nodeId: Node | uuid.UUID | str, level: LogLevel, message: str, status: str | None = None, @@ -881,8 +906,8 @@ def create_analysis_node_log( return self._create_resource( Log, CreateAnalysisNodeLog( - analysis_id=analysis_id, - node_id=node_id, + analysisId=analysisId, + nodeId=nodeId, level=level, message=message, status=status, @@ -895,8 +920,8 @@ def create_analysis_node_log( def delete_analysis_node_logs( self, - analysis_id: Analysis | uuid.UUID | str, - node_id: Node | uuid.UUID | str, + analysisId: Analysis | uuid.UUID | str, + nodeId: Node | uuid.UUID | str, **params: te.Unpack[BaseKwargs], ): self._request( @@ -905,8 +930,8 @@ def delete_analysis_node_logs( expected_code=httpx.codes.ACCEPTED.value, params=build_filter_params( { - "analysis_id": str(obtain_uuid_from(analysis_id)), - "node_id": str(obtain_uuid_from(node_id)), + "analysisId": str(obtain_uuid_from(analysisId)), + "nodeId": str(obtain_uuid_from(nodeId)), } ), **params, @@ -918,16 +943,16 @@ def find_analysis_node_logs(self, **params: te.Unpack[FindAllKwargs]) -> Resourc def create_analysis_bucket( self, bucket_type: AnalysisBucketType, - bucket_id: Bucket | uuid.UUID | str, - analysis_id: Analysis | uuid.UUID | str, + bucketId: Bucket | uuid.UUID | str, + analysisId: Analysis | uuid.UUID | str, **params: te.Unpack[BaseKwargs], ) -> AnalysisBucket: return self._create_resource( AnalysisBucket, CreateAnalysisBucket( type=bucket_type, - bucket_id=bucket_id, - analysis_id=analysis_id, + bucketId=bucketId, + analysisId=analysisId, ), "analysis-buckets", **params, @@ -935,10 +960,10 @@ def create_analysis_bucket( def delete_analysis_bucket( self, - analysis_bucket_id: AnalysisBucket | uuid.UUID | str, + analysisBucketId: AnalysisBucket | uuid.UUID | str, **params: te.Unpack[BaseKwargs], ): - self._delete_resource("analysis-buckets", analysis_bucket_id, **params) + self._delete_resource("analysis-buckets", analysisBucketId, **params) def get_analysis_buckets(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[AnalysisBucket]: return self._get_all_resources( @@ -951,12 +976,12 @@ def find_analysis_buckets(self, **params: te.Unpack[FindAllKwargs]) -> ResourceL ) def get_analysis_bucket( - self, analysis_bucket_id: AnalysisBucket | uuid.UUID | str, **params: te.Unpack[GetKwargs] + self, analysisBucketId: AnalysisBucket | uuid.UUID | str, **params: te.Unpack[GetKwargs] ) -> AnalysisBucket | None: return self._get_single_resource( AnalysisBucket, "analysis-buckets", - analysis_bucket_id, + analysisBucketId, include=get_includable_names(AnalysisBucket), **params, ) @@ -972,38 +997,38 @@ def find_analysis_bucket_files(self, **params: te.Unpack[FindAllKwargs]) -> Reso ) def get_analysis_bucket_file( - self, analysis_bucket_file_id: AnalysisBucketFile | uuid.UUID | str, **params: te.Unpack[GetKwargs] + self, analysisBucketFileId: AnalysisBucketFile | uuid.UUID | str, **params: te.Unpack[GetKwargs] ) -> AnalysisBucketFile | None: return self._get_single_resource( AnalysisBucketFile, "analysis-bucket-files", - analysis_bucket_file_id, + analysisBucketFileId, include=get_includable_names(AnalysisBucketFile), **params, ) def delete_analysis_bucket_file( self, - analysis_bucket_file_id: AnalysisBucketFile | uuid.UUID | str, + analysisBucketFileId: AnalysisBucketFile | uuid.UUID | str, **params: te.Unpack[BaseKwargs], ): - self._delete_resource("analysis-bucket-files", analysis_bucket_file_id, **params) + self._delete_resource("analysis-bucket-files", analysisBucketFileId, **params) def create_analysis_bucket_file( self, path: str, - bucket_file_id: BucketFile | uuid.UUID | str, - bucket_id: Bucket | uuid.UUID | str, - analysis_bucket_id: AnalysisBucket | uuid.UUID | str, + bucketFileId: BucketFile | uuid.UUID | str, + bucketId: Bucket | uuid.UUID | str, + analysisBucketId: AnalysisBucket | uuid.UUID | str, is_entrypoint: bool = False, **params: te.Unpack[BaseKwargs], ) -> AnalysisBucketFile: return self._create_resource( AnalysisBucketFile, CreateAnalysisBucketFile( - bucket_file_id=bucket_file_id, - bucket_id=bucket_id, - analysis_bucket_id=analysis_bucket_id, + bucketFileId=bucketFileId, + bucketId=bucketId, + analysisBucketId=analysisBucketId, path=path, root=is_entrypoint, ), @@ -1013,7 +1038,7 @@ def create_analysis_bucket_file( def update_analysis_bucket_file( self, - analysis_bucket_file_id: AnalysisBucketFile | uuid.UUID | str, + analysisBucketFileId: AnalysisBucketFile | uuid.UUID | str, is_entrypoint: bool | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> AnalysisBucketFile: @@ -1021,7 +1046,7 @@ def update_analysis_bucket_file( AnalysisBucketFile, UpdateAnalysisBucketFile(root=is_entrypoint), "analysis-bucket-files", - analysis_bucket_file_id, + analysisBucketFileId, **params, ) @@ -1029,41 +1054,41 @@ def create_registry( self, name: str, host: str, - account_name: str | None = None, - account_secret: str | None = None, + accountName: str | None = None, + accountSecret: str | None = None, **params: te.Unpack[BaseKwargs], ) -> Registry: return self._create_resource( Registry, - CreateRegistry(name=name, host=host, account_name=account_name, account_secret=account_secret), + CreateRegistry(name=name, host=host, accountName=accountName, accountSecret=accountSecret), "registries", **params, ) - def get_registry(self, registry_id: Registry | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Registry | None: - return self._get_single_resource(Registry, "registries", registry_id, **params) + def get_registry(self, registryId: Registry | uuid.UUID | str, **params: te.Unpack[GetKwargs]) -> Registry | None: + return self._get_single_resource(Registry, "registries", registryId, **params) def delete_registry( self, - registry_id: Registry | uuid.UUID | str, + registryId: Registry | uuid.UUID | str, **params: te.Unpack[BaseKwargs], ): - self._delete_resource("registries", registry_id, **params) + self._delete_resource("registries", registryId, **params) def update_registry( self, - registry_id: Registry | uuid.UUID | str, + registryId: Registry | uuid.UUID | str, name: str | UNSET_T = UNSET, host: str | UNSET_T = UNSET, - account_name: str | None | UNSET_T = UNSET, - account_secret: str | None | UNSET_T = UNSET, + accountName: str | None | UNSET_T = UNSET, + accountSecret: str | None | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> Registry: return self._update_resource( Registry, - UpdateRegistry(name=name, host=host, account_name=account_name, account_secret=account_secret), + UpdateRegistry(name=name, host=host, accountName=accountName, accountSecret=accountSecret), "registries", - registry_id, + registryId, **params, ) @@ -1075,7 +1100,7 @@ def find_registries(self, **params: te.Unpack[FindAllKwargs]) -> ResourceListRes def send_registry_command( self, - registry_id: Registry | uuid.UUID | str, + registryId: Registry | uuid.UUID | str, command: RegistryCommand, **params: te.Unpack[BaseKwargs], ): @@ -1085,7 +1110,7 @@ def send_registry_command( "registry", "command", expected_code=httpx.codes.ACCEPTED.value, - json={"command": command, "id": str(obtain_uuid_from(registry_id))}, + json={"command": command, "id": str(obtain_uuid_from(registryId))}, **params, ) @@ -1093,10 +1118,10 @@ def create_registry_project( self, name: str, registry_project_type: RegistryProjectType, - registry_id: Registry | uuid.UUID | str, - external_name: str, - account_name: str | None = None, - account_secret: str | None = None, + registryId: Registry | uuid.UUID | str, + externalName: str, + accountName: str | None = None, + accountSecret: str | None = None, **params: te.Unpack[BaseKwargs], ) -> RegistryProject: return self._create_resource( @@ -1104,42 +1129,42 @@ def create_registry_project( CreateRegistryProject( name=name, type=registry_project_type, - registry_id=registry_id, - external_name=external_name, - account_name=account_name, - account_secret=account_secret, + registryId=registryId, + externalName=externalName, + accountName=accountName, + accountSecret=accountSecret, ), "registry-projects", **params, ) def get_registry_project( - self, registry_project_id: RegistryProject | uuid.UUID | str, **params: te.Unpack[GetKwargs] + self, registryProjectId: RegistryProject | uuid.UUID | str, **params: te.Unpack[GetKwargs] ) -> RegistryProject | None: return self._get_single_resource( RegistryProject, "registry-projects", - registry_project_id, + registryProjectId, include=get_includable_names(RegistryProject), **params, ) def delete_registry_project( self, - registry_project_id: RegistryProject | uuid.UUID | str, + registryProjectId: RegistryProject | uuid.UUID | str, **params: te.Unpack[BaseKwargs], ): - self._delete_resource("registry-projects", registry_project_id, **params) + self._delete_resource("registry-projects", registryProjectId, **params) def update_registry_project( self, - registry_project_id: RegistryProject | uuid.UUID | str, + registryProjectId: RegistryProject | uuid.UUID | str, name: str | UNSET_T = UNSET, registry_project_type: RegistryProjectType | UNSET_T = UNSET, - registry_id: Registry | uuid.UUID | str | UNSET_T = UNSET, - external_name: str | UNSET_T = UNSET, - account_name: str | None | UNSET_T = UNSET, - account_secret: str | None | UNSET_T = UNSET, + registryId: Registry | uuid.UUID | str | UNSET_T = UNSET, + externalName: str | UNSET_T = UNSET, + accountName: str | None | UNSET_T = UNSET, + accountSecret: str | None | UNSET_T = UNSET, **params: te.Unpack[BaseKwargs], ) -> RegistryProject: return self._update_resource( @@ -1147,13 +1172,13 @@ def update_registry_project( UpdateRegistryProject( name=name, type=registry_project_type, - registry_id=registry_id, - external_name=external_name, - account_name=account_name, - account_secret=account_secret, + registryId=registryId, + externalName=externalName, + accountName=accountName, + accountSecret=accountSecret, ), "registry-projects", - registry_project_id, + registryProjectId, **params, ) @@ -1173,12 +1198,12 @@ def find_registry_projects(self, **params: te.Unpack[FindAllKwargs]) -> Resource **params, ) - def delete_analysis_logs(self, analysis_id: Analysis | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): + def delete_analysis_logs(self, analysisId: Analysis | uuid.UUID | str, **params: te.Unpack[BaseKwargs]): self._request( "DELETE", "analysis-logs", expected_code=httpx.codes.ACCEPTED.value, - params=build_filter_params({"analysis_id": str(obtain_uuid_from(analysis_id))}), + params=build_filter_params({"analysisId": str(obtain_uuid_from(analysisId))}), **params, ) diff --git a/flame_hub/_storage_client.py b/flame_hub/_storage_client.py index 4cc18f2..c5a7a15 100644 --- a/flame_hub/_storage_client.py +++ b/flame_hub/_storage_client.py @@ -18,6 +18,7 @@ ResourceListResult, AuthParam, BaseKwargs, + unwrap_enveloped_resource, ) from flame_hub._defaults import DEFAULT_STORAGE_BASE_URL @@ -29,11 +30,11 @@ class CreateBucket(BaseModel): class Bucket(CreateBucket): id: uuid.UUID - created_at: datetime - updated_at: datetime - actor_id: uuid.UUID | None - actor_type: str | None - realm_id: uuid.UUID | None + createdAt: datetime + updatedAt: datetime + actorId: uuid.UUID | None + actorType: str | None + realmId: uuid.UUID | None class BucketFile(BaseModel): @@ -43,12 +44,12 @@ class BucketFile(BaseModel): hash: str directory: str size: int | None - created_at: datetime - updated_at: datetime - actor_type: str - actor_id: uuid.UUID - realm_id: uuid.UUID - bucket_id: uuid.UUID + createdAt: datetime + updatedAt: datetime + actorType: str + actorId: uuid.UUID + realmId: uuid.UUID + bucketId: uuid.UUID bucket: t.Annotated[Bucket, IsIncludable] = None @@ -88,11 +89,32 @@ def __init__( ): super().__init__(base_url, auth, **kwargs) + def _unwrap_single_resource(self, body: t.Any) -> t.Any: + """Extract the resource object from the storage service's record envelope. + + Since ``0.13.0`` the FLAME Hub responds to record requests with :python:`{"data": ..., "meta": ...}` instead + of the resource object itself, mirroring the envelope that list responses have always used. ``meta`` holds + response-scoped extras such as the queryable schema of the endpoint and is discarded. + + The bucket upload endpoint responds with a collection rather than a record and is parsed as a + :py:class:`.ResourceList` instead of going through this method. + + Raises + ------ + :py:exc:`ValueError` + If ``body`` does not carry a ``data`` property, which is the case for FLAME Hub versions before ``0.13.0``. + + See Also + -------- + :py:meth:`.BaseClient._unwrap_single_resource`, :py:func:`.unwrap_enveloped_resource` + """ + return unwrap_enveloped_resource(body, "FLAME Hub 0.13.0") + def create_bucket(self, name: str, region: str | None = None, **params: te.Unpack[BaseKwargs]) -> Bucket: return self._create_resource(Bucket, CreateBucket(name=name, region=region), "buckets", **params) - def delete_bucket(self, bucket_id: Bucket | str | uuid.UUID, **params: te.Unpack[BaseKwargs]): - self._delete_resource("buckets", bucket_id, **params) + def delete_bucket(self, bucketId: Bucket | str | uuid.UUID, **params: te.Unpack[BaseKwargs]): + self._delete_resource("buckets", bucketId, **params) def get_buckets(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[Bucket]: return self._get_all_resources(Bucket, "buckets", **params) @@ -100,19 +122,19 @@ def get_buckets(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[Buck def find_buckets(self, **params: te.Unpack[FindAllKwargs]) -> ResourceListResult[Bucket]: return self._find_all_resources(Bucket, "buckets", **params) - def get_bucket(self, bucket_id: Bucket | str | uuid.UUID, **params: te.Unpack[GetKwargs]) -> Bucket | None: - return self._get_single_resource(Bucket, "buckets", bucket_id, **params) + def get_bucket(self, bucketId: Bucket | str | uuid.UUID, **params: te.Unpack[GetKwargs]) -> Bucket | None: + return self._get_single_resource(Bucket, "buckets", bucketId, **params) def stream_bucket_tarball( self, - bucket_id: Bucket | str | uuid.UUID, + bucketId: Bucket | str | uuid.UUID, chunk_size: int = 1024, **params: te.Unpack[BaseKwargs], ) -> t.Iterator[bytes]: r = self._request( "GET", "buckets", - str(obtain_uuid_from(bucket_id)), + str(obtain_uuid_from(bucketId)), "stream", expected_code=httpx.codes.OK.value, stream=True, @@ -127,7 +149,7 @@ def stream_bucket_tarball( def upload_to_bucket( self, - bucket_id: Bucket | str | uuid.UUID, + bucketId: Bucket | str | uuid.UUID, *upload_file: UploadFile, **params: te.Unpack[BaseKwargs], ) -> list[BucketFile]: @@ -139,7 +161,7 @@ def upload_to_bucket( r = self._request( "POST", "buckets", - str(obtain_uuid_from(bucket_id)), + str(obtain_uuid_from(bucketId)), "upload", expected_code=httpx.codes.CREATED.value, files=upload_file_dict, @@ -148,14 +170,14 @@ def upload_to_bucket( return ResourceList[BucketFile](**r.json()).data - def delete_bucket_file(self, bucket_file_id: BucketFile | str | uuid.UUID, **params: te.Unpack[BaseKwargs]): - self._delete_resource("bucket-files", bucket_file_id, **params) + def delete_bucket_file(self, bucketFileId: BucketFile | str | uuid.UUID, **params: te.Unpack[BaseKwargs]): + self._delete_resource("bucket-files", bucketFileId, **params) def get_bucket_file( - self, bucket_file_id: BucketFile | str | uuid.UUID, **params: te.Unpack[GetKwargs] + self, bucketFileId: BucketFile | str | uuid.UUID, **params: te.Unpack[GetKwargs] ) -> BucketFile | None: return self._get_single_resource( - BucketFile, "bucket-files", bucket_file_id, include=get_includable_names(BucketFile), **params + BucketFile, "bucket-files", bucketFileId, include=get_includable_names(BucketFile), **params ) def get_bucket_files(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[BucketFile]: @@ -166,14 +188,14 @@ def find_bucket_files(self, **params: te.Unpack[FindAllKwargs]) -> ResourceListR def stream_bucket_file( self, - bucket_file_id: BucketFile | str | uuid.UUID, + bucketFileId: BucketFile | str | uuid.UUID, chunk_size: int = 1024, **params: te.Unpack[BaseKwargs], ) -> t.Iterator[bytes]: r = self._request( "GET", "bucket-files", - str(obtain_uuid_from(bucket_file_id)), + str(obtain_uuid_from(bucketFileId)), "stream", expected_code=httpx.codes.OK.value, stream=True, diff --git a/tests/test_base_client.py b/tests/test_base_client.py index 3b82281..95a7199 100644 --- a/tests/test_base_client.py +++ b/tests/test_base_client.py @@ -25,7 +25,7 @@ ) from flame_hub.auth import ClientAuth, PasswordAuth, StaticAuth from flame_hub.types import FilterOperator -from flame_hub.models import Node, Realm, User, Bucket, RefreshToken +from flame_hub.models import Node, Realm, User, Bucket, RefreshToken, ClientCredentials from tests.helpers import next_random_string @@ -228,18 +228,17 @@ def new_mock_client(body: t.Any, status_code: int = httpx.codes.OK.value) -> htt NODE_JSON = { "id": "5a0a3b0e-e4a0-4a41-9d7f-e2b9dcdcf9c1", "name": "my-node", - "external_name": None, + "externalName": None, "hidden": False, - "realm_id": "9d6b7a44-3f66-4f8b-9d2e-9dd0d2d7b2c1", - "registry_id": None, + "realmId": "9d6b7a44-3f66-4f8b-9d2e-9dd0d2d7b2c1", + "registryId": None, "type": "default", - "public_key": None, + "publicKey": None, "online": False, - "registry_project_id": None, - "robot_id": None, - "client_id": None, - "created_at": "2026-07-20T09:25:01.000Z", - "updated_at": "2026-07-20T09:25:01.000Z", + "registryProjectId": None, + "clientId": None, + "createdAt": "2026-07-20T09:25:01.000Z", + "updatedAt": "2026-07-20T09:25:01.000Z", } @@ -272,12 +271,94 @@ def test_auth_client_keeps_list_response_untouched(): def test_base_client_keeps_single_resource_untouched(): - # the FLAME Hub core and storage services respond with the resource itself + # the base client makes no assumption about the envelope, so it passes the body through unchanged client = BaseClient(base_url="http://localhost", client=new_mock_client(NODE_JSON)) assert client._get_single_resource(Node, "nodes", NODE_JSON["id"]) == Node(**NODE_JSON) +BUCKET_JSON = { + "id": "0a6ff30e-2f6f-4a09-9d67-33f5b5a4bb3e", + "name": "my-bucket", + "region": None, + "createdAt": "2026-07-20T09:25:01.000Z", + "updatedAt": "2026-07-20T09:25:01.000Z", + "actorId": None, + "actorType": None, + "realmId": None, +} + +CLIENT_CREDENTIALS_JSON = { + "id": "9b5d0b2c-19d7-4b7f-9a9a-4b1f0aa0f9de", + "secret": "s3cr3t", + "name": "my-node", + "displayName": "My Node", +} + + +@pytest.mark.parametrize( + "status_code,call_client", + [ + (httpx.codes.OK.value, lambda c: c.get_node(NODE_JSON["id"])), + (httpx.codes.CREATED.value, lambda c: c.create_node(NODE_JSON["name"], NODE_JSON["realmId"])), + (httpx.codes.ACCEPTED.value, lambda c: c.update_node(NODE_JSON["id"], hidden=False)), + ], +) +def test_core_client_unwraps_single_resource(status_code, call_client): + # the FLAME Hub wraps records as {"data": ..., "meta": ...} since 0.13.0 + core_client = flame_hub.CoreClient(client=new_mock_client({"data": NODE_JSON, "meta": {}}, status_code)) + + assert call_client(core_client) == Node(**NODE_JSON) + + +def test_core_client_rejects_unwrapped_single_resource(): + core_client = flame_hub.CoreClient(client=new_mock_client(NODE_JSON)) + + with pytest.raises(ValueError, match="data property"): + core_client.get_node(NODE_JSON["id"]) + + +def test_core_client_unwraps_analysis_command_response(): + # POST /analyses/:id/command reads the body itself rather than going through _get_single_resource + core_client = flame_hub.CoreClient(client=new_mock_client(NODE_JSON, httpx.codes.ACCEPTED.value)) + + with pytest.raises(ValueError, match="data property"): + core_client.send_analysis_command(uuid.uuid4(), "configurationLock") + + +@pytest.mark.parametrize( + "call_client", + [ + lambda c: c.get_node_client_credentials(NODE_JSON["id"]), + lambda c: c.get_analysis_client_credentials(uuid.uuid4()), + ], +) +def test_core_client_keeps_credentials_flat(call_client): + # the credential routes are among the endpoints which deliberately respond without an envelope + core_client = flame_hub.CoreClient(client=new_mock_client(CLIENT_CREDENTIALS_JSON)) + + assert call_client(core_client) == ClientCredentials(**CLIENT_CREDENTIALS_JSON) + + +def test_core_client_keeps_list_response_untouched(): + core_client = flame_hub.CoreClient(client=new_mock_client({"data": [NODE_JSON], "meta": {"total": 1}})) + + assert core_client.get_nodes() == [Node(**NODE_JSON)] + + +def test_storage_client_unwraps_single_resource(): + storage_client = flame_hub.StorageClient(client=new_mock_client({"data": BUCKET_JSON, "meta": {}})) + + assert storage_client.get_bucket(BUCKET_JSON["id"]) == Bucket(**BUCKET_JSON) + + +def test_storage_client_rejects_unwrapped_single_resource(): + storage_client = flame_hub.StorageClient(client=new_mock_client(BUCKET_JSON)) + + with pytest.raises(ValueError, match="data property"): + storage_client.get_bucket(BUCKET_JSON["id"]) + + @pytest.mark.integration @pytest.mark.parametrize( "resource_type,base_url_fixture_name,path", diff --git a/tests/test_core.py b/tests/test_core.py index f604259..38963b1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -37,30 +37,28 @@ def sync_master_images(core_client): def master_image(core_client): default_master_image = os.getenv("PYTEST_DEFAULT_MASTER_IMAGE", "python/base") - if len(core_client.find_master_images(filter={"virtual_path": default_master_image})) != 1: + if len(core_client.find_master_images(filter={"virtualPath": default_master_image})) != 1: sync_master_images(core_client) def _check_default_master_image_available(): - assert len(core_client.find_master_images(filter={"virtual_path": default_master_image})) == 1 + assert len(core_client.find_master_images(filter={"virtualPath": default_master_image})) == 1 assert_eventually(_check_default_master_image_available, max_retries=10, delay_millis=1000) - return core_client.find_master_images(filter={"virtual_path": default_master_image})[0] + return core_client.find_master_images(filter={"virtualPath": default_master_image})[0] @pytest.fixture(scope="module") def master_image_group(core_client, master_image): - if len(core_client.find_master_image_groups(filter={"virtual_path": master_image.group_virtual_path})) != 1: + if len(core_client.find_master_image_groups(filter={"virtualPath": master_image.groupVirtualPath})) != 1: sync_master_images(core_client) def _check_default_master_image_group_available(): - assert ( - len(core_client.find_master_image_groups(filter={"virtual_path": master_image.group_virtual_path})) == 1 - ) + assert len(core_client.find_master_image_groups(filter={"virtualPath": master_image.groupVirtualPath})) == 1 assert_eventually(_check_default_master_image_group_available, max_retries=10, delay_millis=1000) - return core_client.find_master_image_groups(filter={"virtual_path": master_image.group_virtual_path})[0] + return core_client.find_master_image_groups(filter={"virtualPath": master_image.groupVirtualPath})[0] @pytest.fixture() @@ -103,7 +101,7 @@ def project_node_includables(): def analysis(core_client, project, master_image): new_analysis = core_client.create_analysis( project, - master_image_id=master_image.id, + masterImageId=master_image.id, ) yield new_analysis core_client.delete_analysis(new_analysis) @@ -116,7 +114,7 @@ def analysis_includables(): @pytest.fixture() def analysis_node(core_client, analysis, project_node): - new_analysis_node = core_client.create_analysis_node(analysis.id, project_node.node_id) + new_analysis_node = core_client.create_analysis_node(analysis.id, project_node.nodeId) yield new_analysis_node core_client.delete_analysis_node(new_analysis_node) @@ -129,11 +127,11 @@ def analysis_node_includables(): @pytest.fixture() def analysis_code_bucket(core_client, analysis): def _wait_for_buckets(): - assert len(core_client.find_analysis_buckets(filter={"analysis_id": analysis.id})) != 0 + assert len(core_client.find_analysis_buckets(filter={"analysisId": analysis.id})) != 0 assert_eventually(_wait_for_buckets) - analysis_buckets = core_client.find_analysis_buckets(filter={"analysis_id": analysis.id}) + analysis_buckets = core_client.find_analysis_buckets(filter={"analysisId": analysis.id}) code_buckets = [bucket for bucket in analysis_buckets if bucket.type == AnalysisBucketType.CODE] assert len(code_buckets) == 1 @@ -154,7 +152,7 @@ def analysis_bucket_file(core_client, storage_client, analysis_code_bucket, rng_ # Upload example file to referenced bucket. file_name = next_random_string() bucket_files = storage_client.upload_to_bucket( - analysis_code_bucket.bucket_id, {"file_name": file_name, "content": rng_bytes} + analysis_code_bucket.bucketId, {"file_name": file_name, "content": rng_bytes} ) assert len(bucket_files) == 1 @@ -163,14 +161,14 @@ def analysis_bucket_file(core_client, storage_client, analysis_code_bucket, rng_ def _wait_for_analysis_bucket_file(): analysis_bucket_files = core_client.find_analysis_bucket_files( - filter={"analysis_id": analysis_code_bucket.analysis_id} + filter={"analysisId": analysis_code_bucket.analysisId} ) assert len(analysis_bucket_files) == 1 assert_eventually(_wait_for_analysis_bucket_file) analysis_bucket_file = core_client.find_analysis_bucket_files( - filter={"analysis_id": analysis_code_bucket.analysis_id} + filter={"analysisId": analysis_code_bucket.analysisId} ).pop() yield analysis_bucket_file @@ -191,12 +189,12 @@ def configured_analysis(core_client, registry, analysis, master_realm, analysis_ for node_type in t.get_args(NodeType): new_node = core_client.create_node( name=next_random_string(), - realm_id=master_realm, + realmId=master_realm, node_type=node_type, - registry_id=registry.id, + registryId=registry.id, ) nodes.append(new_node) - core_client.create_project_node(analysis.project_id, new_node) + core_client.create_project_node(analysis.projectId, new_node) core_client.create_analysis_node(analysis, new_node) # Mark analysis bucket file as the entrypoint. @@ -224,11 +222,11 @@ def analysis_log(core_client, configured_analysis): core_client.send_analysis_command(configured_analysis, "buildStart") def _check_analysis_logs_present(): - assert len(core_client.find_analysis_logs(filter={"analysis_id": configured_analysis.id})) > 0 + assert len(core_client.find_analysis_logs(filter={"analysisId": configured_analysis.id})) > 0 assert_eventually(_check_analysis_logs_present) - return core_client.find_analysis_logs(filter={"analysis_id": configured_analysis.id})[0] + return core_client.find_analysis_logs(filter={"analysisId": configured_analysis.id})[0] @pytest.fixture() @@ -236,8 +234,8 @@ def registry(core_client): new_registry = core_client.create_registry( name=next_random_string(), host=next_random_string(), - # account_name=next_random_string(), - account_secret=next_random_string(), + # accountName=next_random_string(), + accountSecret=next_random_string(), ) yield new_registry core_client.delete_registry(new_registry) @@ -253,8 +251,8 @@ def registry_project(core_client, registry): new_registry_project = core_client.create_registry_project( name=next_random_string(), registry_project_type="default", - registry_id=registry, - external_name=next_random_string(charset=string.ascii_lowercase + string.digits), + registryId=registry, + externalName=next_random_string(charset=string.ascii_lowercase + string.digits), ) yield new_registry_project core_client.delete_registry_project(new_registry_project) @@ -298,39 +296,39 @@ def test_get_node_not_found(core_client): def test_update_node(core_client, node): # only accepts lowercase letters and numbers new_name = next_random_string(charset=string.ascii_lowercase + string.digits) - new_node = core_client.update_node(node.id, external_name=new_name) + new_node = core_client.update_node(node.id, externalName=new_name) assert node != new_node - assert new_node.external_name == new_name + assert new_node.externalName == new_name @pytest.mark.xfail(reason="Hub returns properties of another registry") def test_get_node_registry_credentials(core_client, node, registry): - core_client.update_node(node.id, registry_id=registry.id) + core_client.update_node(node.id, registryId=registry.id) credentials = core_client.get_node_registry_credentials(node.id) assert credentials.host == registry.host - assert credentials.account_name == registry.account_name - assert credentials.account_secret == registry.account_secret + assert credentials.accountName == registry.accountName + assert credentials.accountSecret == registry.accountSecret def test_get_node_client_credentials(core_client, auth_client, node): - client = auth_client.get_client(client_id=node.client_id) - credentials = core_client.get_node_client_credentials(node_id=node.id) + client = auth_client.get_client(clientId=node.clientId) + credentials = core_client.get_node_client_credentials(nodeId=node.id) assert credentials.id == client.id assert credentials.name == client.name - assert credentials.display_name == client.display_name + assert credentials.displayName == client.displayName def test_update_node_client_credentials(core_client, node): new_name, new_display_name, new_secret = next_random_string().lower(), next_random_string(), next_random_string() new_credentials = core_client.update_node_client_credentials( - node_id=node.id, name=new_name, display_name=new_display_name, secret=new_secret + nodeId=node.id, name=new_name, displayName=new_display_name, secret=new_secret ) assert new_credentials.name == new_name - assert new_credentials.display_name == new_display_name + assert new_credentials.displayName == new_display_name assert new_credentials.secret == new_secret @@ -378,11 +376,11 @@ def test_get_project_not_found(core_client): def test_update_project(core_client, project): new_name = next_random_string() new_display_name = next_random_string() - new_node = core_client.update_project(project.id, name=new_name, display_name=new_display_name) + new_node = core_client.update_project(project.id, name=new_name, displayName=new_display_name) assert project != new_node assert new_node.name.lower() == new_name.lower() - assert new_node.display_name.lower() == new_display_name.lower() + assert new_node.displayName.lower() == new_display_name.lower() def test_get_project_nodes(core_client, project_node, project_node_includables): @@ -393,8 +391,8 @@ def test_get_project_nodes(core_client, project_node, project_node_includables): def test_find_project_nodes(core_client, project_node, project_node_includables): - # Use "project_id" instead of "id" because filtering for ids does not work. - project_nodes_find = core_client.find_project_nodes(filter={"project_id": project_node.project_id}) + # Use "projectId" instead of "id" because filtering for ids does not work. + project_nodes_find = core_client.find_project_nodes(filter={"projectId": project_node.projectId}) assert [project_node.id] == [pn.id for pn in project_nodes_find] assert all( @@ -412,9 +410,9 @@ def test_get_project_node(core_client, project_node, project_node_includables): def test_update_project_node(core_client, project_node): new_comment = next_random_string() - new_project_node = core_client.update_project_node(project_node.id, approval_status="rejected", comment=new_comment) + new_project_node = core_client.update_project_node(project_node.id, approvalStatus="rejected", comment=new_comment) - assert new_project_node.approval_status == "rejected" + assert new_project_node.approvalStatus == "rejected" assert new_project_node != project_node @@ -460,14 +458,14 @@ def test_update_analysis(core_client, analysis): new_analysis = core_client.update_analysis( analysis.id, name=new_name, - display_name=new_display_name, - image_command_arguments=args, + displayName=new_display_name, + imageCommandArguments=args, ) assert analysis != new_analysis assert new_analysis.name.lower() == new_name.lower() - assert new_analysis.display_name.lower() == new_display_name.lower() - assert new_analysis.image_command_arguments == args # Note that args is modified during updating the analysis. + assert new_analysis.displayName.lower() == new_display_name.lower() + assert new_analysis.imageCommandArguments == args # Note that args is modified during updating the analysis. def test_create_analysis_with_arguments(core_client, project, master_image): @@ -477,19 +475,19 @@ def test_create_analysis_with_arguments(core_client, project, master_image): ] analysis = core_client.create_analysis( project, - master_image_id=master_image.id, - image_command_arguments=args, + masterImageId=master_image.id, + imageCommandArguments=args, ) core_client.delete_analysis(analysis) def test_unlock_analysis(core_client, configured_analysis): assert ( - core_client.send_analysis_command(configured_analysis.id, command="configurationUnlock").configuration_locked + core_client.send_analysis_command(configured_analysis.id, command="configurationUnlock").configurationLocked is False ) assert ( - core_client.send_analysis_command(configured_analysis.id, command="configurationLock").configuration_locked + core_client.send_analysis_command(configured_analysis.id, command="configurationLock").configurationLocked is True ) @@ -497,32 +495,32 @@ def test_unlock_analysis(core_client, configured_analysis): def test_build_analysis(core_client, configured_analysis): assert ( core_client.send_analysis_command( - analysis_id=configured_analysis.id, + analysisId=configured_analysis.id, command="buildStart", - ).build_status + ).buildStatus == "starting" ) def _wait_for_successful_build(): - analysis = core_client.get_analysis(analysis_id=configured_analysis.id) - assert analysis.build_status == "executed" + analysis = core_client.get_analysis(analysisId=configured_analysis.id) + assert analysis.buildStatus == "executed" assert_eventually(_wait_for_successful_build) def test_get_analysis_client_credentials(core_client, auth_client, analysis): - client = auth_client.get_client(client_id=analysis.client_id) - credentials = core_client.get_analysis_client_credentials(analysis_id=analysis.id) + client = auth_client.get_client(clientId=analysis.clientId) + credentials = core_client.get_analysis_client_credentials(analysisId=analysis.id) assert credentials.id == client.id assert credentials.name == client.name - assert credentials.display_name == client.display_name + assert credentials.displayName == client.displayName def test_update_analysis_client_credentials(core_client, analysis): new_name, new_display_name, new_secret = next_random_string().lower(), next_random_string(), next_random_string() new_credentials = core_client.update_analysis_client_credentials( - analysis_id=analysis.id, name=new_name, display_name=new_display_name, secret=new_secret + analysisId=analysis.id, name=new_name, displayName=new_display_name, secret=new_secret ) assert new_credentials.secret == new_secret @@ -533,13 +531,13 @@ def test_update_analysis_node(core_client, analysis_node): status = random.choice(t.get_args(ProcessStatus)) new_analysis_node = core_client.update_analysis_node( analysis_node.id, - execution_status=status, - execution_progress=progress, + executionStatus=status, + executionProgress=progress, ) assert analysis_node != new_analysis_node - assert new_analysis_node.execution_status == status - assert new_analysis_node.execution_progress == progress + assert new_analysis_node.executionStatus == status + assert new_analysis_node.executionProgress == progress def test_get_analysis_nodes(core_client, analysis_node, analysis_node_includables): @@ -552,8 +550,8 @@ def test_get_analysis_nodes(core_client, analysis_node, analysis_node_includable def test_find_analysis_nodes(core_client, analysis_node, analysis_node_includables): - # Use "analysis_id" instead of "id" because filtering for ids does not work. - analysis_nodes_find = core_client.find_analysis_nodes(filter={"analysis_id": analysis_node.analysis_id}) + # Use "analysisId" instead of "id" because filtering for ids does not work. + analysis_nodes_find = core_client.find_analysis_nodes(filter={"analysisId": analysis_node.analysisId}) assert [analysis_node.id] == [an.id for an in analysis_nodes_find] assert all( @@ -576,32 +574,32 @@ def test_get_analysis_node_not_found(core_client): @pytest.mark.xfail(reason="Deletion of analysis node logs does not work") def test_analysis_node_logs(core_client, analysis_node): log = core_client.create_analysis_node_log( - analysis_id=analysis_node.analysis_id, - node_id=analysis_node.node_id, + analysisId=analysis_node.analysisId, + nodeId=analysis_node.nodeId, level=random.choice(t.get_args(LogLevel)), message=next_random_string(), ) def _check_analysis_node_logs_present(): found_logs = core_client.find_analysis_node_logs( - filter={"analysis_id": analysis_node.analysis_id, "node_id": analysis_node.node_id} + filter={"analysisId": analysis_node.analysisId, "nodeId": analysis_node.nodeId} ) assert len(found_logs) == 1 assert_eventually(_check_analysis_node_logs_present) new_log = core_client.find_analysis_node_logs( - filter={"analysis_id": analysis_node.analysis_id, "node_id": analysis_node.node_id} + filter={"analysisId": analysis_node.analysisId, "nodeId": analysis_node.nodeId} )[0] assert log == new_log - core_client.delete_analysis_node_logs(analysis_id=analysis_node.analysis_id, node_id=analysis_node.node_id) + core_client.delete_analysis_node_logs(analysisId=analysis_node.analysisId, nodeId=analysis_node.nodeId) assert ( len( core_client.find_analysis_node_logs( - filter={"analysis_id": analysis_node.analysis_id, "node_id": analysis_node.node_id} + filter={"analysisId": analysis_node.analysisId, "nodeId": analysis_node.nodeId} ) ) == 0 @@ -630,8 +628,8 @@ def test_get_analysis_buckets(core_client, analysis_code_bucket, analysis_bucket def test_find_analysis_buckets(core_client, analysis_code_bucket, analysis_bucket_includables): - # Use "analysis_id" instead of "id" because filtering for ids does not work. - analysis_buckets_find = core_client.find_analysis_buckets(filter={"analysis_id": analysis_code_bucket.analysis_id}) + # Use "analysisId" instead of "id" because filtering for ids does not work. + analysis_buckets_find = core_client.find_analysis_buckets(filter={"analysisId": analysis_code_bucket.analysisId}) assert analysis_code_bucket.id in [bucket.id for bucket in analysis_buckets_find] assert all( @@ -667,9 +665,9 @@ def test_get_analysis_bucket_files(core_client, analysis_bucket_file, analysis_b def test_find_analysis_bucket_files(core_client, analysis_bucket_file, analysis_bucket_file_includables): - # Use "analysis_id" instead of "id" because filtering for ids does not work. + # Use "analysisId" instead of "id" because filtering for ids does not work. analysis_bucket_files_find = core_client.find_analysis_bucket_files( - filter={"analysis_id": analysis_bucket_file.analysis_id} + filter={"analysisId": analysis_bucket_file.analysisId} ) assert [analysis_bucket_file.id] == [abf.id for abf in analysis_bucket_files_find] @@ -726,7 +724,7 @@ def test_registry_setup(core_client, registry): core_client.send_registry_command(registry.id, command="setup") def _check_setup(): - registry_projects = core_client.find_registry_projects(filter={"registry_id": registry.id}) + registry_projects = core_client.find_registry_projects(filter={"registryId": registry.id}) assert len(registry_projects) == 3 assert {"incoming", "outgoing", "masterImages"} == set(rp.type for rp in registry_projects) @@ -780,6 +778,6 @@ def test_update_registry_project(core_client, registry_project): @pytest.mark.xfail(reason="Bug in Hub, see https://github.com/PrivateAIM/hub/issues/1181.") def test_delete_analysis_logs(core_client, analysis_log): - core_client.delete_analysis_logs(analysis_id=analysis_log.labels["analysis_id"]) + core_client.delete_analysis_logs(analysisId=analysis_log.labels["analysisId"]) - assert len(core_client.find_analysis_logs(filter={"analysis_id": analysis_log.labels["analysis_id"]})) == 0 + assert len(core_client.find_analysis_logs(filter={"analysisId": analysis_log.labels["analysisId"]})) == 0 diff --git a/tests/test_storage.py b/tests/test_storage.py index e7ade35..c403e10 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -55,7 +55,7 @@ def test_get_bucket(storage_client, bucket): def test_stream_bucket_tarball(storage_client, bucket_file, rng_bytes): - tarball_bytes = next(storage_client.stream_bucket_tarball(bucket_file.bucket_id)) + tarball_bytes = next(storage_client.stream_bucket_tarball(bucket_file.bucketId)) with tarfile.open(fileobj=BytesIO(tarball_bytes), mode="r") as tar: members = tar.getmembers()