stacklet-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.
- stacklet/mcp/__init__.py +8 -0
- stacklet/mcp/__main__.py +11 -0
- stacklet/mcp/assetdb/__init__.py +4 -0
- stacklet/mcp/assetdb/models.py +268 -0
- stacklet/mcp/assetdb/redash.py +268 -0
- stacklet/mcp/assetdb/sql_info.md +147 -0
- stacklet/mcp/assetdb/tools.py +404 -0
- stacklet/mcp/cmdline.py +67 -0
- stacklet/mcp/docs/__init__.py +4 -0
- stacklet/mcp/docs/client.py +80 -0
- stacklet/mcp/docs/models.py +33 -0
- stacklet/mcp/docs/tools.py +71 -0
- stacklet/mcp/lifespan.py +52 -0
- stacklet/mcp/mcp.py +23 -0
- stacklet/mcp/mcp_info.md +40 -0
- stacklet/mcp/platform/__init__.py +4 -0
- stacklet/mcp/platform/dataset_info.md +115 -0
- stacklet/mcp/platform/graphql.py +250 -0
- stacklet/mcp/platform/graphql_info.md +71 -0
- stacklet/mcp/platform/models.py +152 -0
- stacklet/mcp/platform/tools.py +240 -0
- stacklet/mcp/server.py +35 -0
- stacklet/mcp/settings.py +42 -0
- stacklet/mcp/stacklet_auth.py +105 -0
- stacklet/mcp/utils/__init__.py +4 -0
- stacklet/mcp/utils/file.py +31 -0
- stacklet/mcp/utils/json.py +67 -0
- stacklet/mcp/utils/mcp_json.py +82 -0
- stacklet/mcp/utils/text.py +14 -0
- stacklet/mcp/utils/tool.py +27 -0
- stacklet_mcp-0.1.0.dist-info/METADATA +13 -0
- stacklet_mcp-0.1.0.dist-info/RECORD +34 -0
- stacklet_mcp-0.1.0.dist-info/WHEEL +4 -0
- stacklet_mcp-0.1.0.dist-info/entry_points.txt +2 -0
stacklet/mcp/__init__.py
ADDED
stacklet/mcp/__main__.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# LICENSE HEADER MANAGED BY add-license-header
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2025 Stacklet, Inc.
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import IntEnum, StrEnum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExportFormat(StrEnum):
|
|
16
|
+
"""Format for query result export."""
|
|
17
|
+
|
|
18
|
+
CSV = "csv"
|
|
19
|
+
JSON = "json"
|
|
20
|
+
TSV = "tsv"
|
|
21
|
+
XLSX = "xlsx"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JobStatus(IntEnum):
|
|
25
|
+
"""Status values for AssetDB query execution jobs."""
|
|
26
|
+
|
|
27
|
+
QUEUED = 1
|
|
28
|
+
STARTED = 2
|
|
29
|
+
FINISHED = 3
|
|
30
|
+
FAILED = 4
|
|
31
|
+
CANCELED = 5
|
|
32
|
+
DEFERRED = 6
|
|
33
|
+
SCHEDULED = 7
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_terminal(self) -> bool:
|
|
37
|
+
"""Whether this job status represents a completed state (finished, failed, or canceled)."""
|
|
38
|
+
return self in (JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELED)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Job(BaseModel):
|
|
42
|
+
"""Redash job object for async query execution."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
status: JobStatus
|
|
46
|
+
error: str | None
|
|
47
|
+
query_result_id: int | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QueryArchiveResult(BaseModel):
|
|
51
|
+
"""Result of archiving/deleting a query."""
|
|
52
|
+
|
|
53
|
+
success: bool
|
|
54
|
+
message: str
|
|
55
|
+
query_id: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class User(BaseModel):
|
|
59
|
+
"""Redash user object model."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="ignore")
|
|
62
|
+
|
|
63
|
+
id: int = Field(..., description="Unique user ID in the Redash system")
|
|
64
|
+
name: str | None = Field(None, description="User's display name")
|
|
65
|
+
email: str | None = Field(None, description="User's email address")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Query(BaseModel):
|
|
69
|
+
"""Redash query object model based on serialize_query output."""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(extra="ignore")
|
|
72
|
+
|
|
73
|
+
id: int = Field(..., description="Unique query ID in the Redash system")
|
|
74
|
+
latest_query_data_id: int | None = Field(
|
|
75
|
+
None, description="ID of the most recent query result data"
|
|
76
|
+
)
|
|
77
|
+
name: str = Field(..., description="Query display name")
|
|
78
|
+
description: str | None = Field(None, description="Query description or documentation")
|
|
79
|
+
query: str = Field(..., description="SQL query text")
|
|
80
|
+
api_key: str = Field(..., description="API key for accessing this query")
|
|
81
|
+
is_draft: bool = Field(..., description="Whether the query is in draft status")
|
|
82
|
+
updated_at: datetime = Field(..., description="Timestamp of last modification")
|
|
83
|
+
created_at: datetime = Field(..., description="Timestamp when query was created")
|
|
84
|
+
data_source_id: int = Field(..., description="ID of the data source this query runs against")
|
|
85
|
+
options: dict[str, Any] = Field(
|
|
86
|
+
..., description="Query configuration options including parameters"
|
|
87
|
+
)
|
|
88
|
+
tags: list[str] = Field(..., description="List of tags for categorizing the query")
|
|
89
|
+
is_safe: bool = Field(..., description="Whether the query is considered safe to run")
|
|
90
|
+
user: User = Field(..., description="User who created the query")
|
|
91
|
+
last_modified_by: User | None = Field(None, description="User who last modified the query")
|
|
92
|
+
retrieved_at: datetime | None = Field(
|
|
93
|
+
None, description="Timestamp when query data was last retrieved"
|
|
94
|
+
)
|
|
95
|
+
runtime: float | None = Field(None, description="Last execution runtime in seconds")
|
|
96
|
+
is_favorite: bool = Field(..., description="Whether the query is marked as favorite")
|
|
97
|
+
|
|
98
|
+
@model_validator(mode="before")
|
|
99
|
+
@classmethod
|
|
100
|
+
def transform_user_fields(cls, data: Any) -> Any:
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
# Deep copy to avoid any mutation issues
|
|
105
|
+
data = copy.deepcopy(data)
|
|
106
|
+
|
|
107
|
+
# Handle user field - convert user_id to User object if needed
|
|
108
|
+
if "user_id" in data and "user" not in data:
|
|
109
|
+
data["user"] = {"id": data["user_id"]}
|
|
110
|
+
|
|
111
|
+
# Handle last_modified_by - convert last_modified_by_id to User object if needed
|
|
112
|
+
if "last_modified_by_id" in data and "last_modified_by" not in data:
|
|
113
|
+
if data["last_modified_by_id"] is not None:
|
|
114
|
+
data["last_modified_by"] = {"id": data["last_modified_by_id"]}
|
|
115
|
+
else:
|
|
116
|
+
data["last_modified_by"] = None
|
|
117
|
+
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class QueryListResponse(BaseModel):
|
|
122
|
+
"""Raw response model for query list endpoint (internal use)."""
|
|
123
|
+
|
|
124
|
+
model_config = ConfigDict(extra="ignore")
|
|
125
|
+
|
|
126
|
+
count: int = Field(..., description="Total number of queries matching the search criteria")
|
|
127
|
+
page: int = Field(..., description="Current page number (1-based)")
|
|
128
|
+
page_size: int = Field(..., description="Number of queries per page")
|
|
129
|
+
results: list[Query] = Field(..., description="List of queries on the current page")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class QueryUpsert(BaseModel):
|
|
133
|
+
"""Query data for create/update operations."""
|
|
134
|
+
|
|
135
|
+
name: str | None = Field(None, description="Query display name (required for new queries)")
|
|
136
|
+
query: str | None = Field(None, description="SQL query text (required for new queries)")
|
|
137
|
+
description: str | None = Field(None, description="Query description or documentation")
|
|
138
|
+
tags: list[str] | None = Field(None, description="List of tags for categorizing the query")
|
|
139
|
+
options: dict[str, Any] | None = Field(
|
|
140
|
+
None, description="Query configuration options including parameters"
|
|
141
|
+
)
|
|
142
|
+
is_draft: bool | None = Field(None, description="Whether the query should be in draft status")
|
|
143
|
+
|
|
144
|
+
def payload(self, data_source_id: int | None = None) -> dict[str, Any]:
|
|
145
|
+
"""
|
|
146
|
+
Build API payload for query create/update.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
data_source_id: Required data source ID for the query
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Payload dictionary with non-None values
|
|
153
|
+
"""
|
|
154
|
+
payload = self.model_dump(exclude_none=True)
|
|
155
|
+
if data_source_id:
|
|
156
|
+
payload["data_source_id"] = data_source_id
|
|
157
|
+
|
|
158
|
+
return payload
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ToolQueryListPagination(BaseModel):
|
|
162
|
+
"""Pagination metadata for query list responses."""
|
|
163
|
+
|
|
164
|
+
page: int = Field(..., description="Current page number (1-based)")
|
|
165
|
+
page_size: int = Field(..., description="Number of queries per page")
|
|
166
|
+
has_next_page: bool = Field(..., description="Whether there are more pages available")
|
|
167
|
+
total_count: int = Field(
|
|
168
|
+
..., description="Total number of queries matching the search criteria"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ToolQueryListItem(BaseModel):
|
|
173
|
+
"""Simplified query information for list responses."""
|
|
174
|
+
|
|
175
|
+
id: int = Field(..., description="Unique query ID")
|
|
176
|
+
name: str = Field(..., description="Query display name")
|
|
177
|
+
description: str | None = Field(..., description="Query description or documentation")
|
|
178
|
+
has_parameters: bool = Field(..., description="Whether the query accepts parameters")
|
|
179
|
+
data_source_id: int = Field(..., description="ID of the data source this query runs against")
|
|
180
|
+
is_draft: bool = Field(..., description="Whether the query is in draft status")
|
|
181
|
+
is_favorite: bool = Field(..., description="Whether the query is marked as favorite")
|
|
182
|
+
tags: list[str] = Field(..., description="List of tags for categorizing the query")
|
|
183
|
+
user: User = Field(..., description="User who created the query")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ToolQueryList(BaseModel):
|
|
187
|
+
"""Complete response for query list operations."""
|
|
188
|
+
|
|
189
|
+
queries: list[ToolQueryListItem] = Field(..., description="List of queries on the current page")
|
|
190
|
+
pagination: ToolQueryListPagination = Field(..., description="Pagination information")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class QueryResultColumn(BaseModel):
|
|
194
|
+
"""Column definition in a query result."""
|
|
195
|
+
|
|
196
|
+
name: str = Field(..., description="Column name")
|
|
197
|
+
type: str | None = Field(None, description="Column data type")
|
|
198
|
+
friendly_name: str | None = Field(None, description="Human-friendly column name")
|
|
199
|
+
|
|
200
|
+
model_config = ConfigDict(extra="ignore")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class QueryResultData(BaseModel):
|
|
204
|
+
"""The data structure within a query result containing columns and rows."""
|
|
205
|
+
|
|
206
|
+
columns: list[QueryResultColumn] = Field(
|
|
207
|
+
..., description="Column definitions for the query result"
|
|
208
|
+
)
|
|
209
|
+
rows: list[dict[str, Any]] = Field(
|
|
210
|
+
..., description="Query result rows as key-value dictionaries"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
model_config = ConfigDict(extra="ignore")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class QueryResult(BaseModel):
|
|
217
|
+
"""Query result object as returned by Redash QueryResult.to_dict()."""
|
|
218
|
+
|
|
219
|
+
id: int = Field(..., description="Query result ID")
|
|
220
|
+
query: str = Field(..., description="The SQL query text that was executed")
|
|
221
|
+
data: QueryResultData = Field(..., description="Query result data with columns and rows")
|
|
222
|
+
data_source_id: int = Field(..., description="ID of the data source used")
|
|
223
|
+
runtime: float = Field(..., description="Query execution time in seconds")
|
|
224
|
+
retrieved_at: datetime = Field(..., description="When the query result was retrieved")
|
|
225
|
+
|
|
226
|
+
model_config = ConfigDict(extra="ignore")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ToolQueryResultArtifact(BaseModel):
|
|
230
|
+
"""Query download details for a data format."""
|
|
231
|
+
|
|
232
|
+
format: ExportFormat = Field(..., description="Export format for the query result download")
|
|
233
|
+
download_from: str = Field(..., description="URL to download the data in the specified format")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ToolQueryResult(BaseModel):
|
|
237
|
+
"""
|
|
238
|
+
Truncated query results suitable for LLMs, along with ways to get the full
|
|
239
|
+
result set for analysis with tools suited to that task.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
result_id: int = Field(..., description="Query result id")
|
|
243
|
+
query_id: int | None = Field(None, description="Query id, if applicable")
|
|
244
|
+
|
|
245
|
+
# These fields come directly from the redash QueryResult.
|
|
246
|
+
query_text: str = Field(..., description="The SQL query text that was executed")
|
|
247
|
+
query_runtime: float = Field(..., description="Query execution duration in seconds")
|
|
248
|
+
query_timestamp: datetime = Field(..., description="Query execution finish timestamp")
|
|
249
|
+
columns: list[QueryResultColumn] = Field(
|
|
250
|
+
..., description="Column definitions; sparse row dicts are keyed on column name"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# These fields are derived from the QueryResult rows.
|
|
254
|
+
row_count: int = Field(..., description="Total rows in the full query result")
|
|
255
|
+
some_rows: list[dict[str, Any]] = Field(
|
|
256
|
+
..., description="Sample of up to 20 rows from the query result for preview"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Complete result data is always saved locally for further analysis. (We don't *have*
|
|
260
|
+
# to do this on every path, but we do on *some*, so we choose consistency.)
|
|
261
|
+
full_results_saved_to: str = Field(
|
|
262
|
+
..., description="Local path where complete result data was saved as JSON"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Available only for saved queries (not ad-hoc queries) that have API keys.
|
|
266
|
+
alternate_formats: list[ToolQueryResultArtifact] | None = Field(
|
|
267
|
+
None, description="Download URLs for different formats, None for ad-hoc queries"
|
|
268
|
+
)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# LICENSE HEADER MANAGED BY add-license-header
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2025 Stacklet, Inc.
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
AssetDB client using Redash API with Stacklet authentication.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from typing import Any, Self, cast
|
|
14
|
+
from urllib.parse import urljoin
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from fastmcp import Context
|
|
19
|
+
|
|
20
|
+
from ..lifespan import server_cached
|
|
21
|
+
from ..settings import SETTINGS
|
|
22
|
+
from ..stacklet_auth import StackletCredentials
|
|
23
|
+
from .models import ExportFormat, Job, Query, QueryListResponse, QueryResult, QueryUpsert
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AssetDBClient:
|
|
27
|
+
"""Client for AssetDB interface via Redash API using Stacklet authentication."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, credentials: StackletCredentials, data_source_id: int = 1) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Initialize AssetDB client with Stacklet credentials.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
credentials: StackletCredentials object containing endpoint and id_token
|
|
35
|
+
data_source_id: ID of the Redash data source (default 1 for main AssetDB)
|
|
36
|
+
"""
|
|
37
|
+
self.credentials = credentials
|
|
38
|
+
self.data_source_id = data_source_id
|
|
39
|
+
|
|
40
|
+
self.redash_url = self.credentials.service_endpoint("redash")
|
|
41
|
+
self.session = httpx.AsyncClient(
|
|
42
|
+
cookies={"stacklet-auth": credentials.identity_token}, timeout=60.0
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def get(cls, ctx: Context) -> Self:
|
|
47
|
+
def construct() -> AssetDBClient:
|
|
48
|
+
return cls(StackletCredentials.get(ctx), SETTINGS.assetdb_datasource)
|
|
49
|
+
|
|
50
|
+
return cast(Self, server_cached(ctx, "ASSETDB_CLIENT", construct))
|
|
51
|
+
|
|
52
|
+
async def _make_request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
|
|
53
|
+
"""
|
|
54
|
+
Make a request to the Redash API with Stacklet authentication.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
method: HTTP method (GET, POST, etc.)
|
|
58
|
+
endpoint: API endpoint path
|
|
59
|
+
**kwargs: Additional arguments for httpx
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Decoded response JSON
|
|
63
|
+
"""
|
|
64
|
+
url = urljoin(self.redash_url, endpoint)
|
|
65
|
+
response = await self.session.request(method, url, **kwargs)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
return response.json()
|
|
68
|
+
|
|
69
|
+
async def list_queries(
|
|
70
|
+
self,
|
|
71
|
+
page: int = 1,
|
|
72
|
+
page_size: int = 25,
|
|
73
|
+
search: str | None = None,
|
|
74
|
+
tags: list[str] | None = None,
|
|
75
|
+
) -> QueryListResponse:
|
|
76
|
+
"""
|
|
77
|
+
Get list of queries with search and sorting support.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
page: Page number (1-based)
|
|
81
|
+
page_size: Number of queries per page
|
|
82
|
+
search: Search query names, descriptions, and SQL content
|
|
83
|
+
tags: Filter out queries not matching all tags
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Structured response with queries and pagination metadata
|
|
87
|
+
"""
|
|
88
|
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
|
89
|
+
|
|
90
|
+
if search:
|
|
91
|
+
params["q"] = search
|
|
92
|
+
if tags:
|
|
93
|
+
params["tags"] = tags
|
|
94
|
+
|
|
95
|
+
result = await self._make_request("GET", "api/queries", params=params)
|
|
96
|
+
return QueryListResponse(**result)
|
|
97
|
+
|
|
98
|
+
async def get_query(self, query_id: int) -> Query:
|
|
99
|
+
"""
|
|
100
|
+
Get detailed information about a specific saved query.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
query_id: ID of the query to retrieve
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Complete query object with SQL and parameters
|
|
107
|
+
"""
|
|
108
|
+
result = await self._make_request("GET", f"api/queries/{query_id}")
|
|
109
|
+
return Query(**result)
|
|
110
|
+
|
|
111
|
+
async def execute_saved_query(
|
|
112
|
+
self,
|
|
113
|
+
query_id: int,
|
|
114
|
+
parameters: dict[str, Any] | None,
|
|
115
|
+
max_age: int,
|
|
116
|
+
timeout: int,
|
|
117
|
+
) -> QueryResult:
|
|
118
|
+
"""
|
|
119
|
+
Execute a saved query by ID, with caching control.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
query_id: ID of the query
|
|
123
|
+
parameters: Optional parameters for the query
|
|
124
|
+
max_age: Maximum age of cached results in seconds (-1=any cached result, 0=always fresh)
|
|
125
|
+
timeout: Timeout in seconds for query execution (if not cached)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Complete query result with data, columns, and metadata
|
|
129
|
+
"""
|
|
130
|
+
payload = {"max_age": max_age, "parameters": parameters or {}}
|
|
131
|
+
return await self._execute_results(f"api/queries/{query_id}/results", payload, timeout)
|
|
132
|
+
|
|
133
|
+
async def execute_adhoc_query(self, query: str, max_age: int, timeout: int) -> QueryResult:
|
|
134
|
+
"""
|
|
135
|
+
Execute an ad-hoc SQL query without saving it.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
query: SQL query string to execute
|
|
139
|
+
timeout: Timeout in seconds for query execution
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Complete query result with data, columns, and metadata
|
|
143
|
+
"""
|
|
144
|
+
payload = {
|
|
145
|
+
"query": query,
|
|
146
|
+
"data_source_id": self.data_source_id,
|
|
147
|
+
"max_age": max_age,
|
|
148
|
+
"parameters": {},
|
|
149
|
+
"apply_auto_limit": True,
|
|
150
|
+
}
|
|
151
|
+
return await self._execute_results("api/query_results", payload, timeout)
|
|
152
|
+
|
|
153
|
+
async def _execute_results(
|
|
154
|
+
self, endpoint: str, payload: dict[str, Any], timeout: int
|
|
155
|
+
) -> QueryResult:
|
|
156
|
+
"""
|
|
157
|
+
Execute query request and handle both sync and async results.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
endpoint: API endpoint to POST the query to
|
|
161
|
+
payload: Query parameters and options
|
|
162
|
+
timeout: Maximum time to wait for async job completion
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Complete query result with data, columns, and metadata
|
|
166
|
+
"""
|
|
167
|
+
# This will contain either a "job" or a full "query_result". Since we're
|
|
168
|
+
# sometimes stuck grabbing a whole result set any way, we may as well do
|
|
169
|
+
# it every time; this also lets us always return a preview of the result
|
|
170
|
+
# data even when it's large.
|
|
171
|
+
response = await self._make_request("POST", endpoint, json=payload)
|
|
172
|
+
if "query_result" in response:
|
|
173
|
+
return QueryResult(**response["query_result"])
|
|
174
|
+
|
|
175
|
+
job = Job(**response["job"])
|
|
176
|
+
result_id = await self._poll_job(job, timeout)
|
|
177
|
+
qr_response = await self._make_request("GET", f"api/query_results/{result_id}")
|
|
178
|
+
return QueryResult(**qr_response["query_result"])
|
|
179
|
+
|
|
180
|
+
async def _poll_job(self, job: Job, timeout: int) -> int:
|
|
181
|
+
"""
|
|
182
|
+
Poll an async job until completion using exponential backoff.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
job: Initial job object from query execution
|
|
186
|
+
timeout: Maximum time to wait before timing out
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Query result ID when job completes successfully
|
|
190
|
+
"""
|
|
191
|
+
cutoff = time.monotonic() + timeout
|
|
192
|
+
interval_s = 2
|
|
193
|
+
while True:
|
|
194
|
+
job_result = await self._make_request("GET", f"api/jobs/{job.id}")
|
|
195
|
+
job = Job(**job_result["job"])
|
|
196
|
+
if job.query_result_id:
|
|
197
|
+
return job.query_result_id
|
|
198
|
+
elif job.status.is_terminal:
|
|
199
|
+
raise RuntimeError(f"Query execution failed: {job.error or 'Unknown error.'}")
|
|
200
|
+
|
|
201
|
+
remaining_s = cutoff - time.monotonic()
|
|
202
|
+
if remaining_s <= 0:
|
|
203
|
+
raise RuntimeError(f"Query execution timed out after {timeout} seconds")
|
|
204
|
+
await asyncio.sleep(min(interval_s, remaining_s))
|
|
205
|
+
interval_s *= 2
|
|
206
|
+
|
|
207
|
+
def get_query_result_urls(
|
|
208
|
+
self, query: Query, query_result: QueryResult
|
|
209
|
+
) -> dict[ExportFormat, str]:
|
|
210
|
+
"""
|
|
211
|
+
Return download URLs for a query result.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
query_id: ID of the query the result refers to
|
|
215
|
+
result_id: ID of the query result to get downloads urls for
|
|
216
|
+
api_key: the API key for the query.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Dictionary mapping download formats to their URLs
|
|
220
|
+
"""
|
|
221
|
+
return {
|
|
222
|
+
fmt: urljoin(
|
|
223
|
+
self.redash_url,
|
|
224
|
+
f"api/queries/{query.id}/results/{query_result.id}.{fmt}?api_key={query.api_key}",
|
|
225
|
+
)
|
|
226
|
+
for fmt in ExportFormat
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async def create_query(self, upsert: QueryUpsert) -> Query:
|
|
230
|
+
"""
|
|
231
|
+
Create a new saved query.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
upsert: QueryUpsert object with query data
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Complete query object with ID, timestamps, and metadata
|
|
238
|
+
"""
|
|
239
|
+
payload = upsert.payload(data_source_id=self.data_source_id)
|
|
240
|
+
result = await self._make_request("POST", "api/queries", json=payload)
|
|
241
|
+
return Query(**result)
|
|
242
|
+
|
|
243
|
+
async def update_query(self, query_id: int, upsert: QueryUpsert) -> Query:
|
|
244
|
+
"""
|
|
245
|
+
Update an existing saved query.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
query_id: ID of the query to update
|
|
249
|
+
upsert: QueryUpsert object with query data to update
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Complete updated query object with ID, timestamps, and metadata
|
|
253
|
+
"""
|
|
254
|
+
payload = upsert.payload()
|
|
255
|
+
result = await self._make_request("POST", f"api/queries/{query_id}", json=payload)
|
|
256
|
+
return Query(**result)
|
|
257
|
+
|
|
258
|
+
async def delete_query(self, query_id: int) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Archive a saved query.
|
|
261
|
+
|
|
262
|
+
This sets the query's is_archived flag to True and removes associated
|
|
263
|
+
visualizations and alerts, but preserves the query in the database.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
query_id: ID of the query to archive
|
|
267
|
+
"""
|
|
268
|
+
await self._make_request("DELETE", f"api/queries/{query_id}")
|