powerbi-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- powerbi_mcp-0.1.0.dist-info/METADATA +540 -0
- powerbi_mcp-0.1.0.dist-info/RECORD +18 -0
- powerbi_mcp-0.1.0.dist-info/WHEEL +4 -0
- powerbi_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- powerbi_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +13 -0
- src/auth.py +136 -0
- src/client.py +259 -0
- src/constants.py +26 -0
- src/exceptions.py +96 -0
- src/formatters.py +367 -0
- src/models.py +85 -0
- src/server.py +90 -0
- src/tools/__init__.py +15 -0
- src/tools/datasets.py +355 -0
- src/tools/queries.py +125 -0
- src/tools/workspaces.py +185 -0
- src/validation.py +40 -0
src/auth.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication module for PowerBI API
|
|
3
|
+
|
|
4
|
+
Handles Azure AD OAuth2 client credentials flow for service principal authentication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
|
|
15
|
+
from .constants import AUTH_URL_TEMPLATE, AUTH_SCOPE, AUTH_TIMEOUT, TOKEN_REFRESH_BUFFER
|
|
16
|
+
from .exceptions import handle_http_error
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Load environment variables from current working directory (where the MCP client is running)
|
|
21
|
+
# This allows .env files to be project-specific rather than server-specific
|
|
22
|
+
load_dotenv(dotenv_path=os.path.join(os.getcwd(), '.env'), override=False)
|
|
23
|
+
logger.info(f"Attempted to load .env from: {os.getcwd()}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PowerBIAuth:
|
|
27
|
+
"""Handles PowerBI authentication using client credentials flow"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
tenant_id: Optional[str] = None,
|
|
32
|
+
client_id: Optional[str] = None,
|
|
33
|
+
client_secret: Optional[str] = None
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialize authentication handler
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
tenant_id: Azure AD tenant ID (defaults to POWERBI_TENANT_ID env var)
|
|
40
|
+
client_id: Application client ID (defaults to POWERBI_CLIENT_ID env var)
|
|
41
|
+
client_secret: Application client secret (defaults to POWERBI_CLIENT_SECRET env var)
|
|
42
|
+
|
|
43
|
+
Note:
|
|
44
|
+
Credentials can be None at initialization. Validation happens when
|
|
45
|
+
authentication is attempted (when tools are actually used).
|
|
46
|
+
"""
|
|
47
|
+
self.tenant_id = tenant_id or os.getenv("POWERBI_TENANT_ID")
|
|
48
|
+
self.client_id = client_id or os.getenv("POWERBI_CLIENT_ID")
|
|
49
|
+
self.client_secret = client_secret or os.getenv("POWERBI_CLIENT_SECRET")
|
|
50
|
+
|
|
51
|
+
self.access_token: Optional[str] = None
|
|
52
|
+
self.token_expires: Optional[datetime] = None
|
|
53
|
+
self.http_client = httpx.AsyncClient(timeout=AUTH_TIMEOUT)
|
|
54
|
+
|
|
55
|
+
if all([self.tenant_id, self.client_id, self.client_secret]):
|
|
56
|
+
logger.info("PowerBI authentication handler initialized with credentials")
|
|
57
|
+
else:
|
|
58
|
+
logger.warning(
|
|
59
|
+
"PowerBI authentication handler initialized without credentials. "
|
|
60
|
+
"Set POWERBI_TENANT_ID, POWERBI_CLIENT_ID, and POWERBI_CLIENT_SECRET "
|
|
61
|
+
"environment variables before using tools."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def get_access_token(self) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Get valid access token, refreshing if necessary
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Valid access token string
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
Exception: If authentication fails
|
|
73
|
+
"""
|
|
74
|
+
if self.access_token and self.token_expires and datetime.now() < self.token_expires:
|
|
75
|
+
logger.debug("Using cached access token")
|
|
76
|
+
return self.access_token
|
|
77
|
+
|
|
78
|
+
logger.info("Access token expired or missing, refreshing...")
|
|
79
|
+
await self._authenticate()
|
|
80
|
+
return self.access_token
|
|
81
|
+
|
|
82
|
+
async def _authenticate(self) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Authenticate with Azure AD using client credentials
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
Exception: If authentication fails with detailed error message
|
|
88
|
+
"""
|
|
89
|
+
# Validate credentials before attempting authentication
|
|
90
|
+
if not all([self.tenant_id, self.client_id, self.client_secret]):
|
|
91
|
+
missing = []
|
|
92
|
+
if not self.tenant_id:
|
|
93
|
+
missing.append("POWERBI_TENANT_ID")
|
|
94
|
+
if not self.client_id:
|
|
95
|
+
missing.append("POWERBI_CLIENT_ID")
|
|
96
|
+
if not self.client_secret:
|
|
97
|
+
missing.append("POWERBI_CLIENT_SECRET")
|
|
98
|
+
|
|
99
|
+
raise Exception(
|
|
100
|
+
f"Missing required environment variables: {', '.join(missing)}. "
|
|
101
|
+
"Please set these variables in your .env file or environment configuration."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
auth_url = AUTH_URL_TEMPLATE.format(tenant_id=self.tenant_id)
|
|
105
|
+
|
|
106
|
+
data = {
|
|
107
|
+
"client_id": self.client_id,
|
|
108
|
+
"client_secret": self.client_secret,
|
|
109
|
+
"scope": AUTH_SCOPE,
|
|
110
|
+
"grant_type": "client_credentials"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
logger.debug(f"Authenticating with Azure AD at {auth_url}")
|
|
115
|
+
response = await self.http_client.post(auth_url, data=data)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
|
|
118
|
+
token_data = response.json()
|
|
119
|
+
self.access_token = token_data["access_token"]
|
|
120
|
+
expires_in = token_data.get("expires_in", 3600)
|
|
121
|
+
# Refresh before expiry to avoid race conditions
|
|
122
|
+
self.token_expires = datetime.now() + timedelta(seconds=expires_in - TOKEN_REFRESH_BUFFER)
|
|
123
|
+
|
|
124
|
+
logger.info(f"Successfully authenticated with PowerBI. Token expires in {expires_in}s")
|
|
125
|
+
|
|
126
|
+
except httpx.HTTPStatusError as e:
|
|
127
|
+
# Use centralized error handler
|
|
128
|
+
raise handle_http_error(e, "authentication") from e
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"Authentication error: {e}")
|
|
131
|
+
raise Exception(f"Failed to authenticate with PowerBI: {str(e)}") from e
|
|
132
|
+
|
|
133
|
+
async def close(self) -> None:
|
|
134
|
+
"""Close HTTP client and cleanup resources"""
|
|
135
|
+
await self.http_client.aclose()
|
|
136
|
+
logger.info("Authentication handler closed")
|
src/client.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PowerBI REST API client
|
|
3
|
+
|
|
4
|
+
Handles all interactions with PowerBI REST API endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .auth import PowerBIAuth
|
|
13
|
+
from .constants import API_BASE_URL, API_TIMEOUT
|
|
14
|
+
from .exceptions import handle_http_error
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PowerBIClient:
|
|
20
|
+
"""PowerBI REST API client"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, auth: PowerBIAuth):
|
|
23
|
+
"""
|
|
24
|
+
Initialize PowerBI API client
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
auth: PowerBIAuth instance for handling authentication
|
|
28
|
+
"""
|
|
29
|
+
self.auth = auth
|
|
30
|
+
self.http_client = httpx.AsyncClient(timeout=API_TIMEOUT)
|
|
31
|
+
logger.info("PowerBI API client initialized")
|
|
32
|
+
|
|
33
|
+
async def _make_request(
|
|
34
|
+
self,
|
|
35
|
+
method: str,
|
|
36
|
+
endpoint: str,
|
|
37
|
+
**kwargs
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""
|
|
40
|
+
Make authenticated request to PowerBI API
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
method: HTTP method (GET, POST, etc.)
|
|
44
|
+
endpoint: API endpoint path
|
|
45
|
+
**kwargs: Additional arguments for httpx request
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
JSON response from API
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
Exception: If API request fails with actionable error message
|
|
52
|
+
"""
|
|
53
|
+
token = await self.auth.get_access_token()
|
|
54
|
+
|
|
55
|
+
headers = kwargs.get("headers", {})
|
|
56
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
57
|
+
headers["Content-Type"] = "application/json"
|
|
58
|
+
kwargs["headers"] = headers
|
|
59
|
+
|
|
60
|
+
url = f"{API_BASE_URL}{endpoint}"
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
logger.debug(f"{method} {url}")
|
|
64
|
+
response = await self.http_client.request(method, url, **kwargs)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
return response.json()
|
|
67
|
+
|
|
68
|
+
except httpx.HTTPStatusError as e:
|
|
69
|
+
# Use centralized error handler
|
|
70
|
+
raise handle_http_error(e, "API request") from e
|
|
71
|
+
|
|
72
|
+
except httpx.TimeoutException as e:
|
|
73
|
+
logger.error(f"Request timeout for endpoint: {endpoint}")
|
|
74
|
+
raise Exception(
|
|
75
|
+
f"Request to PowerBI API timed out for endpoint: {endpoint}. "
|
|
76
|
+
"The operation took too long. Try reducing the scope of your query "
|
|
77
|
+
"or check PowerBI service status."
|
|
78
|
+
) from e
|
|
79
|
+
except Exception as e:
|
|
80
|
+
if isinstance(e, Exception) and "PowerBI" in str(e):
|
|
81
|
+
raise
|
|
82
|
+
logger.error(f"Unexpected error: {e}")
|
|
83
|
+
raise Exception(f"Unexpected error while calling PowerBI API: {str(e)}") from e
|
|
84
|
+
|
|
85
|
+
async def get_workspaces(
|
|
86
|
+
self,
|
|
87
|
+
top: Optional[int] = None,
|
|
88
|
+
skip: Optional[int] = None
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""
|
|
91
|
+
Get list of workspaces (groups)
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
top: Maximum number of workspaces to return (pagination)
|
|
95
|
+
skip: Number of workspaces to skip (pagination)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Dictionary with 'value' key containing list of workspaces
|
|
99
|
+
"""
|
|
100
|
+
params = {}
|
|
101
|
+
if top is not None:
|
|
102
|
+
params["$top"] = top
|
|
103
|
+
if skip is not None:
|
|
104
|
+
params["$skip"] = skip
|
|
105
|
+
|
|
106
|
+
logger.info(f"Fetching workspaces (top={top}, skip={skip})")
|
|
107
|
+
return await self._make_request("GET", "/groups", params=params)
|
|
108
|
+
|
|
109
|
+
async def get_datasets(
|
|
110
|
+
self,
|
|
111
|
+
workspace_id: Optional[str] = None
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""
|
|
114
|
+
Get list of datasets from workspace or My workspace
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
workspace_id: Workspace (group) ID. If None, returns datasets from "My workspace"
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Dictionary with 'value' key containing list of datasets
|
|
121
|
+
"""
|
|
122
|
+
if workspace_id:
|
|
123
|
+
endpoint = f"/groups/{workspace_id}/datasets"
|
|
124
|
+
logger.info(f"Fetching datasets from workspace {workspace_id}")
|
|
125
|
+
else:
|
|
126
|
+
endpoint = "/datasets"
|
|
127
|
+
logger.info("Fetching datasets from My workspace")
|
|
128
|
+
|
|
129
|
+
return await self._make_request("GET", endpoint)
|
|
130
|
+
|
|
131
|
+
async def get_dataset(
|
|
132
|
+
self,
|
|
133
|
+
dataset_id: str,
|
|
134
|
+
workspace_id: Optional[str] = None
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""
|
|
137
|
+
Get details of a specific dataset
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
dataset_id: Dataset ID
|
|
141
|
+
workspace_id: Workspace (group) ID. If None, looks in "My workspace"
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Dataset details dictionary
|
|
145
|
+
"""
|
|
146
|
+
if workspace_id:
|
|
147
|
+
endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}"
|
|
148
|
+
logger.info(f"Fetching dataset {dataset_id} from workspace {workspace_id}")
|
|
149
|
+
else:
|
|
150
|
+
endpoint = f"/datasets/{dataset_id}"
|
|
151
|
+
logger.info(f"Fetching dataset {dataset_id} from My workspace")
|
|
152
|
+
|
|
153
|
+
return await self._make_request("GET", endpoint)
|
|
154
|
+
|
|
155
|
+
async def execute_queries(
|
|
156
|
+
self,
|
|
157
|
+
dataset_id: str,
|
|
158
|
+
queries: list[dict[str, str]],
|
|
159
|
+
workspace_id: Optional[str] = None
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""
|
|
162
|
+
Execute DAX queries against a dataset
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
dataset_id: Dataset ID to query
|
|
166
|
+
queries: List of query dictionaries with 'query' key containing DAX expression
|
|
167
|
+
workspace_id: Workspace (group) ID. If None, uses "My workspace"
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Query results dictionary
|
|
171
|
+
"""
|
|
172
|
+
if workspace_id:
|
|
173
|
+
endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}/executeQueries"
|
|
174
|
+
logger.info(f"Executing {len(queries)} queries on dataset {dataset_id} in workspace {workspace_id}")
|
|
175
|
+
else:
|
|
176
|
+
endpoint = f"/datasets/{dataset_id}/executeQueries"
|
|
177
|
+
logger.info(f"Executing {len(queries)} queries on dataset {dataset_id} in My workspace")
|
|
178
|
+
|
|
179
|
+
payload = {"queries": queries}
|
|
180
|
+
|
|
181
|
+
return await self._make_request("POST", endpoint, json=payload)
|
|
182
|
+
|
|
183
|
+
async def get_refresh_history(
|
|
184
|
+
self,
|
|
185
|
+
dataset_id: str,
|
|
186
|
+
workspace_id: Optional[str] = None,
|
|
187
|
+
top: int = 5
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""
|
|
190
|
+
Get refresh history for a dataset
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
dataset_id: Dataset ID
|
|
194
|
+
workspace_id: Workspace (group) ID. If None, uses "My workspace"
|
|
195
|
+
top: Number of refresh records to return (default: 5)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Dictionary with 'value' containing refresh history records
|
|
199
|
+
"""
|
|
200
|
+
if workspace_id:
|
|
201
|
+
endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}/refreshes"
|
|
202
|
+
logger.info(f"Fetching refresh history for dataset {dataset_id} in workspace {workspace_id}")
|
|
203
|
+
else:
|
|
204
|
+
endpoint = f"/datasets/{dataset_id}/refreshes"
|
|
205
|
+
logger.info(f"Fetching refresh history for dataset {dataset_id} in My workspace")
|
|
206
|
+
|
|
207
|
+
params = {"$top": top}
|
|
208
|
+
return await self._make_request("GET", endpoint, params=params)
|
|
209
|
+
|
|
210
|
+
async def get_parameters(
|
|
211
|
+
self,
|
|
212
|
+
dataset_id: str,
|
|
213
|
+
workspace_id: Optional[str] = None
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""
|
|
216
|
+
Get parameters for a dataset
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
dataset_id: Dataset ID
|
|
220
|
+
workspace_id: Workspace (group) ID. If None, uses "My workspace"
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Dictionary with 'value' containing parameter definitions
|
|
224
|
+
"""
|
|
225
|
+
if workspace_id:
|
|
226
|
+
endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}/parameters"
|
|
227
|
+
logger.info(f"Fetching parameters for dataset {dataset_id} in workspace {workspace_id}")
|
|
228
|
+
else:
|
|
229
|
+
endpoint = f"/datasets/{dataset_id}/parameters"
|
|
230
|
+
logger.info(f"Fetching parameters for dataset {dataset_id} in My workspace")
|
|
231
|
+
|
|
232
|
+
return await self._make_request("GET", endpoint)
|
|
233
|
+
|
|
234
|
+
async def get_reports(
|
|
235
|
+
self,
|
|
236
|
+
workspace_id: Optional[str] = None
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""
|
|
239
|
+
Get reports from workspace or My workspace
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
workspace_id: Workspace (group) ID. If None, uses "My workspace"
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Dictionary with 'value' containing report list
|
|
246
|
+
"""
|
|
247
|
+
if workspace_id:
|
|
248
|
+
endpoint = f"/groups/{workspace_id}/reports"
|
|
249
|
+
logger.info(f"Fetching reports from workspace {workspace_id}")
|
|
250
|
+
else:
|
|
251
|
+
endpoint = "/reports"
|
|
252
|
+
logger.info("Fetching reports from My workspace")
|
|
253
|
+
|
|
254
|
+
return await self._make_request("GET", endpoint)
|
|
255
|
+
|
|
256
|
+
async def close(self) -> None:
|
|
257
|
+
"""Close HTTP client and cleanup resources"""
|
|
258
|
+
await self.http_client.aclose()
|
|
259
|
+
logger.info("PowerBI API client closed")
|
src/constants.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PowerBI MCP Server Constants
|
|
3
|
+
|
|
4
|
+
Single source of truth for all configuration values, timeouts, and limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# API Configuration
|
|
8
|
+
API_BASE_URL = "https://api.powerbi.com/v1.0/myorg"
|
|
9
|
+
AUTH_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
|
10
|
+
AUTH_SCOPE = "https://analysis.windows.net/powerbi/api/.default"
|
|
11
|
+
|
|
12
|
+
# Timeouts (seconds)
|
|
13
|
+
AUTH_TIMEOUT = 30.0
|
|
14
|
+
API_TIMEOUT = 60.0
|
|
15
|
+
TOKEN_REFRESH_BUFFER = 60
|
|
16
|
+
|
|
17
|
+
# Response Limits
|
|
18
|
+
CHARACTER_LIMIT = 25000
|
|
19
|
+
MAX_ROWS_DISPLAY = 100
|
|
20
|
+
MAX_WORKSPACES_TOP = 5000
|
|
21
|
+
MIN_WORKSPACES_TOP = 1
|
|
22
|
+
|
|
23
|
+
# Refresh History Limits
|
|
24
|
+
REFRESH_HISTORY_MIN = 1
|
|
25
|
+
REFRESH_HISTORY_MAX = 60
|
|
26
|
+
REFRESH_HISTORY_DEFAULT = 5
|
src/exceptions.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception handling for PowerBI MCP Server
|
|
3
|
+
|
|
4
|
+
Centralized error handling logic for HTTP errors and PowerBI-specific exceptions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PowerBIError(Exception):
|
|
14
|
+
"""Base exception for PowerBI operations"""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthenticationError(PowerBIError):
|
|
19
|
+
"""Authentication failures"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class APIError(PowerBIError):
|
|
24
|
+
"""API request failures"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def handle_http_error(error: httpx.HTTPStatusError, context: str = "API request") -> PowerBIError:
|
|
29
|
+
"""
|
|
30
|
+
Convert httpx.HTTPStatusError to PowerBIError with actionable messages
|
|
31
|
+
|
|
32
|
+
Consolidates error handling from client.py and auth.py to provide
|
|
33
|
+
consistent, user-friendly error messages.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
error: The HTTP status error from httpx
|
|
37
|
+
context: Context of the error ("API request", "authentication", etc.)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
PowerBIError subclass with detailed error message
|
|
41
|
+
"""
|
|
42
|
+
# Extract error detail
|
|
43
|
+
error_detail = ""
|
|
44
|
+
try:
|
|
45
|
+
error_detail = error.response.json()
|
|
46
|
+
except Exception:
|
|
47
|
+
error_detail = error.response.text
|
|
48
|
+
|
|
49
|
+
status_code = error.response.status_code
|
|
50
|
+
|
|
51
|
+
# Map status codes to specific errors
|
|
52
|
+
if status_code == 401:
|
|
53
|
+
logger.error("Authentication failed (401)")
|
|
54
|
+
if context == "authentication":
|
|
55
|
+
return AuthenticationError(
|
|
56
|
+
f"PowerBI authentication failed with status {status_code}. "
|
|
57
|
+
"Please verify your POWERBI_TENANT_ID, POWERBI_CLIENT_ID, and "
|
|
58
|
+
f"POWERBI_CLIENT_SECRET are correct. Error details: {error_detail}"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
return AuthenticationError(
|
|
62
|
+
"Authentication failed (401). Your access token may have expired or "
|
|
63
|
+
"your service principal may not have the required permissions. "
|
|
64
|
+
"Ensure the service principal is enabled in PowerBI admin settings."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
elif status_code == 403:
|
|
68
|
+
logger.error("Access forbidden (403)")
|
|
69
|
+
return APIError(
|
|
70
|
+
"Access forbidden (403). Your service principal does not have "
|
|
71
|
+
"permission to access this resource. Check PowerBI admin portal "
|
|
72
|
+
"settings and ensure proper workspace access is granted."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
elif status_code == 404:
|
|
76
|
+
endpoint = error.request.url.path if hasattr(error.request, 'url') else "unknown"
|
|
77
|
+
logger.error(f"Resource not found (404) at endpoint: {endpoint}")
|
|
78
|
+
return APIError(
|
|
79
|
+
f"Resource not found (404) at endpoint: {endpoint}. "
|
|
80
|
+
"Please verify the workspace ID, dataset ID, or other identifiers are correct."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
elif status_code == 429:
|
|
84
|
+
retry_after = error.response.headers.get("Retry-After", "60")
|
|
85
|
+
logger.warning(f"Rate limit exceeded (429). Retry after {retry_after}s")
|
|
86
|
+
return APIError(
|
|
87
|
+
f"Rate limit exceeded (429). Too many requests. "
|
|
88
|
+
f"Please wait {retry_after} seconds before retrying."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
else:
|
|
92
|
+
logger.error(f"{context} failed with status {status_code}")
|
|
93
|
+
return APIError(
|
|
94
|
+
f"PowerBI {context} failed with status {status_code}. "
|
|
95
|
+
f"Error details: {error_detail}"
|
|
96
|
+
)
|