Skip to content

Commit f0b4abc

Browse files
authored
Merge pull request #1003 from atlanhq/aryaman/bldx-1611
feat: client.requests + client.inbox — approve/reject both request systems programmatically (BLDX-1611)
2 parents b24e424 + e7c16a8 commit f0b4abc

19 files changed

Lines changed: 1959 additions & 0 deletions
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Atlan Pte. Ltd.
3+
4+
from __future__ import annotations
5+
6+
from typing import Optional
7+
8+
from pydantic.v1 import validate_arguments
9+
10+
from pyatlan.client.common import (
11+
ApprovalWorkflowBulkActionRequests,
12+
ApprovalWorkflowGetRequest,
13+
AsyncApiCaller,
14+
)
15+
from pyatlan.errors import ErrorCode, InvalidRequestError
16+
from pyatlan.model.enums import ApprovalWorkflowRequestType
17+
from pyatlan.model.approval_workflow import (
18+
ApprovalWorkflowBulkActionResponse,
19+
ApprovalWorkflowRequest,
20+
)
21+
22+
23+
def _raise_if_recipient_scoped(err: InvalidRequestError, group_key: str):
24+
"""Translate the server's misleading 1003 into an actionable message.
25+
26+
Bulk actions are RECIPIENT-scoped: the server reports "No pending tasks
27+
found for the specified group" even when the group visibly has pending
28+
tasks — whenever none of them are addressed to the calling identity.
29+
"""
30+
if "No pending tasks found" not in str(err):
31+
return
32+
raise ErrorCode.INVALID_REQUEST_PASSTHROUGH.exception_with_parameters(
33+
"1003",
34+
(
35+
f"no actionable pending tasks in group '{group_key}' for the "
36+
"calling identity. Two common causes: (1) every task in the "
37+
"group is already actioned (approved/rejected/withdrawn) — "
38+
"check task_execution_action via a Task search; (2) the pending "
39+
"tasks are addressed to a different user — bulk approvals are "
40+
"recipient-scoped, and an admin role does not override this. "
41+
"To automate approvals, the token's identity must be the "
42+
"workflow's approver (the workflow builder currently supports "
43+
"only human users and groups as approvers, so automation may "
44+
"require a user token)."
45+
),
46+
"",
47+
) from err
48+
49+
50+
class AsyncApprovalWorkflowClient:
51+
"""
52+
Async client for the governance-workflow approval system (the newer
53+
Inbox). For the classic Requests module use `client.requests` instead —
54+
tenants can have both.
55+
"""
56+
57+
def __init__(self, client: AsyncApiCaller):
58+
if not isinstance(client, AsyncApiCaller):
59+
raise ErrorCode.INVALID_PARAMETER_TYPE.exception_with_parameters(
60+
"client", "AsyncApiCaller"
61+
)
62+
self._client = client
63+
64+
@validate_arguments
65+
async def get(self, guid: str) -> Optional[ApprovalWorkflowRequest]:
66+
"""
67+
Retrieve one approval-workflow request by its GUID.
68+
69+
:param guid: unique identifier of the workflow request
70+
:raises AtlanError: on any error during API invocation.
71+
:returns: the workflow request, or None if it does not exist
72+
"""
73+
endpoint = ApprovalWorkflowGetRequest.prepare_request(guid)
74+
raw_json = await self._client._call_api(endpoint)
75+
return ApprovalWorkflowGetRequest.process_response(raw_json)
76+
77+
@validate_arguments
78+
async def approve_all(
79+
self,
80+
group_key: str,
81+
sub_type: Optional[ApprovalWorkflowRequestType] = None,
82+
comment: Optional[str] = None,
83+
) -> ApprovalWorkflowBulkActionResponse:
84+
"""Bulk-approve all pending workflow tasks in a group."""
85+
endpoint, request_obj = ApprovalWorkflowBulkActionRequests.prepare_request(
86+
group_key, "APPROVED", sub_type, comment
87+
)
88+
try:
89+
raw_json = await self._client._call_api(endpoint, request_obj=request_obj)
90+
except InvalidRequestError as err:
91+
_raise_if_recipient_scoped(err, group_key)
92+
raise
93+
return ApprovalWorkflowBulkActionRequests.process_response(raw_json)
94+
95+
@validate_arguments
96+
async def reject_all(
97+
self,
98+
group_key: str,
99+
sub_type: Optional[ApprovalWorkflowRequestType] = None,
100+
comment: Optional[str] = None,
101+
) -> ApprovalWorkflowBulkActionResponse:
102+
"""Bulk-reject all pending workflow tasks in a group."""
103+
endpoint, request_obj = ApprovalWorkflowBulkActionRequests.prepare_request(
104+
group_key, "REJECTED", sub_type, comment
105+
)
106+
try:
107+
raw_json = await self._client._call_api(endpoint, request_obj=request_obj)
108+
except InvalidRequestError as err:
109+
_raise_if_recipient_scoped(err, group_key)
110+
raise
111+
return ApprovalWorkflowBulkActionRequests.process_response(raw_json)

