semantic
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# [DEF:backend.src.core.utils.async_network:Module]
|
||||
# [DEF:AsyncNetworkModule:Module]
|
||||
#
|
||||
# @COMPLEXITY: 5
|
||||
# @SEMANTICS: network, httpx, async, superset, authentication, cache
|
||||
@@ -8,7 +8,7 @@
|
||||
# @POST: Async network clients reuse cached auth tokens and expose stable async request/error translation flow.
|
||||
# @SIDE_EFFECT: Performs upstream HTTP I/O and mutates process-local auth cache entries.
|
||||
# @DATA_CONTRACT: Input[config: Dict[str, Any]] -> Output[authenticated async Superset HTTP interactions]
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.utils.network.SupersetAuthCache
|
||||
# @RELATION: [DEPENDS_ON] ->[SupersetAuthCache]
|
||||
# @INVARIANT: Async client reuses cached auth tokens per environment credentials and invalidates on 401.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
@@ -29,22 +29,24 @@ from .network import (
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:backend.src.core.utils.async_network.AsyncAPIClient:Class]
|
||||
# [DEF:AsyncAPIClient:Class]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Async Superset API client backed by httpx.AsyncClient with shared auth cache.
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.utils.network.SupersetAuthCache]
|
||||
# @RELATION: [CALLS] ->[backend.src.core.utils.network.SupersetAuthCache.get]
|
||||
# @RELATION: [CALLS] ->[backend.src.core.utils.network.SupersetAuthCache.set]
|
||||
# @RELATION: [DEPENDS_ON] ->[SupersetAuthCache]
|
||||
# @RELATION: [CALLS] ->[SupersetAuthCache.get]
|
||||
# @RELATION: [CALLS] ->[SupersetAuthCache.set]
|
||||
class AsyncAPIClient:
|
||||
DEFAULT_TIMEOUT = 30
|
||||
_auth_locks: Dict[tuple[str, str, bool], asyncio.Lock] = {}
|
||||
|
||||
# [DEF:backend.src.core.utils.async_network.AsyncAPIClient.__init__:Function]
|
||||
# [DEF:AsyncAPIClient.__init__:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Initialize async API client for one environment.
|
||||
# @PRE: config contains base_url and auth payload.
|
||||
# @POST: Client is ready for async request/authentication flow.
|
||||
# @DATA_CONTRACT: Input[config: Dict[str, Any]] -> self._auth_cache_key[str]
|
||||
# @RELATION: [CALLS] ->[AsyncAPIClient._normalize_base_url]
|
||||
# @RELATION: [DEPENDS_ON] ->[SupersetAuthCache]
|
||||
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT):
|
||||
self.base_url: str = self._normalize_base_url(config.get("base_url", ""))
|
||||
self.api_base_url: str = f"{self.base_url}/api/v1"
|
||||
@@ -63,9 +65,9 @@ class AsyncAPIClient:
|
||||
verify_ssl,
|
||||
)
|
||||
|
||||
# [/DEF:__init__:Function]
|
||||
# [/DEF:AsyncAPIClient.__init__:Function]
|
||||
|
||||
# [DEF:backend.src.core.utils.async_network.AsyncAPIClient._normalize_base_url:Function]
|
||||
# [DEF:AsyncAPIClient._normalize_base_url:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Normalize base URL for Superset API root construction.
|
||||
# @POST: Returns canonical base URL without trailing slash and duplicate /api/v1 suffix.
|
||||
@@ -74,9 +76,9 @@ class AsyncAPIClient:
|
||||
if normalized.lower().endswith("/api/v1"):
|
||||
normalized = normalized[:-len("/api/v1")]
|
||||
return normalized.rstrip("/")
|
||||
# [/DEF:_normalize_base_url:Function]
|
||||
# [/DEF:AsyncAPIClient._normalize_base_url:Function]
|
||||
|
||||
# [DEF:_build_api_url:Function]
|
||||
# [DEF:AsyncAPIClient._build_api_url:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Build full API URL from relative Superset endpoint.
|
||||
# @POST: Returns absolute URL for upstream request.
|
||||
@@ -89,9 +91,9 @@ class AsyncAPIClient:
|
||||
if normalized_endpoint.startswith("/api/v1/") or normalized_endpoint == "/api/v1":
|
||||
return f"{self.base_url}{normalized_endpoint}"
|
||||
return f"{self.api_base_url}{normalized_endpoint}"
|
||||
# [/DEF:_build_api_url:Function]
|
||||
# [/DEF:AsyncAPIClient._build_api_url:Function]
|
||||
|
||||
# [DEF:_get_auth_lock:Function]
|
||||
# [DEF:AsyncAPIClient._get_auth_lock:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Return per-cache-key async lock to serialize fresh login attempts.
|
||||
# @POST: Returns stable asyncio.Lock instance.
|
||||
@@ -103,14 +105,16 @@ class AsyncAPIClient:
|
||||
created_lock = asyncio.Lock()
|
||||
cls._auth_locks[cache_key] = created_lock
|
||||
return created_lock
|
||||
# [/DEF:_get_auth_lock:Function]
|
||||
# [/DEF:AsyncAPIClient._get_auth_lock:Function]
|
||||
|
||||
# [DEF:authenticate:Function]
|
||||
# [DEF:AsyncAPIClient.authenticate:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Authenticate against Superset and cache access/csrf tokens.
|
||||
# @POST: Client tokens are populated and reusable across requests.
|
||||
# @SIDE_EFFECT: Performs network requests to Superset authentication endpoints.
|
||||
# @DATA_CONTRACT: None -> Output[Dict[str, str]]
|
||||
# @RELATION: [CALLS] ->[SupersetAuthCache.get]
|
||||
# @RELATION: [CALLS] ->[SupersetAuthCache.set]
|
||||
async def authenticate(self) -> Dict[str, str]:
|
||||
cached_tokens = SupersetAuthCache.get(self._auth_cache_key)
|
||||
if cached_tokens and cached_tokens.get("access_token") and cached_tokens.get("csrf_token"):
|
||||
@@ -163,13 +167,13 @@ class AsyncAPIClient:
|
||||
except (httpx.HTTPError, KeyError) as exc:
|
||||
SupersetAuthCache.invalidate(self._auth_cache_key)
|
||||
raise NetworkError(f"Network or parsing error during authentication: {exc}") from exc
|
||||
# [/DEF:authenticate:Function]
|
||||
# [/DEF:AsyncAPIClient.authenticate:Function]
|
||||
|
||||
# [DEF:get_headers:Function]
|
||||
# [DEF:AsyncAPIClient.get_headers:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Return authenticated Superset headers for async requests.
|
||||
# @POST: Headers include Authorization and CSRF tokens.
|
||||
# @RELATION: CALLS -> self.authenticate
|
||||
# @RELATION: [CALLS] ->[AsyncAPIClient.authenticate]
|
||||
async def get_headers(self) -> Dict[str, str]:
|
||||
if not self._authenticated:
|
||||
await self.authenticate()
|
||||
@@ -179,16 +183,16 @@ class AsyncAPIClient:
|
||||
"Referer": self.base_url,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
# [/DEF:get_headers:Function]
|
||||
# [/DEF:AsyncAPIClient.get_headers:Function]
|
||||
|
||||
# [DEF:request:Function]
|
||||
# [DEF:AsyncAPIClient.request:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Perform one authenticated async Superset API request.
|
||||
# @POST: Returns JSON payload or raw httpx.Response when raw_response=true.
|
||||
# @SIDE_EFFECT: Performs network I/O.
|
||||
# @RELATION: [CALLS] ->[self.get_headers]
|
||||
# @RELATION: [CALLS] ->[self._handle_http_error]
|
||||
# @RELATION: [CALLS] ->[self._handle_network_error]
|
||||
# @RELATION: [CALLS] ->[AsyncAPIClient.get_headers]
|
||||
# @RELATION: [CALLS] ->[AsyncAPIClient._handle_http_error]
|
||||
# @RELATION: [CALLS] ->[AsyncAPIClient._handle_network_error]
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -216,9 +220,9 @@ class AsyncAPIClient:
|
||||
self._handle_http_error(exc, endpoint)
|
||||
except httpx.HTTPError as exc:
|
||||
self._handle_network_error(exc, full_url)
|
||||
# [/DEF:request:Function]
|
||||
# [/DEF:AsyncAPIClient.request:Function]
|
||||
|
||||
# [DEF:_handle_http_error:Function]
|
||||
# [DEF:AsyncAPIClient._handle_http_error:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Translate upstream HTTP errors into stable domain exceptions.
|
||||
# @POST: Raises domain-specific exception for caller flow control.
|
||||
@@ -229,15 +233,40 @@ class AsyncAPIClient:
|
||||
if status_code in [502, 503, 504]:
|
||||
raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from exc
|
||||
if status_code == 404:
|
||||
raise DashboardNotFoundError(endpoint) from exc
|
||||
if self._is_dashboard_endpoint(endpoint):
|
||||
raise DashboardNotFoundError(endpoint) from exc
|
||||
raise SupersetAPIError(
|
||||
f"API resource not found at endpoint '{endpoint}'",
|
||||
status_code=status_code,
|
||||
endpoint=endpoint,
|
||||
subtype="not_found",
|
||||
) from exc
|
||||
if status_code == 403:
|
||||
raise PermissionDeniedError() from exc
|
||||
if status_code == 401:
|
||||
raise AuthenticationError() from exc
|
||||
raise SupersetAPIError(f"API Error {status_code}: {exc.response.text}") from exc
|
||||
# [/DEF:_handle_http_error:Function]
|
||||
# [/DEF:AsyncAPIClient._handle_http_error:Function]
|
||||
|
||||
# [DEF:_handle_network_error:Function]
|
||||
# [DEF:AsyncAPIClient._is_dashboard_endpoint:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Determine whether an API endpoint represents a dashboard resource for 404 translation.
|
||||
# @POST: Returns true only for dashboard-specific endpoints.
|
||||
def _is_dashboard_endpoint(self, endpoint: str) -> bool:
|
||||
normalized_endpoint = str(endpoint or "").strip().lower()
|
||||
if not normalized_endpoint:
|
||||
return False
|
||||
if normalized_endpoint.startswith("http://") or normalized_endpoint.startswith("https://"):
|
||||
try:
|
||||
normalized_endpoint = "/" + normalized_endpoint.split("/api/v1", 1)[1].lstrip("/")
|
||||
except IndexError:
|
||||
return False
|
||||
if normalized_endpoint.startswith("/api/v1/"):
|
||||
normalized_endpoint = normalized_endpoint[len("/api/v1"):]
|
||||
return normalized_endpoint.startswith("/dashboard/") or normalized_endpoint == "/dashboard"
|
||||
# [/DEF:backend.src.core.utils.async_network.AsyncAPIClient._is_dashboard_endpoint:Function]
|
||||
|
||||
# [DEF:backend.src.core.utils.async_network.AsyncAPIClient._handle_network_error:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Translate generic httpx errors into NetworkError.
|
||||
# @POST: Raises NetworkError with URL context.
|
||||
@@ -251,16 +280,16 @@ class AsyncAPIClient:
|
||||
else:
|
||||
message = f"Unknown network error: {exc}"
|
||||
raise NetworkError(message, url=url) from exc
|
||||
# [/DEF:_handle_network_error:Function]
|
||||
# [/DEF:backend.src.core.utils.async_network.AsyncAPIClient._handle_network_error:Function]
|
||||
|
||||
# [DEF:aclose:Function]
|
||||
# [DEF:backend.src.core.utils.async_network.AsyncAPIClient.aclose:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Close underlying httpx client.
|
||||
# @POST: Client resources are released.
|
||||
# @SIDE_EFFECT: Closes network connections.
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
# [/DEF:aclose:Function]
|
||||
# [/DEF:AsyncAPIClient:Class]
|
||||
# [/DEF:backend.src.core.utils.async_network.AsyncAPIClient.aclose:Function]
|
||||
# [/DEF:backend.src.core.utils.async_network.AsyncAPIClient:Class]
|
||||
|
||||
# [/DEF:backend.src.core.utils.async_network:Module]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# [DEF:network:Module]
|
||||
# [DEF:NetworkModule:Module]
|
||||
#
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: network, http, client, api, requests, session, authentication
|
||||
# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
|
||||
# @LAYER: Infra
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.logger
|
||||
# @RELATION: DEPENDS_ON -> requests
|
||||
# @RELATION: [DEPENDS_ON] ->[LoggerModule]
|
||||
# @PUBLIC_API: APIClient
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
@@ -82,7 +81,7 @@ class DashboardNotFoundError(SupersetAPIError):
|
||||
# [DEF:NetworkError:Class]
|
||||
# @PURPOSE: Exception raised when a network level error occurs.
|
||||
class NetworkError(Exception):
|
||||
# [DEF:network.APIClient.__init__:Function]
|
||||
# [DEF:NetworkError.__init__:Function]
|
||||
# @PURPOSE: Initializes the network error.
|
||||
# @PRE: message is a string.
|
||||
# @POST: NetworkError is initialized.
|
||||
@@ -90,11 +89,11 @@ class NetworkError(Exception):
|
||||
with belief_scope("NetworkError.__init__"):
|
||||
self.context = context
|
||||
super().__init__(f"[NETWORK_FAILURE] {message} | Context: {self.context}")
|
||||
# [/DEF:__init__:Function]
|
||||
# [/DEF:NetworkError.__init__:Function]
|
||||
# [/DEF:NetworkError:Class]
|
||||
|
||||
|
||||
# [DEF:network.SupersetAuthCache:Class]
|
||||
# [DEF:SupersetAuthCache:Class]
|
||||
# @PURPOSE: Process-local cache for Superset access/csrf tokens keyed by environment credentials.
|
||||
# @PRE: base_url and username are stable strings.
|
||||
# @POST: Cached entries expire automatically by TTL and can be reused across requests.
|
||||
@@ -152,8 +151,8 @@ class SupersetAuthCache:
|
||||
# [DEF:APIClient:Class]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Synchronous Superset API client with process-local auth token caching.
|
||||
# @RELATION: DEPENDS_ON -> network.SupersetAuthCache
|
||||
# @RELATION: DEPENDS_ON -> logger
|
||||
# @RELATION: [DEPENDS_ON] ->[SupersetAuthCache]
|
||||
# @RELATION: [DEPENDS_ON] ->[LoggerModule]
|
||||
class APIClient:
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
@@ -256,7 +255,7 @@ class APIClient:
|
||||
return f"{self.api_base_url}{normalized_endpoint}"
|
||||
# [/DEF:_build_api_url:Function]
|
||||
|
||||
# [DEF:authenticate:Function]
|
||||
# [DEF:APIClient.authenticate:Function]
|
||||
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
|
||||
# @PRE: self.auth and self.base_url must be valid.
|
||||
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
|
||||
@@ -364,7 +363,14 @@ class APIClient:
|
||||
if status_code == 502 or status_code == 503 or status_code == 504:
|
||||
raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from e
|
||||
if status_code == 404:
|
||||
raise DashboardNotFoundError(endpoint) from e
|
||||
if self._is_dashboard_endpoint(endpoint):
|
||||
raise DashboardNotFoundError(endpoint) from e
|
||||
raise SupersetAPIError(
|
||||
f"API resource not found at endpoint '{endpoint}'",
|
||||
status_code=status_code,
|
||||
endpoint=endpoint,
|
||||
subtype="not_found",
|
||||
) from e
|
||||
if status_code == 403:
|
||||
raise PermissionDeniedError() from e
|
||||
if status_code == 401:
|
||||
@@ -372,6 +378,24 @@ class APIClient:
|
||||
raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
|
||||
# [/DEF:_handle_http_error:Function]
|
||||
|
||||
# [DEF:_is_dashboard_endpoint:Function]
|
||||
# @PURPOSE: Determine whether an API endpoint represents a dashboard resource for 404 translation.
|
||||
# @PRE: endpoint may be relative or absolute.
|
||||
# @POST: Returns true only for dashboard-specific endpoints.
|
||||
def _is_dashboard_endpoint(self, endpoint: str) -> bool:
|
||||
normalized_endpoint = str(endpoint or "").strip().lower()
|
||||
if not normalized_endpoint:
|
||||
return False
|
||||
if normalized_endpoint.startswith("http://") or normalized_endpoint.startswith("https://"):
|
||||
try:
|
||||
normalized_endpoint = "/" + normalized_endpoint.split("/api/v1", 1)[1].lstrip("/")
|
||||
except IndexError:
|
||||
return False
|
||||
if normalized_endpoint.startswith("/api/v1/"):
|
||||
normalized_endpoint = normalized_endpoint[len("/api/v1"):]
|
||||
return normalized_endpoint.startswith("/dashboard/") or normalized_endpoint == "/dashboard"
|
||||
# [/DEF:_is_dashboard_endpoint:Function]
|
||||
|
||||
# [DEF:_handle_network_error:Function]
|
||||
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
|
||||
# @PARAM: e (requests.exceptions.RequestException) - Ошибка.
|
||||
@@ -505,4 +529,4 @@ class APIClient:
|
||||
|
||||
# [/DEF:APIClient:Class]
|
||||
|
||||
# [/DEF:backend.core.utils.network:Module]
|
||||
# [/DEF:NetworkModule:Module]
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
# @SEMANTICS: dataset_review, superset, compilation_preview, sql_lab_launch, execution_truth
|
||||
# @PURPOSE: Interact with Superset preview compilation and SQL Lab execution endpoints using the current approved execution context.
|
||||
# @LAYER: Infra
|
||||
# @RELATION: [CALLS] ->[backend.src.core.superset_client.SupersetClient]
|
||||
# @RELATION: [CALLS] ->[SupersetClient]
|
||||
# @RELATION: [DEPENDS_ON] ->[CompiledPreview]
|
||||
# @RELATION: [DEPENDS_ON] ->[DatasetRunContext]
|
||||
# @PRE: effective template params and dataset execution reference are available.
|
||||
# @POST: preview and launch calls return Superset-originated artifacts or explicit errors.
|
||||
# @SIDE_EFFECT: performs upstream Superset preview and SQL Lab calls.
|
||||
@@ -54,7 +53,7 @@ class SqlLabLaunchPayload:
|
||||
# [DEF:SupersetCompilationAdapter:Class]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Delegate preview compilation and SQL Lab launch to Superset without local SQL fabrication.
|
||||
# @RELATION: [CALLS] ->[backend.src.core.superset_client.SupersetClient]
|
||||
# @RELATION: [CALLS] ->[SupersetClient]
|
||||
# @PRE: environment is configured and Superset is reachable for the target session.
|
||||
# @POST: adapter can return explicit ready/failed preview artifacts and canonical SQL Lab references.
|
||||
# @SIDE_EFFECT: issues network requests to Superset API surfaces.
|
||||
@@ -222,57 +221,49 @@ class SupersetCompilationAdapter:
|
||||
|
||||
# [DEF:SupersetCompilationAdapter._request_superset_preview:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Probe supported Superset preview surfaces and return the first explicit compilation response.
|
||||
# @RELATION: [CALLS] ->[backend.src.core.superset_client.SupersetClient]
|
||||
# @PURPOSE: Request preview compilation through explicit client support backed by real Superset endpoints only.
|
||||
# @RELATION: [CALLS] ->[SupersetClient.compile_dataset_preview]
|
||||
# @PRE: payload contains a valid dataset identifier and deterministic execution inputs for one preview attempt.
|
||||
# @POST: returns the first upstream response that exposes compiled SQL without fabricating local SQL.
|
||||
# @SIDE_EFFECT: issues one or more Superset preview requests until a supported surface responds.
|
||||
# @POST: returns one normalized upstream compilation response without endpoint guessing.
|
||||
# @SIDE_EFFECT: issues one Superset chart-data request through the client.
|
||||
# @DATA_CONTRACT: Input[PreviewCompilationPayload] -> Output[Dict[str,Any]]
|
||||
def _request_superset_preview(self, payload: PreviewCompilationPayload) -> Dict[str, Any]:
|
||||
request_payload = {
|
||||
"dataset_id": payload.dataset_id,
|
||||
"template_params": payload.template_params,
|
||||
"effective_filters": payload.effective_filters,
|
||||
"session_id": payload.session_id,
|
||||
}
|
||||
candidate_calls = self._build_preview_call_candidates(payload.dataset_id, request_payload)
|
||||
errors: List[str] = []
|
||||
try:
|
||||
logger.reason(
|
||||
"Attempting deterministic Superset preview compilation via chart/data",
|
||||
extra={
|
||||
"dataset_id": payload.dataset_id,
|
||||
"session_id": payload.session_id,
|
||||
"filter_count": len(payload.effective_filters),
|
||||
"template_param_count": len(payload.template_params),
|
||||
},
|
||||
)
|
||||
response = self.client.compile_dataset_preview(
|
||||
dataset_id=payload.dataset_id,
|
||||
template_params=payload.template_params,
|
||||
effective_filters=payload.effective_filters,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"Superset preview compilation via chart/data failed",
|
||||
extra={
|
||||
"dataset_id": payload.dataset_id,
|
||||
"session_id": payload.session_id,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
for candidate in candidate_calls:
|
||||
call_kind = candidate["kind"]
|
||||
target = candidate["target"]
|
||||
try:
|
||||
logger.reason(
|
||||
"Attempting Superset preview compilation candidate",
|
||||
extra={"kind": call_kind, "target": target},
|
||||
)
|
||||
if call_kind == "client_method":
|
||||
method = getattr(self.client, target)
|
||||
response = method(request_payload)
|
||||
else:
|
||||
response = self.client.network.request(
|
||||
method=candidate["http_method"],
|
||||
endpoint=target,
|
||||
data=candidate["data"],
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
normalized = self._normalize_preview_response(response)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
except Exception as exc:
|
||||
errors.append(f"{call_kind}:{target}:{exc}")
|
||||
logger.explore(
|
||||
"Superset preview compilation candidate failed",
|
||||
extra={"kind": call_kind, "target": target, "error": str(exc)},
|
||||
)
|
||||
|
||||
raise RuntimeError("; ".join(errors) or "No Superset preview surface accepted the request")
|
||||
normalized = self._normalize_preview_response(response)
|
||||
if normalized is None:
|
||||
raise RuntimeError("Superset chart/data compilation response could not be normalized")
|
||||
return normalized
|
||||
# [/DEF:SupersetCompilationAdapter._request_superset_preview:Function]
|
||||
|
||||
# [DEF:SupersetCompilationAdapter._request_sql_lab_session:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Probe supported SQL Lab execution surfaces and return the first successful response.
|
||||
# @RELATION: [CALLS] ->[backend.src.core.superset_client.SupersetClient]
|
||||
# @RELATION: [CALLS] ->[SupersetClient.get_dataset]
|
||||
# @PRE: payload carries non-empty Superset-originated SQL and a preview identifier for the current launch.
|
||||
# @POST: returns the first successful SQL Lab execution response from Superset.
|
||||
# @SIDE_EFFECT: issues Superset dataset lookup and SQL Lab execution requests.
|
||||
@@ -317,49 +308,6 @@ class SupersetCompilationAdapter:
|
||||
raise RuntimeError("; ".join(errors) or "No Superset SQL Lab surface accepted the request")
|
||||
# [/DEF:SupersetCompilationAdapter._request_sql_lab_session:Function]
|
||||
|
||||
# [DEF:SupersetCompilationAdapter._build_preview_call_candidates:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Assemble preview candidate call shapes in priority order.
|
||||
def _build_preview_call_candidates(
|
||||
self,
|
||||
dataset_id: int,
|
||||
request_payload: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for method_name in (
|
||||
"compile_sql_preview",
|
||||
"compile_preview",
|
||||
"get_compiled_sql_preview",
|
||||
):
|
||||
if hasattr(self.client, method_name):
|
||||
candidates.append({"kind": "client_method", "target": method_name})
|
||||
|
||||
encoded_payload = self._dump_json(request_payload)
|
||||
candidates.extend(
|
||||
[
|
||||
{
|
||||
"kind": "network",
|
||||
"target": f"/dataset/{dataset_id}/preview",
|
||||
"http_method": "POST",
|
||||
"data": encoded_payload,
|
||||
},
|
||||
{
|
||||
"kind": "network",
|
||||
"target": f"/dataset/{dataset_id}/sql",
|
||||
"http_method": "POST",
|
||||
"data": encoded_payload,
|
||||
},
|
||||
{
|
||||
"kind": "network",
|
||||
"target": "/sqllab/format_sql/",
|
||||
"http_method": "POST",
|
||||
"data": encoded_payload,
|
||||
},
|
||||
]
|
||||
)
|
||||
return candidates
|
||||
# [/DEF:SupersetCompilationAdapter._build_preview_call_candidates:Function]
|
||||
|
||||
# [DEF:SupersetCompilationAdapter._normalize_preview_response:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Normalize candidate Superset preview responses into one compiled-sql structure.
|
||||
|
||||
@@ -94,6 +94,8 @@ class SupersetContextExtractor:
|
||||
|
||||
dataset_id = self._extract_numeric_identifier(path_parts, "dataset")
|
||||
dashboard_id = self._extract_numeric_identifier(path_parts, "dashboard")
|
||||
dashboard_ref = self._extract_dashboard_reference(path_parts)
|
||||
dashboard_permalink_key = self._extract_dashboard_permalink_key(path_parts)
|
||||
chart_id = self._extract_numeric_identifier(path_parts, "chart")
|
||||
|
||||
resource_type = "unknown"
|
||||
@@ -108,13 +110,82 @@ class SupersetContextExtractor:
|
||||
"Resolved direct dataset link",
|
||||
extra={"dataset_id": dataset_id},
|
||||
)
|
||||
elif dashboard_id is not None:
|
||||
elif dashboard_permalink_key is not None:
|
||||
resource_type = "dashboard"
|
||||
partial_recovery = True
|
||||
dataset_ref = f"dashboard_permalink:{dashboard_permalink_key}"
|
||||
unresolved_references.append("dashboard_permalink_dataset_binding_unresolved")
|
||||
logger.reason(
|
||||
"Resolving dashboard permalink state from Superset",
|
||||
extra={"permalink_key": dashboard_permalink_key},
|
||||
)
|
||||
permalink_payload = self.client.get_dashboard_permalink_state(dashboard_permalink_key)
|
||||
permalink_state = (
|
||||
permalink_payload.get("state", permalink_payload)
|
||||
if isinstance(permalink_payload, dict)
|
||||
else {}
|
||||
)
|
||||
if isinstance(permalink_state, dict):
|
||||
for key, value in permalink_state.items():
|
||||
query_state.setdefault(key, value)
|
||||
resolved_dashboard_id = self._extract_dashboard_id_from_state(permalink_state)
|
||||
resolved_chart_id = self._extract_chart_id_from_state(permalink_state)
|
||||
if resolved_dashboard_id is not None:
|
||||
dashboard_id = resolved_dashboard_id
|
||||
unresolved_references = [
|
||||
item
|
||||
for item in unresolved_references
|
||||
if item != "dashboard_permalink_dataset_binding_unresolved"
|
||||
]
|
||||
dataset_id, unresolved_references = self._recover_dataset_binding_from_dashboard(
|
||||
dashboard_id=dashboard_id,
|
||||
dataset_ref=dataset_ref,
|
||||
unresolved_references=unresolved_references,
|
||||
)
|
||||
if dataset_id is not None:
|
||||
dataset_ref = f"dataset:{dataset_id}"
|
||||
elif resolved_chart_id is not None:
|
||||
chart_id = resolved_chart_id
|
||||
unresolved_references = [
|
||||
item
|
||||
for item in unresolved_references
|
||||
if item != "dashboard_permalink_dataset_binding_unresolved"
|
||||
]
|
||||
try:
|
||||
chart_payload = self.client.get_chart(chart_id)
|
||||
chart_data = chart_payload.get("result", chart_payload) if isinstance(chart_payload, dict) else {}
|
||||
datasource_id = chart_data.get("datasource_id")
|
||||
if datasource_id is not None:
|
||||
dataset_id = int(datasource_id)
|
||||
dataset_ref = f"dataset:{dataset_id}"
|
||||
logger.reason(
|
||||
"Recovered dataset reference from permalink chart context",
|
||||
extra={"chart_id": chart_id, "dataset_id": dataset_id},
|
||||
)
|
||||
else:
|
||||
unresolved_references.append("chart_dataset_binding_unresolved")
|
||||
except Exception as exc:
|
||||
unresolved_references.append("chart_dataset_binding_unresolved")
|
||||
logger.explore(
|
||||
"Chart lookup failed during permalink recovery",
|
||||
extra={"chart_id": chart_id, "error": str(exc)},
|
||||
)
|
||||
else:
|
||||
logger.explore(
|
||||
"Dashboard permalink state was not a structured object",
|
||||
extra={"permalink_key": dashboard_permalink_key},
|
||||
)
|
||||
elif dashboard_id is not None or dashboard_ref is not None:
|
||||
resource_type = "dashboard"
|
||||
resolved_dashboard_ref = dashboard_id if dashboard_id is not None else dashboard_ref
|
||||
logger.reason(
|
||||
"Resolving dashboard-bound dataset from Superset",
|
||||
extra={"dashboard_id": dashboard_id},
|
||||
extra={"dashboard_ref": resolved_dashboard_ref},
|
||||
)
|
||||
dashboard_detail = self.client.get_dashboard_detail(dashboard_id)
|
||||
dashboard_detail = self.client.get_dashboard_detail(resolved_dashboard_ref)
|
||||
resolved_dashboard_id = dashboard_detail.get("id")
|
||||
if resolved_dashboard_id is not None:
|
||||
dashboard_id = int(resolved_dashboard_id)
|
||||
datasets = dashboard_detail.get("datasets") or []
|
||||
if datasets:
|
||||
first_dataset = datasets[0]
|
||||
@@ -460,6 +531,127 @@ class SupersetContextExtractor:
|
||||
return int(candidate)
|
||||
# [/DEF:SupersetContextExtractor._extract_numeric_identifier:Function]
|
||||
|
||||
# [DEF:SupersetContextExtractor._extract_dashboard_reference:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Extract a dashboard id-or-slug reference from a Superset URL path.
|
||||
def _extract_dashboard_reference(self, path_parts: List[str]) -> Optional[str]:
|
||||
if "dashboard" not in path_parts:
|
||||
return None
|
||||
try:
|
||||
resource_index = path_parts.index("dashboard")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if resource_index + 1 >= len(path_parts):
|
||||
return None
|
||||
|
||||
candidate = str(path_parts[resource_index + 1]).strip()
|
||||
if not candidate or candidate == "p":
|
||||
return None
|
||||
return candidate
|
||||
# [/DEF:SupersetContextExtractor._extract_dashboard_reference:Function]
|
||||
|
||||
# [DEF:SupersetContextExtractor._extract_dashboard_permalink_key:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Extract a dashboard permalink key from a Superset URL path.
|
||||
def _extract_dashboard_permalink_key(self, path_parts: List[str]) -> Optional[str]:
|
||||
if "dashboard" not in path_parts:
|
||||
return None
|
||||
try:
|
||||
resource_index = path_parts.index("dashboard")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if resource_index + 2 >= len(path_parts):
|
||||
return None
|
||||
|
||||
permalink_marker = str(path_parts[resource_index + 1]).strip()
|
||||
permalink_key = str(path_parts[resource_index + 2]).strip()
|
||||
if permalink_marker != "p" or not permalink_key:
|
||||
return None
|
||||
return permalink_key
|
||||
# [/DEF:SupersetContextExtractor._extract_dashboard_permalink_key:Function]
|
||||
|
||||
# [DEF:SupersetContextExtractor._extract_dashboard_id_from_state:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Extract a dashboard identifier from returned permalink state when present.
|
||||
def _extract_dashboard_id_from_state(self, state: Dict[str, Any]) -> Optional[int]:
|
||||
return self._search_nested_numeric_key(
|
||||
payload=state,
|
||||
candidate_keys={"dashboardId", "dashboard_id", "dashboard_id_value"},
|
||||
)
|
||||
# [/DEF:SupersetContextExtractor._extract_dashboard_id_from_state:Function]
|
||||
|
||||
# [DEF:SupersetContextExtractor._extract_chart_id_from_state:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Extract a chart identifier from returned permalink state when dashboard id is absent.
|
||||
def _extract_chart_id_from_state(self, state: Dict[str, Any]) -> Optional[int]:
|
||||
return self._search_nested_numeric_key(
|
||||
payload=state,
|
||||
candidate_keys={"slice_id", "sliceId", "chartId", "chart_id"},
|
||||
)
|
||||
# [/DEF:SupersetContextExtractor._extract_chart_id_from_state:Function]
|
||||
|
||||
# [DEF:SupersetContextExtractor._search_nested_numeric_key:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Recursively search nested dict/list payloads for the first numeric value under a candidate key set.
|
||||
def _search_nested_numeric_key(self, payload: Any, candidate_keys: Set[str]) -> Optional[int]:
|
||||
if isinstance(payload, dict):
|
||||
for key, value in payload.items():
|
||||
if key in candidate_keys:
|
||||
try:
|
||||
if value is not None:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
found = self._search_nested_numeric_key(value, candidate_keys)
|
||||
if found is not None:
|
||||
return found
|
||||
elif isinstance(payload, list):
|
||||
for item in payload:
|
||||
found = self._search_nested_numeric_key(item, candidate_keys)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
# [/DEF:SupersetContextExtractor._search_nested_numeric_key:Function]
|
||||
|
||||
# [DEF:SupersetContextExtractor._recover_dataset_binding_from_dashboard:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Recover a dataset binding from resolved dashboard context while preserving explicit unresolved markers.
|
||||
def _recover_dataset_binding_from_dashboard(
|
||||
self,
|
||||
dashboard_id: int,
|
||||
dataset_ref: Optional[str],
|
||||
unresolved_references: List[str],
|
||||
) -> tuple[Optional[int], List[str]]:
|
||||
dashboard_detail = self.client.get_dashboard_detail(dashboard_id)
|
||||
datasets = dashboard_detail.get("datasets") or []
|
||||
if datasets:
|
||||
first_dataset = datasets[0]
|
||||
resolved_dataset_id = first_dataset.get("id")
|
||||
if resolved_dataset_id is not None:
|
||||
resolved_dataset = int(resolved_dataset_id)
|
||||
logger.reason(
|
||||
"Recovered dataset reference from dashboard permalink context",
|
||||
extra={
|
||||
"dashboard_id": dashboard_id,
|
||||
"dataset_id": resolved_dataset,
|
||||
"dataset_count": len(datasets),
|
||||
"dataset_ref": dataset_ref,
|
||||
},
|
||||
)
|
||||
if len(datasets) > 1 and "multiple_dashboard_datasets" not in unresolved_references:
|
||||
unresolved_references.append("multiple_dashboard_datasets")
|
||||
return resolved_dataset, unresolved_references
|
||||
if "dashboard_dataset_id_missing" not in unresolved_references:
|
||||
unresolved_references.append("dashboard_dataset_id_missing")
|
||||
return None, unresolved_references
|
||||
|
||||
if "dashboard_dataset_binding_missing" not in unresolved_references:
|
||||
unresolved_references.append("dashboard_dataset_binding_missing")
|
||||
return None, unresolved_references
|
||||
# [/DEF:SupersetContextExtractor._recover_dataset_binding_from_dashboard:Function]
|
||||
|
||||
# [DEF:SupersetContextExtractor._decode_query_state:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Decode query-string structures used by Superset URL state transport.
|
||||
@@ -470,7 +662,7 @@ class SupersetContextExtractor:
|
||||
continue
|
||||
raw_value = values[-1]
|
||||
decoded_value = unquote(raw_value)
|
||||
if key in {"native_filters", "native_filters_key", "form_data", "q"}:
|
||||
if key in {"native_filters", "form_data", "q"}:
|
||||
try:
|
||||
query_state[key] = json.loads(decoded_value)
|
||||
continue
|
||||
@@ -514,6 +706,36 @@ class SupersetContextExtractor:
|
||||
}
|
||||
)
|
||||
|
||||
dashboard_data_mask = query_state.get("dataMask")
|
||||
if isinstance(dashboard_data_mask, dict):
|
||||
for filter_key, item in dashboard_data_mask.items():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
filter_state = item.get("filterState")
|
||||
extra_form_data = item.get("extraFormData")
|
||||
display_name = None
|
||||
raw_value = None
|
||||
if isinstance(filter_state, dict):
|
||||
display_name = filter_state.get("label")
|
||||
raw_value = filter_state.get("value")
|
||||
if raw_value is None and isinstance(extra_form_data, dict):
|
||||
extra_filters = extra_form_data.get("filters")
|
||||
if isinstance(extra_filters, list) and extra_filters:
|
||||
first_filter = extra_filters[0]
|
||||
if isinstance(first_filter, dict):
|
||||
raw_value = first_filter.get("val")
|
||||
imported_filters.append(
|
||||
{
|
||||
"filter_name": str(item.get("id") or filter_key),
|
||||
"raw_value": raw_value,
|
||||
"display_name": display_name,
|
||||
"source": "superset_permalink",
|
||||
"recovery_status": "recovered" if raw_value is not None else "partial",
|
||||
"requires_confirmation": raw_value is None,
|
||||
"notes": "Recovered from Superset dashboard permalink state",
|
||||
}
|
||||
)
|
||||
|
||||
form_data_payload = query_state.get("form_data")
|
||||
if isinstance(form_data_payload, dict):
|
||||
extra_filters = form_data_payload.get("extra_filters") or []
|
||||
|
||||
Reference in New Issue
Block a user