vantage-cli 0.1.1__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.
- vantage_cli/__init__.py +131 -0
- vantage_cli/apps/__init__.py +22 -0
- vantage_cli/apps/common.py +78 -0
- vantage_cli/apps/juju_localhost/__init__.py +17 -0
- vantage_cli/apps/juju_localhost/app.py +255 -0
- vantage_cli/apps/juju_localhost/bundle_yaml.py +143 -0
- vantage_cli/apps/microk8s/README.md +47 -0
- vantage_cli/apps/microk8s/__init__.py +3 -0
- vantage_cli/apps/microk8s/app.py +301 -0
- vantage_cli/apps/multipass_singlenode/__init__.py +12 -0
- vantage_cli/apps/multipass_singlenode/app.py +173 -0
- vantage_cli/apps/templates.py +178 -0
- vantage_cli/auth.py +429 -0
- vantage_cli/cache.py +143 -0
- vantage_cli/client.py +84 -0
- vantage_cli/command_base.py +63 -0
- vantage_cli/commands/__init__.py +1 -0
- vantage_cli/commands/clouds/__init__.py +20 -0
- vantage_cli/commands/clouds/add.py +81 -0
- vantage_cli/commands/clouds/delete.py +61 -0
- vantage_cli/commands/clouds/render.py +146 -0
- vantage_cli/commands/clouds/update.py +97 -0
- vantage_cli/commands/clusters/__init__.py +27 -0
- vantage_cli/commands/clusters/create.py +270 -0
- vantage_cli/commands/clusters/delete.py +101 -0
- vantage_cli/commands/clusters/get.py +30 -0
- vantage_cli/commands/clusters/list.py +84 -0
- vantage_cli/commands/clusters/render.py +233 -0
- vantage_cli/commands/clusters/schema.py +31 -0
- vantage_cli/commands/clusters/utils.py +248 -0
- vantage_cli/commands/profile/__init__.py +30 -0
- vantage_cli/commands/profile/crud.py +529 -0
- vantage_cli/commands/profile/render.py +55 -0
- vantage_cli/config.py +161 -0
- vantage_cli/constants.py +40 -0
- vantage_cli/exceptions.py +127 -0
- vantage_cli/format.py +39 -0
- vantage_cli/gql_client.py +655 -0
- vantage_cli/main.py +303 -0
- vantage_cli/render.py +56 -0
- vantage_cli/schemas.py +48 -0
- vantage_cli/time_loop.py +124 -0
- vantage_cli-0.1.1.dist-info/METADATA +30 -0
- vantage_cli-0.1.1.dist-info/RECORD +46 -0
- vantage_cli-0.1.1.dist-info/WHEEL +4 -0
- vantage_cli-0.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
# © 2025 Vantage Compute, Inc. All rights reserved.
|
|
2
|
+
# Confidential and proprietary. Unauthorized use prohibited.
|
|
3
|
+
"""Modern GraphQL client implementation using the gql library.
|
|
4
|
+
|
|
5
|
+
This module provides a robust, production-ready GraphQL client with comprehensive
|
|
6
|
+
features including authentication, retry logic, error handling, and observability.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
15
|
+
|
|
16
|
+
from gql import Client
|
|
17
|
+
from gql import gql as gql_query
|
|
18
|
+
from gql.transport.aiohttp import AIOHTTPTransport
|
|
19
|
+
from gql.transport.exceptions import (
|
|
20
|
+
TransportClosed,
|
|
21
|
+
TransportConnectionFailed,
|
|
22
|
+
TransportServerError,
|
|
23
|
+
)
|
|
24
|
+
from graphql import DocumentNode
|
|
25
|
+
from graphql.language.ast import OperationDefinitionNode
|
|
26
|
+
from jose import exceptions as jwt_exceptions
|
|
27
|
+
from jose import jwt
|
|
28
|
+
from loguru import logger
|
|
29
|
+
from requests.exceptions import ConnectionError, Timeout
|
|
30
|
+
|
|
31
|
+
from .auth import extract_persona, refresh_access_token_standalone
|
|
32
|
+
from .cache import load_tokens_from_cache, save_tokens_to_cache
|
|
33
|
+
from .config import Settings
|
|
34
|
+
from .exceptions import VantageCliError
|
|
35
|
+
from .schemas import Persona
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(VantageCliError):
|
|
39
|
+
"""Authentication-related errors."""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GraphQLError(VantageCliError):
|
|
45
|
+
"""GraphQL-specific error."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message: str,
|
|
50
|
+
query: Optional[str] = None,
|
|
51
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
52
|
+
errors: Optional[List[Dict[str, Any]]] = None,
|
|
53
|
+
extensions: Optional[Dict[str, Any]] = None,
|
|
54
|
+
):
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.query = query
|
|
57
|
+
self.variables = variables
|
|
58
|
+
self.errors = errors or []
|
|
59
|
+
self.extensions = extensions or {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TransportType(Enum):
|
|
63
|
+
"""Available transport types for GraphQL client."""
|
|
64
|
+
|
|
65
|
+
AIOHTTP = "aiohttp" # Async transport using aiohttp library
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class GraphQLClientConfig:
|
|
70
|
+
"""Configuration for GraphQL client."""
|
|
71
|
+
|
|
72
|
+
# Connection settings
|
|
73
|
+
url: str
|
|
74
|
+
timeout: int = 30
|
|
75
|
+
verify_ssl: bool = True
|
|
76
|
+
|
|
77
|
+
# Retry settings
|
|
78
|
+
max_retries: int = 3
|
|
79
|
+
retry_backoff_factor: float = 0.5
|
|
80
|
+
retry_status_codes: Tuple[int, ...] = (429, 500, 502, 503, 504)
|
|
81
|
+
|
|
82
|
+
# Schema settings
|
|
83
|
+
fetch_schema: bool = True
|
|
84
|
+
validate_queries: bool = True
|
|
85
|
+
|
|
86
|
+
# Observability settings
|
|
87
|
+
enable_logging: bool = True
|
|
88
|
+
log_queries: bool = False # Set to True for debugging (security risk in production)
|
|
89
|
+
|
|
90
|
+
# Custom headers
|
|
91
|
+
headers: Dict[str, str] = field(default_factory=lambda: {})
|
|
92
|
+
|
|
93
|
+
# Transport type
|
|
94
|
+
transport_type: TransportType = TransportType.AIOHTTP
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class QueryMetrics:
|
|
99
|
+
"""Metrics for a GraphQL query execution."""
|
|
100
|
+
|
|
101
|
+
query_name: str
|
|
102
|
+
execution_time_ms: float
|
|
103
|
+
success: bool
|
|
104
|
+
error_type: Optional[str] = None
|
|
105
|
+
retry_count: int = 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class VantageGraphQLClient:
|
|
109
|
+
"""Production-ready GraphQL client with comprehensive features.
|
|
110
|
+
|
|
111
|
+
Features:
|
|
112
|
+
- Multiple transport options (sync/async)
|
|
113
|
+
- Automatic authentication token injection
|
|
114
|
+
- Request retry logic with exponential backoff
|
|
115
|
+
- Schema validation and introspection
|
|
116
|
+
- Comprehensive error handling
|
|
117
|
+
- Request/response logging and metrics
|
|
118
|
+
- Connection pooling and keep-alive
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
config: GraphQLClientConfig,
|
|
124
|
+
persona: Optional[Persona] = None,
|
|
125
|
+
profile: str = "default",
|
|
126
|
+
settings: Optional[Settings] = None,
|
|
127
|
+
):
|
|
128
|
+
self.config = config
|
|
129
|
+
self.persona = persona
|
|
130
|
+
self.profile = profile
|
|
131
|
+
self.settings = settings
|
|
132
|
+
self._client: Optional[Client] = None
|
|
133
|
+
self._transport = None
|
|
134
|
+
self._schema = None
|
|
135
|
+
self._query_metrics: List[QueryMetrics] = []
|
|
136
|
+
|
|
137
|
+
# Setup logging
|
|
138
|
+
if config.enable_logging:
|
|
139
|
+
self._setup_logging()
|
|
140
|
+
|
|
141
|
+
def _setup_logging(self) -> None:
|
|
142
|
+
"""Set up GraphQL client logging."""
|
|
143
|
+
gql_logger = logging.getLogger("gql")
|
|
144
|
+
gql_logger.setLevel(logging.INFO if self.config.log_queries else logging.WARNING)
|
|
145
|
+
|
|
146
|
+
def _create_transport(self) -> None:
|
|
147
|
+
"""Create (or recreate) the underlying transport and store it.
|
|
148
|
+
|
|
149
|
+
Tests only assert side–effects (headers/auth) and in some cases
|
|
150
|
+
expect this method to return ``None``. Returning ``None`` keeps
|
|
151
|
+
semantics simple while callers rely on ``self._transport``.
|
|
152
|
+
"""
|
|
153
|
+
headers = {"Content-Type": "application/json"}
|
|
154
|
+
|
|
155
|
+
if self.persona and self.persona.token_set.access_token:
|
|
156
|
+
headers["Authorization"] = f"Bearer {self.persona.token_set.access_token}"
|
|
157
|
+
|
|
158
|
+
# Currently only AIOHTTP async transport is supported.
|
|
159
|
+
self._transport = AIOHTTPTransport(
|
|
160
|
+
url=self.config.url,
|
|
161
|
+
headers=headers,
|
|
162
|
+
timeout=self.config.timeout,
|
|
163
|
+
ssl=self.config.verify_ssl,
|
|
164
|
+
)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def _build_headers(self) -> Dict[str, str]:
|
|
168
|
+
"""Build HTTP headers including authentication."""
|
|
169
|
+
headers = {
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
"User-Agent": "VantageGraphQLClient/1.0",
|
|
172
|
+
**self.config.headers,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Add authentication if persona is available
|
|
176
|
+
if self.persona and self.persona.token_set.access_token:
|
|
177
|
+
headers["Authorization"] = f"Bearer {self.persona.token_set.access_token}"
|
|
178
|
+
|
|
179
|
+
return headers
|
|
180
|
+
|
|
181
|
+
def _validate_auth(self) -> None:
|
|
182
|
+
"""Validate authentication before making requests."""
|
|
183
|
+
if not self.persona:
|
|
184
|
+
raise AuthenticationError("No authentication persona provided")
|
|
185
|
+
|
|
186
|
+
if not self.persona.token_set.access_token:
|
|
187
|
+
raise AuthenticationError("No access token available")
|
|
188
|
+
|
|
189
|
+
# Check if token is expired
|
|
190
|
+
if self._is_token_expired():
|
|
191
|
+
raise AuthenticationError("Access token has expired")
|
|
192
|
+
|
|
193
|
+
def _is_token_expired(self) -> bool:
|
|
194
|
+
"""Check if the current token is expired."""
|
|
195
|
+
if not self.persona or not self.persona.token_set.access_token:
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
# Decode without verification to check expiration
|
|
200
|
+
jwt.decode(
|
|
201
|
+
self.persona.token_set.access_token,
|
|
202
|
+
key="", # Empty key since we're not verifying signature
|
|
203
|
+
options={"verify_signature": False, "verify_exp": True},
|
|
204
|
+
)
|
|
205
|
+
return False
|
|
206
|
+
except jwt_exceptions.ExpiredSignatureError:
|
|
207
|
+
return True
|
|
208
|
+
except jwt_exceptions.JWTError:
|
|
209
|
+
logger.warning("Invalid token format")
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
async def _refresh_token_async(self, settings: Settings) -> bool:
|
|
213
|
+
"""Refresh the access token using the refresh token asynchronously.
|
|
214
|
+
|
|
215
|
+
Returns True if refresh was successful, False otherwise.
|
|
216
|
+
Updates the persona's token_set in-place.
|
|
217
|
+
"""
|
|
218
|
+
if not self.persona or not self.persona.token_set.refresh_token:
|
|
219
|
+
logger.warning("No persona or refresh token available")
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
# Use the existing sync refresh function in an async wrapper
|
|
224
|
+
# This is safe since the function doesn't block the event loop for long
|
|
225
|
+
refresh_success = refresh_access_token_standalone(self.persona.token_set, settings)
|
|
226
|
+
|
|
227
|
+
if refresh_success:
|
|
228
|
+
# Save updated tokens to cache
|
|
229
|
+
save_tokens_to_cache(self.profile, self.persona.token_set)
|
|
230
|
+
logger.debug("Successfully refreshed access token")
|
|
231
|
+
return True
|
|
232
|
+
else:
|
|
233
|
+
logger.error("Token refresh returned False")
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"Failed to refresh token asynchronously: {e}")
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
def _refresh_transport_headers(self) -> None:
|
|
241
|
+
"""Update transport with refreshed token by recreating it."""
|
|
242
|
+
if self.persona and self.persona.token_set.access_token:
|
|
243
|
+
# Recreate transport with updated token. Some tests monkeypatch
|
|
244
|
+
# _create_transport to return a sentinel transport; honor that
|
|
245
|
+
# return value if provided while keeping default implementation
|
|
246
|
+
# (which returns None and sets self._transport internally).
|
|
247
|
+
maybe_transport = self._create_transport()
|
|
248
|
+
if maybe_transport is not None: # pragma: no cover - exercised via monkeypatch
|
|
249
|
+
self._transport = maybe_transport # type: ignore[assignment]
|
|
250
|
+
|
|
251
|
+
def _log_query_metrics(self, metrics: QueryMetrics) -> None:
|
|
252
|
+
"""Log query execution metrics."""
|
|
253
|
+
self._query_metrics.append(metrics)
|
|
254
|
+
|
|
255
|
+
if self.config.enable_logging:
|
|
256
|
+
status = "SUCCESS" if metrics.success else "FAILED"
|
|
257
|
+
logger.info(
|
|
258
|
+
f"GraphQL Query [{metrics.query_name}] {status} "
|
|
259
|
+
f"in {metrics.execution_time_ms:.2f}ms "
|
|
260
|
+
f"(retries: {metrics.retry_count})"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if not metrics.success and metrics.error_type:
|
|
264
|
+
logger.error(f"Query failed with error: {metrics.error_type}")
|
|
265
|
+
|
|
266
|
+
def _extract_query_name(self, query: Union[str, DocumentNode]) -> str:
|
|
267
|
+
"""Extract operation name from GraphQL query or DocumentNode.
|
|
268
|
+
|
|
269
|
+
Falls back to ``UnnamedOperation`` if no explicit name can be
|
|
270
|
+
determined. This function purposefully implements a *loose*
|
|
271
|
+
parsing strategy sufficient for logging & metrics – not full
|
|
272
|
+
GraphQL validation.
|
|
273
|
+
"""
|
|
274
|
+
# AST path: leverage OperationDefinition name if present.
|
|
275
|
+
if isinstance(query, DocumentNode):
|
|
276
|
+
for definition in getattr(query, "definitions", []) or []:
|
|
277
|
+
if isinstance(definition, OperationDefinitionNode):
|
|
278
|
+
name_node = getattr(definition, "name", None)
|
|
279
|
+
if name_node and getattr(name_node, "value", ""):
|
|
280
|
+
return name_node.value # type: ignore[return-value]
|
|
281
|
+
# Fallback to string parsing after AST attempt
|
|
282
|
+
query_str = str(query)
|
|
283
|
+
else:
|
|
284
|
+
query_str = query
|
|
285
|
+
|
|
286
|
+
lowered = query_str.lower()
|
|
287
|
+
# String heuristics (case‑insensitive search preserving original case)
|
|
288
|
+
for op in ("query", "mutation"):
|
|
289
|
+
idx = lowered.find(f"{op} ")
|
|
290
|
+
if idx != -1:
|
|
291
|
+
try:
|
|
292
|
+
segment = query_str[idx + len(op) + 1 :].split("{")[0].strip()
|
|
293
|
+
if segment and not segment.startswith("("):
|
|
294
|
+
return segment.split("(")[0].strip()
|
|
295
|
+
except Exception:
|
|
296
|
+
break
|
|
297
|
+
return "UnnamedOperation"
|
|
298
|
+
|
|
299
|
+
def _handle_graphql_errors(
|
|
300
|
+
self, result: Dict[str, Any], query: str, variables: Optional[Dict[str, Any]] = None
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Handle GraphQL errors from response."""
|
|
303
|
+
# Check for errors in the response data structure
|
|
304
|
+
if "errors" in result and result["errors"]:
|
|
305
|
+
error_messages = [str(error) for error in result["errors"]]
|
|
306
|
+
raise GraphQLError(
|
|
307
|
+
message=f"GraphQL errors: {'; '.join(error_messages)}",
|
|
308
|
+
query=query if self.config.log_queries else None,
|
|
309
|
+
variables=variables if self.config.log_queries else None,
|
|
310
|
+
errors=result["errors"],
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _handle_transport_error(self, error: Exception, query_name: str) -> None:
|
|
314
|
+
"""Handle transport-level errors with appropriate error types."""
|
|
315
|
+
if isinstance(error, TransportServerError):
|
|
316
|
+
if "401" in str(error) or "Unauthorized" in str(error):
|
|
317
|
+
raise AuthenticationError(f"Authentication failed: {error}")
|
|
318
|
+
elif "403" in str(error) or "Forbidden" in str(error):
|
|
319
|
+
raise AuthenticationError(f"Access forbidden: {error}")
|
|
320
|
+
else:
|
|
321
|
+
raise GraphQLError(f"Server error during {query_name}: {error}")
|
|
322
|
+
|
|
323
|
+
elif isinstance(error, (TransportConnectionFailed, ConnectionError)):
|
|
324
|
+
raise GraphQLError(f"Connection failed during {query_name}: {error}")
|
|
325
|
+
|
|
326
|
+
elif isinstance(error, Timeout):
|
|
327
|
+
raise GraphQLError(f"Request timeout during {query_name}: {error}")
|
|
328
|
+
|
|
329
|
+
elif isinstance(error, TransportClosed):
|
|
330
|
+
raise GraphQLError(f"Transport closed during {query_name}: {error}")
|
|
331
|
+
|
|
332
|
+
else:
|
|
333
|
+
raise GraphQLError(f"Transport error during {query_name}: {error}")
|
|
334
|
+
|
|
335
|
+
@asynccontextmanager
|
|
336
|
+
async def _async_session(self):
|
|
337
|
+
"""Context manager for asynchronous GraphQL sessions."""
|
|
338
|
+
if not self._transport:
|
|
339
|
+
created = self._create_transport()
|
|
340
|
+
# Support tests that monkeypatch _create_transport to return a transport
|
|
341
|
+
if self._transport is None and created is not None: # pragma: no cover
|
|
342
|
+
self._transport = created # type: ignore[assignment]
|
|
343
|
+
|
|
344
|
+
client = Client(
|
|
345
|
+
transport=self._transport, fetch_schema_from_transport=self.config.fetch_schema
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
async with client as session:
|
|
350
|
+
yield session
|
|
351
|
+
finally:
|
|
352
|
+
# Transport cleanup is handled by the context manager
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
async def execute_async(
|
|
356
|
+
self, query: str, variables: Optional[Dict[str, Any]] = None, require_auth: bool = True
|
|
357
|
+
) -> Dict[str, Any]:
|
|
358
|
+
"""Execute a GraphQL query asynchronously.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
query: GraphQL query string
|
|
362
|
+
variables: Query variables
|
|
363
|
+
require_auth: Whether authentication is required
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Query result data
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
GraphQLError: For GraphQL-specific errors
|
|
370
|
+
AuthenticationError: For authentication issues
|
|
371
|
+
"""
|
|
372
|
+
if require_auth:
|
|
373
|
+
self._validate_auth()
|
|
374
|
+
|
|
375
|
+
query_name = self._extract_query_name(query)
|
|
376
|
+
start_time = time.time()
|
|
377
|
+
retry_count = 0
|
|
378
|
+
max_auth_retries = 1 # Only retry once for auth errors
|
|
379
|
+
|
|
380
|
+
while retry_count <= max_auth_retries:
|
|
381
|
+
try:
|
|
382
|
+
parsed_query = gql_query(query)
|
|
383
|
+
|
|
384
|
+
async with self._async_session() as session:
|
|
385
|
+
result = await session.execute(parsed_query, variable_values=variables or {})
|
|
386
|
+
|
|
387
|
+
# Result from gql is already a dict
|
|
388
|
+
if result:
|
|
389
|
+
self._handle_graphql_errors(result, query, variables)
|
|
390
|
+
|
|
391
|
+
execution_time = (time.time() - start_time) * 1000
|
|
392
|
+
metrics = QueryMetrics(
|
|
393
|
+
query_name=query_name,
|
|
394
|
+
execution_time_ms=execution_time,
|
|
395
|
+
success=True,
|
|
396
|
+
retry_count=retry_count,
|
|
397
|
+
)
|
|
398
|
+
self._log_query_metrics(metrics)
|
|
399
|
+
|
|
400
|
+
return result or {}
|
|
401
|
+
|
|
402
|
+
except Exception as error:
|
|
403
|
+
# Check if it's an authentication error and we can retry
|
|
404
|
+
is_auth_error = isinstance(error, TransportServerError) and (
|
|
405
|
+
"401" in str(error)
|
|
406
|
+
or "403" in str(error)
|
|
407
|
+
or "Unauthorized" in str(error)
|
|
408
|
+
or "Forbidden" in str(error)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if is_auth_error and retry_count < max_auth_retries and self.settings:
|
|
412
|
+
logger.debug(
|
|
413
|
+
f"Authentication error detected, attempting token refresh (retry {retry_count + 1})"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Try to refresh the token
|
|
417
|
+
refresh_success = await self._refresh_token_async(self.settings)
|
|
418
|
+
|
|
419
|
+
if refresh_success:
|
|
420
|
+
# Update transport with new token
|
|
421
|
+
self._refresh_transport_headers()
|
|
422
|
+
retry_count += 1
|
|
423
|
+
logger.debug("Token refreshed successfully, retrying request")
|
|
424
|
+
continue
|
|
425
|
+
else:
|
|
426
|
+
logger.error("Token refresh failed")
|
|
427
|
+
|
|
428
|
+
# If we get here, either it's not an auth error, we've exhausted retries,
|
|
429
|
+
# or refresh failed - handle the error normally
|
|
430
|
+
execution_time = (time.time() - start_time) * 1000
|
|
431
|
+
metrics = QueryMetrics(
|
|
432
|
+
query_name=query_name,
|
|
433
|
+
execution_time_ms=execution_time,
|
|
434
|
+
success=False,
|
|
435
|
+
error_type=type(error).__name__,
|
|
436
|
+
retry_count=retry_count,
|
|
437
|
+
)
|
|
438
|
+
self._log_query_metrics(metrics)
|
|
439
|
+
|
|
440
|
+
if isinstance(error, (GraphQLError, AuthenticationError)):
|
|
441
|
+
raise
|
|
442
|
+
else:
|
|
443
|
+
self._handle_transport_error(error, query_name)
|
|
444
|
+
# _handle_transport_error always raises, but just in case:
|
|
445
|
+
raise GraphQLError(f"Unexpected error during {query_name}: {error}")
|
|
446
|
+
|
|
447
|
+
# This should never be reached due to the retry loop and error handling
|
|
448
|
+
raise GraphQLError(f"Unexpected end of execution for {query_name}")
|
|
449
|
+
|
|
450
|
+
async def get_schema(self) -> Optional[Any]:
|
|
451
|
+
"""Get the GraphQL schema if available."""
|
|
452
|
+
if self.config.fetch_schema:
|
|
453
|
+
try:
|
|
454
|
+
async with self._async_session() as session:
|
|
455
|
+
# Access the client's schema, not session's
|
|
456
|
+
return getattr(session, "schema", None)
|
|
457
|
+
except Exception:
|
|
458
|
+
return None
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def get_metrics(self) -> List[QueryMetrics]:
|
|
462
|
+
"""Get query execution metrics."""
|
|
463
|
+
return self._query_metrics.copy()
|
|
464
|
+
|
|
465
|
+
def clear_metrics(self) -> None:
|
|
466
|
+
"""Clear query execution metrics."""
|
|
467
|
+
self._query_metrics.clear()
|
|
468
|
+
|
|
469
|
+
async def health_check(self) -> bool:
|
|
470
|
+
"""Perform a basic health check by executing a simple introspection query.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
True if the service is healthy, False otherwise
|
|
474
|
+
"""
|
|
475
|
+
try:
|
|
476
|
+
introspection_query = """
|
|
477
|
+
query IntrospectionQuery {
|
|
478
|
+
__schema {
|
|
479
|
+
queryType {
|
|
480
|
+
name
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
exec_result = self.execute_async(introspection_query, require_auth=False)
|
|
487
|
+
# Support tests that monkeypatch execute_async with a sync function.
|
|
488
|
+
if hasattr(exec_result, "__await__"):
|
|
489
|
+
result = await exec_result # type: ignore[assignment]
|
|
490
|
+
else: # sync fallback
|
|
491
|
+
result = exec_result # type: ignore[assignment]
|
|
492
|
+
|
|
493
|
+
if isinstance(result, dict):
|
|
494
|
+
# Success if introspection key is present (value may be empty dict in tests)
|
|
495
|
+
return "__schema" in result
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
except Exception as error:
|
|
499
|
+
logger.warning(f"Health check failed: {error}")
|
|
500
|
+
return False
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# Factory functions for common use cases
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def create_vantage_graphql_client(
|
|
507
|
+
url: str,
|
|
508
|
+
persona: Optional[Persona] = None,
|
|
509
|
+
transport_type: TransportType = TransportType.AIOHTTP,
|
|
510
|
+
profile: str = "default",
|
|
511
|
+
settings: Optional[Settings] = None,
|
|
512
|
+
**config_overrides: Any,
|
|
513
|
+
) -> VantageGraphQLClient:
|
|
514
|
+
"""Create a VantageGraphQLClient with sensible defaults.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
url: GraphQL endpoint URL
|
|
518
|
+
persona: Authentication persona
|
|
519
|
+
transport_type: Type of transport to use
|
|
520
|
+
profile: Profile name for caching
|
|
521
|
+
settings: Settings object for token refresh
|
|
522
|
+
**config_overrides: Additional configuration overrides
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
Configured VantageGraphQLClient instance
|
|
526
|
+
"""
|
|
527
|
+
config = GraphQLClientConfig(url=url, transport_type=transport_type, **config_overrides)
|
|
528
|
+
|
|
529
|
+
return VantageGraphQLClient(config=config, persona=persona, profile=profile, settings=settings)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def create_production_client(
|
|
533
|
+
url: str,
|
|
534
|
+
persona: Persona,
|
|
535
|
+
profile: str = "default",
|
|
536
|
+
settings: Optional[Settings] = None,
|
|
537
|
+
**config_overrides: Any,
|
|
538
|
+
) -> VantageGraphQLClient:
|
|
539
|
+
"""Create production-ready GraphQL client with optimal settings.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
url: GraphQL endpoint URL
|
|
543
|
+
persona: Authentication persona (required for production)
|
|
544
|
+
profile: Profile name for caching
|
|
545
|
+
settings: Settings object for token refresh
|
|
546
|
+
**config_overrides: Additional configuration overrides
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
Production-configured VantageGraphQLClient instance
|
|
550
|
+
"""
|
|
551
|
+
production_config: Dict[str, Any] = {
|
|
552
|
+
"timeout": 30,
|
|
553
|
+
"max_retries": 3,
|
|
554
|
+
"retry_backoff_factor": 1.0,
|
|
555
|
+
"verify_ssl": True,
|
|
556
|
+
"fetch_schema": False, # Skip schema fetching in production for performance
|
|
557
|
+
"validate_queries": False, # Skip validation in production for performance
|
|
558
|
+
"enable_logging": True,
|
|
559
|
+
"log_queries": False, # Never log queries in production for security
|
|
560
|
+
**config_overrides,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return create_vantage_graphql_client(
|
|
564
|
+
url=url,
|
|
565
|
+
persona=persona,
|
|
566
|
+
transport_type=TransportType.AIOHTTP,
|
|
567
|
+
profile=profile,
|
|
568
|
+
settings=settings,
|
|
569
|
+
**production_config,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def create_development_client(
|
|
574
|
+
url: str,
|
|
575
|
+
persona: Optional[Persona] = None,
|
|
576
|
+
profile: str = "default",
|
|
577
|
+
settings: Optional[Settings] = None,
|
|
578
|
+
**config_overrides: Any,
|
|
579
|
+
) -> VantageGraphQLClient:
|
|
580
|
+
"""Create development GraphQL client with debugging features.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
url: GraphQL endpoint URL
|
|
584
|
+
persona: Authentication persona (optional for development)
|
|
585
|
+
profile: Profile name for caching
|
|
586
|
+
settings: Settings object for token refresh
|
|
587
|
+
**config_overrides: Additional configuration overrides
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
Development-configured VantageGraphQLClient instance
|
|
591
|
+
"""
|
|
592
|
+
development_config: Dict[str, Any] = {
|
|
593
|
+
"timeout": 60,
|
|
594
|
+
"max_retries": 1,
|
|
595
|
+
"verify_ssl": False, # Allow self-signed certificates in dev
|
|
596
|
+
"fetch_schema": True, # Enable schema introspection
|
|
597
|
+
"validate_queries": True, # Enable query validation
|
|
598
|
+
"enable_logging": True,
|
|
599
|
+
"log_queries": True, # Enable query logging for debugging
|
|
600
|
+
**config_overrides,
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return create_vantage_graphql_client(
|
|
604
|
+
url=url,
|
|
605
|
+
persona=persona,
|
|
606
|
+
transport_type=TransportType.AIOHTTP,
|
|
607
|
+
profile=profile,
|
|
608
|
+
settings=settings,
|
|
609
|
+
**development_config,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def create_async_graphql_client(settings: Settings, profile: str = "default"):
|
|
614
|
+
"""Create an async GraphQL client for the given settings and profile.
|
|
615
|
+
|
|
616
|
+
This is a convenience function that combines the auth/cache logic with client creation.
|
|
617
|
+
It replaces the old async_graphql_client.py module functionality.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
settings: Settings object containing API configuration
|
|
621
|
+
profile: Profile name to use for authentication
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
Configured VantageGraphQLClient instance
|
|
625
|
+
|
|
626
|
+
Raises:
|
|
627
|
+
Exception: If client creation fails
|
|
628
|
+
"""
|
|
629
|
+
try:
|
|
630
|
+
# Load tokens and create persona
|
|
631
|
+
token_set = load_tokens_from_cache(profile)
|
|
632
|
+
persona = extract_persona(profile, token_set, settings)
|
|
633
|
+
|
|
634
|
+
# Construct the GraphQL endpoint URL
|
|
635
|
+
graphql_url = f"{settings.api_base_url}/cluster/graphql"
|
|
636
|
+
|
|
637
|
+
# Create async client with settings
|
|
638
|
+
client = create_production_client(
|
|
639
|
+
url=graphql_url,
|
|
640
|
+
persona=persona,
|
|
641
|
+
profile=profile,
|
|
642
|
+
settings=settings,
|
|
643
|
+
timeout=30,
|
|
644
|
+
max_retries=3,
|
|
645
|
+
verify_ssl=True,
|
|
646
|
+
enable_logging=True,
|
|
647
|
+
log_queries=False, # Security: don't log queries in production
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
logger.debug(f"Created async GraphQL client for {graphql_url}")
|
|
651
|
+
return client
|
|
652
|
+
|
|
653
|
+
except Exception as e:
|
|
654
|
+
logger.error(f"Failed to create async GraphQL client: {e}")
|
|
655
|
+
raise
|