pyatlan/client/aio/client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
from pyatlan.client.aio.search_log import AsyncSearchLogClient
5050
from pyatlan.client.aio.sso import AsyncSSOClient
5151
from pyatlan.client.aio.task import AsyncTaskClient
52+
from pyatlan.client.aio.approval_workflow import AsyncApprovalWorkflowClient
53+
from pyatlan.client.aio.requests import AsyncRequestsClient
5254
from pyatlan.client.aio.token import AsyncTokenClient
5355
from pyatlan.client.aio.typedef import AsyncTypeDefClient
5456
from pyatlan.client.aio.app import AsyncAppClient
@@ -114,6 +116,10 @@ class AsyncAtlanClient(AtlanClient):
114116
_async_search_log_client: Optional[AsyncSearchLogClient] = PrivateAttr(default=None)
115117
_async_sso_client: Optional[AsyncSSOClient] = PrivateAttr(default=None)
116118
_async_task_client: Optional[AsyncTaskClient] = PrivateAttr(default=None)
119+
_async_approval_workflow_client: Optional[AsyncApprovalWorkflowClient] = (
120+
PrivateAttr(default=None)
121+
)
122+
_async_requests_client: Optional[AsyncRequestsClient] = PrivateAttr(default=None)
117123
_async_token_client: Optional[AsyncTokenClient] = PrivateAttr(default=None)
118124
_async_oauth_client_client: Optional[AsyncOAuthClient] = PrivateAttr(default=None)
119125
_async_typedef_client: Optional[AsyncTypeDefClient] = PrivateAttr(default=None)
@@ -361,6 +367,20 @@ def tasks(self) -> AsyncTaskClient: # type: ignore[override]
361367
self._async_task_client = AsyncTaskClient(client=self) # type: ignore[arg-type]
362368
return self._async_task_client
363369

370+
@property
371+
def inbox(self) -> AsyncApprovalWorkflowClient: # type: ignore[override]
372+
"""Async approval-workflow client (governance Inbox)"""
373+
if self._async_approval_workflow_client is None:
374+
self._async_approval_workflow_client = AsyncApprovalWorkflowClient(self) # type: ignore[arg-type]
375+
return self._async_approval_workflow_client
376+
377+
@property
378+
def requests(self) -> AsyncRequestsClient: # type: ignore[override]
379+
"""Async requests client for Metadata Inbox operations"""
380+
if self._async_requests_client is None:
381+
self._async_requests_client = AsyncRequestsClient(self) # type: ignore[arg-type]
382+
return self._async_requests_client
383+
364384
@property
365385
def token(self) -> AsyncTokenClient: # type: ignore[override]
366386
"""Get async token client with same API as sync"""

pyatlan/client/aio/requests.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Atlan Pte. Ltd.
3+
4+
from __future__ import annotations
5+
6+
from typing import Optional
7+
8+
from pydantic.v1 import validate_arguments
9+
10+
from pyatlan.client.common import (
11+
AsyncApiCaller,
12+
RequestsAction,
13+
RequestsCreate,
14+
RequestsGetById,
15+
RequestsList,
16+
RequestsListActionable,
17+
)
18+
from pyatlan.errors import ErrorCode
19+
from pyatlan.model.aio.atlan_request import AsyncAtlanRequestResponse
20+
from pyatlan.model.atlan_request import (
21+
AtlanRequest,
22+
AtlanRequestsCriteria,
23+
build_requests_filter,
24+
)
25+
from pyatlan.model.enums import AtlanRequestStatus, AtlanRequestType
26+
27+
28+
class AsyncRequestsClient:
29+
"""
30+
Async client for operating on Atlan requests (the Metadata Inbox):
31+
listing, retrieving, creating, approving and rejecting them.
32+
33+
Note: requests are only visible to the identity behind the API token —
34+
an API key's service account must be an admin (or the designated
35+
approver) to see and action requests raised for human users.
36+
"""
37+
38+
def __init__(self, client: AsyncApiCaller):
39+
if not isinstance(client, AsyncApiCaller):
40+
raise ErrorCode.INVALID_PARAMETER_TYPE.exception_with_parameters(
41+
"client", "AsyncApiCaller"
42+
)
43+
self._client = client
44+
45+
@validate_arguments
46+
async def list(
47+
self,
48+
status: Optional[AtlanRequestStatus] = None,
49+
request_type: Optional[AtlanRequestType] = None,
50+
destination_guid: Optional[str] = None,
51+
destination_qualified_name: Optional[str] = None,
52+
entity_type: Optional[str] = None,
53+
created_by: Optional[str] = None,
54+
post_filter: Optional[str] = None,
55+
sort: Optional[str] = None,
56+
count: bool = True,
57+
offset: int = 0,
58+
limit: int = 20,
59+
) -> AsyncAtlanRequestResponse:
60+
"""
61+
List requests, optionally filtered by typed arguments.
62+
Async-iterate the response to lazily page through ALL matches.
63+
64+
:param status: only requests with this status (AtlanRequestStatus, e.g. ACTIVE)
65+
:param request_type: only this type (AtlanRequestType, e.g. ATTRIBUTE, ATLAN_TAG)
66+
:param destination_guid: only requests against this asset GUID
67+
:param destination_qualified_name: only requests against this qualified name
68+
:param entity_type: only requests against this asset type
69+
:param created_by: only requests raised by this user
70+
:param post_filter: raw JSON filter (escape hatch — cannot be combined with the typed filters above)
71+
:param sort: property by which to sort the results, e.g. `-createdAt`
72+
:param count: whether to include the total number of records
73+
:param offset: starting point for results, for paging
74+
:param limit: maximum number of results per page
75+
:raises AtlanError: on any error during API invocation.
76+
:returns: a lazily-pageable response of requests
77+
"""
78+
criteria = AtlanRequestsCriteria(
79+
post_filter=build_requests_filter(
80+
status=status,
81+
request_type=request_type,
82+
destination_guid=destination_guid,
83+
destination_qualified_name=destination_qualified_name,
84+
entity_type=entity_type,
85+
created_by=created_by,
86+
post_filter=post_filter,
87+
),
88+
sort=sort,
89+
count=count,
90+
offset=offset,
91+
limit=limit,
92+
)
93+
endpoint, query_params = RequestsList.prepare_request(criteria)
94+
raw_json = await self._client._call_api(endpoint, query_params)
95+
return AsyncAtlanRequestResponse(
96+
client=self._client,
97+
endpoint=RequestsList.ENDPOINT,
98+
criteria=criteria,
99+
start=offset,
100+
size=limit,
101+
**raw_json,
102+
)
103+
104+
@validate_arguments
105+
async def list_actionable(
106+
self,
107+
status: Optional[AtlanRequestStatus] = None,
108+
request_type: Optional[AtlanRequestType] = None,
109+
destination_guid: Optional[str] = None,
110+
destination_qualified_name: Optional[str] = None,
111+
entity_type: Optional[str] = None,
112+
created_by: Optional[str] = None,
113+
post_filter: Optional[str] = None,
114+
sort: Optional[str] = None,
115+
count: bool = True,
116+
offset: int = 0,
117+
limit: int = 20,
118+
) -> AsyncAtlanRequestResponse:
119+
"""
120+
List requests the current identity can approve or reject.
121+
Async-iterate the response to lazily page through ALL matches.
122+
123+
:param status: only requests with this status (AtlanRequestStatus, e.g. ACTIVE)
124+
:param request_type: only this type (AtlanRequestType, e.g. ATTRIBUTE, ATLAN_TAG)
125+
:param destination_guid: only requests against this asset GUID
126+
:param destination_qualified_name: only requests against this qualified name
127+
:param entity_type: only requests against this asset type
128+
:param created_by: only requests raised by this user
129+
:param post_filter: raw JSON filter (escape hatch — cannot be combined with the typed filters above)
130+
:param sort: property by which to sort the results, e.g. `-createdAt`
131+
:param count: whether to include the total number of records
132+
:param offset: starting point for results, for paging
133+
:param limit: maximum number of results per page
134+
:raises AtlanError: on any error during API invocation.
135+
:returns: a lazily-pageable response of requests
136+
"""
137+
criteria = AtlanRequestsCriteria(
138+
post_filter=build_requests_filter(
139+
status=status,
140+
request_type=request_type,
141+
destination_guid=destination_guid,
142+
destination_qualified_name=destination_qualified_name,
143+
entity_type=entity_type,
144+
created_by=created_by,
145+
post_filter=post_filter,
146+
),
147+
sort=sort,
148+
count=count,
149+
offset=offset,
150+
limit=limit,
151+
)
152+
endpoint, query_params = RequestsListActionable.prepare_request(criteria)
153+
raw_json = await self._client._call_api(endpoint, query_params)
154+
return AsyncAtlanRequestResponse(
155+
client=self._client,
156+
endpoint=RequestsListActionable.ENDPOINT,
157+
criteria=criteria,
158+
start=offset,
159+
size=limit,
160+
**raw_json,
161+
)
162+
163+
@validate_arguments
164+
async def get(self, guid: str) -> Optional[AtlanRequest]:
165+
"""
166+
Retrieve a single request by its GUID.
167+
168+
:param guid: unique identifier of the request
169+
:raises AtlanError: on any error during API invocation.
170+
:returns: the request, or None if it does not exist
171+
"""
172+
endpoint = RequestsGetById.prepare_request(guid)
173+
raw_json = await self._client._call_api(endpoint)
174+
return RequestsGetById.process_response(raw_json)
175+
176+
async def create(self, request: AtlanRequest) -> Optional[AtlanRequest]:
177+
"""
178+
Create (raise) a new request.
179+
180+
:param request: the request to create, e.g. via AttributeRequest.creator()
181+
:raises AtlanError: on any error during API invocation.
182+
:returns: the created request, including its server-assigned id
183+
"""
184+
endpoint, request_obj = RequestsCreate.prepare_request(request)
185+
raw_json = await self._client._call_api(endpoint, request_obj=request_obj)
186+
return RequestsCreate.process_response(raw_json)
187+
188+
@validate_arguments
189+
async def approve(self, guid: str, message: Optional[str] = None) -> bool:
190+
"""
191+
Approve a request. Approval applies the requested change.
192+
193+
:param guid: unique identifier of the request to approve
194+
:param message: optional message to include with the approval
195+
:raises AtlanError: on any error during API invocation.
196+
:returns: True if the request was approved
197+
"""
198+
endpoint, request_obj = RequestsAction.prepare_request(
199+
guid, "approved", message
200+
)
201+
raw_json = await self._client._call_api(endpoint, request_obj=request_obj)
202+
return RequestsAction.process_response(raw_json)
203+
204+
@validate_arguments
205+
async def reject(self, guid: str, message: Optional[str] = None) -> bool:
206+
"""
207+
Reject a request.
208+
209+
:param guid: unique identifier of the request to reject
210+
:param message: optional message to include with the rejection
211+
:raises AtlanError: on any error during API invocation.
212+
:returns: True if the request was rejected
213+
"""
214+
endpoint, request_obj = RequestsAction.prepare_request(
215+
guid, "rejected", message
216+
)
217+
raw_json = await self._client._call_api(endpoint, request_obj=request_obj)
218+
return RequestsAction.process_response(raw_json)

0 commit comments

Comments
 (0)