ecosyste-ms-cli 1.3.2__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.
- ecosyste_ms_cli-1.3.2.dist-info/METADATA +133 -0
- ecosyste_ms_cli-1.3.2.dist-info/RECORD +84 -0
- ecosyste_ms_cli-1.3.2.dist-info/WHEEL +5 -0
- ecosyste_ms_cli-1.3.2.dist-info/entry_points.txt +2 -0
- ecosyste_ms_cli-1.3.2.dist-info/licenses/LICENSE +21 -0
- ecosyste_ms_cli-1.3.2.dist-info/top_level.txt +1 -0
- ecosystems_cli/__init__.py +3 -0
- ecosystems_cli/__main__.py +4 -0
- ecosystems_cli/apis/__init__.py +0 -0
- ecosystems_cli/apis/advisories.openapi.yaml +347 -0
- ecosystems_cli/apis/archives.openapi.yaml +193 -0
- ecosystems_cli/apis/commits.openapi.yaml +391 -0
- ecosystems_cli/apis/dependabot.openapi.yaml +887 -0
- ecosystems_cli/apis/diff.openapi.yaml +90 -0
- ecosystems_cli/apis/docker.openapi.yaml +534 -0
- ecosystems_cli/apis/issues.openapi.yaml +839 -0
- ecosystems_cli/apis/licenses.openapi.yaml +80 -0
- ecosystems_cli/apis/opencollective.openapi.yaml +247 -0
- ecosystems_cli/apis/packages.openapi.yaml +2522 -0
- ecosystems_cli/apis/parser.openapi.yaml +97 -0
- ecosystems_cli/apis/registries.yaml +155 -0
- ecosystems_cli/apis/repos.openapi.yaml +1521 -0
- ecosystems_cli/apis/resolve.openapi.yaml +130 -0
- ecosystems_cli/apis/sbom.openapi.yaml +79 -0
- ecosystems_cli/apis/sponsors.openapi.yaml +283 -0
- ecosystems_cli/apis/summary.openapi.yaml +239 -0
- ecosystems_cli/apis/timeline.openapi.yaml +91 -0
- ecosystems_cli/cli.py +213 -0
- ecosystems_cli/commands/__init__.py +1 -0
- ecosystems_cli/commands/advisories.py +109 -0
- ecosystems_cli/commands/archives.py +5 -0
- ecosystems_cli/commands/commits.py +5 -0
- ecosystems_cli/commands/decorators.py +101 -0
- ecosystems_cli/commands/dependabot.py +5 -0
- ecosystems_cli/commands/diff.py +144 -0
- ecosystems_cli/commands/docker.py +5 -0
- ecosystems_cli/commands/execution.py +99 -0
- ecosystems_cli/commands/generator.py +127 -0
- ecosystems_cli/commands/handlers/__init__.py +45 -0
- ecosystems_cli/commands/handlers/advisories.py +73 -0
- ecosystems_cli/commands/handlers/archives.py +40 -0
- ecosystems_cli/commands/handlers/base.py +38 -0
- ecosystems_cli/commands/handlers/commits.py +76 -0
- ecosystems_cli/commands/handlers/default.py +40 -0
- ecosystems_cli/commands/handlers/dependabot.py +205 -0
- ecosystems_cli/commands/handlers/diff.py +72 -0
- ecosystems_cli/commands/handlers/docker.py +142 -0
- ecosystems_cli/commands/handlers/factory.py +60 -0
- ecosystems_cli/commands/handlers/issues.py +87 -0
- ecosystems_cli/commands/handlers/licenses.py +52 -0
- ecosystems_cli/commands/handlers/opencollective.py +86 -0
- ecosystems_cli/commands/handlers/packages.py +103 -0
- ecosystems_cli/commands/handlers/parser.py +57 -0
- ecosystems_cli/commands/handlers/repos.py +97 -0
- ecosystems_cli/commands/handlers/resolve.py +68 -0
- ecosystems_cli/commands/handlers/sbom.py +52 -0
- ecosystems_cli/commands/handlers/sponsors.py +52 -0
- ecosystems_cli/commands/handlers/summary.py +81 -0
- ecosystems_cli/commands/handlers/timeline.py +45 -0
- ecosystems_cli/commands/issues.py +5 -0
- ecosystems_cli/commands/licenses.py +135 -0
- ecosystems_cli/commands/mcp.py +54 -0
- ecosystems_cli/commands/opencollective.py +5 -0
- ecosystems_cli/commands/packages.py +151 -0
- ecosystems_cli/commands/parser.py +135 -0
- ecosystems_cli/commands/repos.py +5 -0
- ecosystems_cli/commands/resolve.py +160 -0
- ecosystems_cli/commands/sbom.py +135 -0
- ecosystems_cli/commands/sponsors.py +5 -0
- ecosystems_cli/commands/summary.py +5 -0
- ecosystems_cli/commands/timeline.py +5 -0
- ecosystems_cli/constants.py +92 -0
- ecosystems_cli/exceptions.py +149 -0
- ecosystems_cli/helpers/click_params.py +49 -0
- ecosystems_cli/helpers/flatten_dict.py +15 -0
- ecosystems_cli/helpers/format_value.py +30 -0
- ecosystems_cli/helpers/get_domain.py +68 -0
- ecosystems_cli/helpers/load_api_spec.py +31 -0
- ecosystems_cli/helpers/print_error.py +15 -0
- ecosystems_cli/helpers/print_operations.py +73 -0
- ecosystems_cli/helpers/print_output.py +183 -0
- ecosystems_cli/helpers/purl_parser.py +121 -0
- ecosystems_cli/mcp_server.py +267 -0
- ecosystems_cli/openapi_client.py +461 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""OpenAPI-core based API client for ecosystems CLI.
|
|
2
|
+
|
|
3
|
+
This module provides a client implementation using openapi-core for OpenAPI v3 specs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
from urllib.parse import quote, urljoin
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
import yaml
|
|
14
|
+
from openapi_core import OpenAPI
|
|
15
|
+
|
|
16
|
+
if sys.version_info >= (3, 9):
|
|
17
|
+
from importlib.resources import files
|
|
18
|
+
else:
|
|
19
|
+
from importlib_resources import files
|
|
20
|
+
|
|
21
|
+
from ecosystems_cli import __version__
|
|
22
|
+
from ecosystems_cli.constants import (
|
|
23
|
+
API_BASE_URL_TEMPLATE,
|
|
24
|
+
DEFAULT_TIMEOUT,
|
|
25
|
+
OPENAPI_FILE_EXTENSION,
|
|
26
|
+
)
|
|
27
|
+
from ecosystems_cli.exceptions import (
|
|
28
|
+
APIAuthenticationError,
|
|
29
|
+
APIConnectionError,
|
|
30
|
+
APIHTTPError,
|
|
31
|
+
APINotFoundError,
|
|
32
|
+
APIRateLimitError,
|
|
33
|
+
APIServerError,
|
|
34
|
+
APITimeoutError,
|
|
35
|
+
InvalidAPIError,
|
|
36
|
+
InvalidOperationError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpenAPIClientFactory:
|
|
41
|
+
"""Factory for creating and managing OpenAPI clients using openapi-core."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, specs_dir: Optional[Path] = None):
|
|
44
|
+
"""Initialize the factory.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
specs_dir: Directory containing OpenAPI specs. Only used for testing with custom specs.
|
|
48
|
+
"""
|
|
49
|
+
self.specs_dir = Path(specs_dir) if specs_dir else None
|
|
50
|
+
self._specs: Dict[str, Dict[str, Any]] = {}
|
|
51
|
+
self._openapi: Dict[str, OpenAPI] = {}
|
|
52
|
+
self._operation_map: Dict[str, Dict[str, Any]] = {}
|
|
53
|
+
self._session = requests.Session()
|
|
54
|
+
self._session.headers.update({"User-Agent": f"ecosyste_ms_cli ({__version__})"})
|
|
55
|
+
|
|
56
|
+
def _discover_apis(self) -> List[str]:
|
|
57
|
+
"""Discover all available API specs."""
|
|
58
|
+
if self.specs_dir:
|
|
59
|
+
# Custom specs directory for testing
|
|
60
|
+
if not self.specs_dir.exists():
|
|
61
|
+
return []
|
|
62
|
+
return [f.name.replace(OPENAPI_FILE_EXTENSION, "") for f in self.specs_dir.glob(f"*{OPENAPI_FILE_EXTENSION}")]
|
|
63
|
+
else:
|
|
64
|
+
# Use importlib.resources to discover specs in the package
|
|
65
|
+
try:
|
|
66
|
+
api_files = files("ecosystems_cli.apis").iterdir()
|
|
67
|
+
return [
|
|
68
|
+
f.name.replace(OPENAPI_FILE_EXTENSION, "") for f in api_files if f.name.endswith(OPENAPI_FILE_EXTENSION)
|
|
69
|
+
]
|
|
70
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
def _get_default_base_url(self, api_name: str, spec: Dict[str, Any]) -> str:
|
|
74
|
+
"""Get default base URL from OpenAPI specification."""
|
|
75
|
+
servers = spec.get("servers", [])
|
|
76
|
+
if servers and "url" in servers[0]:
|
|
77
|
+
return servers[0]["url"]
|
|
78
|
+
return API_BASE_URL_TEMPLATE.format(api_name=api_name)
|
|
79
|
+
|
|
80
|
+
def _fix_response_codes(self, spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
81
|
+
"""Convert integer response codes to strings in OpenAPI spec.
|
|
82
|
+
|
|
83
|
+
YAML parses numeric response codes (e.g., 200, 301) as integers,
|
|
84
|
+
but OpenAPI 3.0 spec requires them to be strings.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
spec: The OpenAPI specification dictionary
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Fixed specification dictionary with string response codes
|
|
91
|
+
"""
|
|
92
|
+
if "paths" not in spec:
|
|
93
|
+
return spec
|
|
94
|
+
|
|
95
|
+
for path, methods in spec["paths"].items():
|
|
96
|
+
for method, operation in methods.items():
|
|
97
|
+
if isinstance(operation, dict) and "responses" in operation:
|
|
98
|
+
# Convert integer keys to strings
|
|
99
|
+
responses = operation["responses"]
|
|
100
|
+
if isinstance(responses, dict):
|
|
101
|
+
operation["responses"] = {str(k): v for k, v in responses.items()}
|
|
102
|
+
|
|
103
|
+
return spec
|
|
104
|
+
|
|
105
|
+
def _build_operation_map(self, api_name: str, spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
106
|
+
"""Build operation ID to path/method mapping."""
|
|
107
|
+
operation_map = {}
|
|
108
|
+
paths = spec.get("paths", {})
|
|
109
|
+
|
|
110
|
+
for path, methods in paths.items():
|
|
111
|
+
for method, operation in methods.items():
|
|
112
|
+
if method.lower() in ["get", "post", "put", "delete", "patch"]:
|
|
113
|
+
operation_id = operation.get("operationId")
|
|
114
|
+
if operation_id:
|
|
115
|
+
operation_map[operation_id] = {
|
|
116
|
+
"path": path,
|
|
117
|
+
"method": method.upper(),
|
|
118
|
+
"operation": operation,
|
|
119
|
+
"summary": operation.get("summary", ""),
|
|
120
|
+
"description": operation.get("description", ""),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return operation_map
|
|
124
|
+
|
|
125
|
+
def get_openapi(self, api_name: str) -> OpenAPI:
|
|
126
|
+
"""Get or create an OpenAPI instance for the specified API.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
api_name: Name of the API
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Configured OpenAPI instance
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
InvalidAPIError: If API spec not found
|
|
136
|
+
"""
|
|
137
|
+
if api_name in self._openapi:
|
|
138
|
+
return self._openapi[api_name]
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# Load the OpenAPI spec
|
|
142
|
+
if self.specs_dir:
|
|
143
|
+
# Custom specs directory for testing
|
|
144
|
+
spec_path = self.specs_dir / f"{api_name}{OPENAPI_FILE_EXTENSION}"
|
|
145
|
+
if not spec_path.exists():
|
|
146
|
+
raise InvalidAPIError(api_name)
|
|
147
|
+
with open(spec_path, "r") as f:
|
|
148
|
+
spec_dict = yaml.safe_load(f)
|
|
149
|
+
else:
|
|
150
|
+
# Use importlib.resources for packaged specs
|
|
151
|
+
api_file_name = f"{api_name}{OPENAPI_FILE_EXTENSION}"
|
|
152
|
+
spec_content = files("ecosystems_cli.apis").joinpath(api_file_name).read_text()
|
|
153
|
+
spec_dict = yaml.safe_load(spec_content)
|
|
154
|
+
|
|
155
|
+
# Fix response codes: OpenAPI spec requires string keys, but YAML parses numeric keys as integers
|
|
156
|
+
spec_dict = self._fix_response_codes(spec_dict)
|
|
157
|
+
|
|
158
|
+
# Create OpenAPI instance
|
|
159
|
+
openapi = OpenAPI.from_dict(spec_dict)
|
|
160
|
+
|
|
161
|
+
# Cache spec and openapi instance
|
|
162
|
+
self._specs[api_name] = spec_dict
|
|
163
|
+
self._openapi[api_name] = openapi
|
|
164
|
+
self._operation_map[api_name] = self._build_operation_map(api_name, spec_dict)
|
|
165
|
+
|
|
166
|
+
return openapi
|
|
167
|
+
|
|
168
|
+
except InvalidAPIError:
|
|
169
|
+
# Re-raise InvalidAPIError as-is
|
|
170
|
+
raise
|
|
171
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
172
|
+
raise InvalidAPIError(api_name)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
raise APIConnectionError(f"Failed to load spec for {api_name}: {str(e)}")
|
|
175
|
+
|
|
176
|
+
def call(
|
|
177
|
+
self,
|
|
178
|
+
api_name: str,
|
|
179
|
+
operation_id: str,
|
|
180
|
+
path_params: Optional[Dict[str, Any]] = None,
|
|
181
|
+
query_params: Optional[Dict[str, Any]] = None,
|
|
182
|
+
body: Optional[Dict[str, Any]] = None,
|
|
183
|
+
headers: Optional[Dict[str, str]] = None,
|
|
184
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
185
|
+
mailto: Optional[str] = None,
|
|
186
|
+
base_url: Optional[str] = None,
|
|
187
|
+
) -> Dict[str, Any]:
|
|
188
|
+
"""Call an API operation by operation ID.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
api_name: Name of the API
|
|
192
|
+
operation_id: Operation ID from OpenAPI spec
|
|
193
|
+
path_params: Path parameters
|
|
194
|
+
query_params: Query parameters
|
|
195
|
+
body: Request body
|
|
196
|
+
headers: Additional headers
|
|
197
|
+
timeout: Request timeout
|
|
198
|
+
mailto: Email for polite pool
|
|
199
|
+
base_url: Override base URL
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
API response as dictionary
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
InvalidOperationError: If operation ID not found
|
|
206
|
+
Various API errors based on response
|
|
207
|
+
"""
|
|
208
|
+
# Ensure OpenAPI spec is loaded and operation map is populated
|
|
209
|
+
self.get_openapi(api_name)
|
|
210
|
+
|
|
211
|
+
# Get operation mapping
|
|
212
|
+
if api_name not in self._operation_map:
|
|
213
|
+
raise InvalidAPIError(api_name)
|
|
214
|
+
|
|
215
|
+
if operation_id not in self._operation_map[api_name]:
|
|
216
|
+
raise InvalidOperationError(operation_id)
|
|
217
|
+
|
|
218
|
+
operation_info = self._operation_map[api_name][operation_id]
|
|
219
|
+
|
|
220
|
+
# Determine base URL
|
|
221
|
+
if base_url is None:
|
|
222
|
+
base_url = self._get_default_base_url(api_name, self._specs[api_name])
|
|
223
|
+
|
|
224
|
+
# Build the request URL
|
|
225
|
+
path = operation_info["path"]
|
|
226
|
+
|
|
227
|
+
# Replace path parameters with URL-encoded values
|
|
228
|
+
if path_params:
|
|
229
|
+
for param_name, param_value in path_params.items():
|
|
230
|
+
# URL-encode the parameter value (safe='' means encode everything including '/')
|
|
231
|
+
encoded_value = quote(str(param_value), safe="")
|
|
232
|
+
path = path.replace(f"{{{param_name}}}", encoded_value)
|
|
233
|
+
|
|
234
|
+
# Ensure proper URL joining by adding trailing slash to base_url
|
|
235
|
+
# urljoin replaces the last component if base_url doesn't end with /
|
|
236
|
+
if not base_url.endswith("/"):
|
|
237
|
+
base_url += "/"
|
|
238
|
+
url = urljoin(base_url, path.lstrip("/"))
|
|
239
|
+
|
|
240
|
+
# Build headers
|
|
241
|
+
request_headers = {}
|
|
242
|
+
if mailto:
|
|
243
|
+
request_headers["User-Agent"] = f"ecosyste_ms_cli ({__version__}) mailto:{mailto}"
|
|
244
|
+
if headers:
|
|
245
|
+
request_headers.update(headers)
|
|
246
|
+
|
|
247
|
+
# Build query parameters
|
|
248
|
+
params = {}
|
|
249
|
+
if query_params:
|
|
250
|
+
params.update(query_params)
|
|
251
|
+
if mailto and "mailto" not in params:
|
|
252
|
+
params["mailto"] = mailto
|
|
253
|
+
|
|
254
|
+
# Prepare the request
|
|
255
|
+
method = operation_info["method"]
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Make the HTTP request
|
|
259
|
+
response = self._session.request(
|
|
260
|
+
method=method,
|
|
261
|
+
url=url,
|
|
262
|
+
params=params if params else None,
|
|
263
|
+
json=body if body else None,
|
|
264
|
+
headers=request_headers if request_headers else None,
|
|
265
|
+
timeout=timeout,
|
|
266
|
+
allow_redirects=False, # Handle redirects manually
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Handle redirects
|
|
270
|
+
if response.status_code in (301, 302, 303, 307, 308):
|
|
271
|
+
location = response.headers.get("Location")
|
|
272
|
+
if location:
|
|
273
|
+
return {"location": location, "status_code": response.status_code}
|
|
274
|
+
|
|
275
|
+
# Check for HTTP errors
|
|
276
|
+
self._handle_http_errors(response)
|
|
277
|
+
|
|
278
|
+
# Parse the response
|
|
279
|
+
return self._parse_response(response)
|
|
280
|
+
|
|
281
|
+
except requests.exceptions.Timeout:
|
|
282
|
+
raise APITimeoutError(timeout)
|
|
283
|
+
except requests.exceptions.ConnectionError as e:
|
|
284
|
+
raise APIConnectionError(f"Failed to connect to {url}: {str(e)}")
|
|
285
|
+
except requests.exceptions.RequestException as e:
|
|
286
|
+
raise APIConnectionError(f"Request failed: {str(e)}")
|
|
287
|
+
|
|
288
|
+
def _handle_http_errors(self, response: requests.Response) -> None:
|
|
289
|
+
"""Handle HTTP error responses.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
response: The HTTP response
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
Various API exceptions based on status code
|
|
296
|
+
"""
|
|
297
|
+
if response.status_code < 400:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
error_text = response.text[:500] if response.text else ""
|
|
301
|
+
|
|
302
|
+
if response.status_code == 401:
|
|
303
|
+
raise APIAuthenticationError(f"Unauthorized: {error_text}")
|
|
304
|
+
elif response.status_code == 404:
|
|
305
|
+
raise APINotFoundError(f"Not found: {error_text}")
|
|
306
|
+
elif response.status_code == 429:
|
|
307
|
+
# Parse rate limit headers if available
|
|
308
|
+
headers = response.headers
|
|
309
|
+
rate_limit_info = {}
|
|
310
|
+
|
|
311
|
+
if "X-RateLimit-Limit" in headers:
|
|
312
|
+
try:
|
|
313
|
+
rate_limit_info["limit"] = int(headers["X-RateLimit-Limit"])
|
|
314
|
+
except ValueError:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
if "X-RateLimit-Remaining" in headers:
|
|
318
|
+
try:
|
|
319
|
+
rate_limit_info["remaining"] = int(headers["X-RateLimit-Remaining"])
|
|
320
|
+
except ValueError:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
if "X-RateLimit-Reset" in headers:
|
|
324
|
+
try:
|
|
325
|
+
reset_timestamp = int(headers["X-RateLimit-Reset"])
|
|
326
|
+
reset_datetime = datetime.fromtimestamp(reset_timestamp, tz=timezone.utc)
|
|
327
|
+
rate_limit_info["reset_time"] = reset_datetime.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
328
|
+
except (ValueError, OSError):
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
if "Retry-After" in headers:
|
|
332
|
+
try:
|
|
333
|
+
rate_limit_info["retry_after"] = int(headers["Retry-After"])
|
|
334
|
+
except ValueError:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
raise APIRateLimitError(
|
|
338
|
+
limit=rate_limit_info.get("limit"),
|
|
339
|
+
remaining=rate_limit_info.get("remaining"),
|
|
340
|
+
reset_time=rate_limit_info.get("reset_time"),
|
|
341
|
+
retry_after=rate_limit_info.get("retry_after"),
|
|
342
|
+
)
|
|
343
|
+
elif response.status_code >= 500:
|
|
344
|
+
raise APIServerError(response.status_code, f"Server error: {error_text}")
|
|
345
|
+
else:
|
|
346
|
+
raise APIHTTPError(response.status_code, f"HTTP error: {error_text}")
|
|
347
|
+
|
|
348
|
+
def _parse_response(self, response: requests.Response) -> Any:
|
|
349
|
+
"""Parse HTTP response.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
response: The HTTP response
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
Parsed response data
|
|
356
|
+
"""
|
|
357
|
+
# Handle empty responses
|
|
358
|
+
if not response.content:
|
|
359
|
+
return {}
|
|
360
|
+
|
|
361
|
+
# Try to parse JSON
|
|
362
|
+
try:
|
|
363
|
+
data = response.json()
|
|
364
|
+
return self._convert_dates(data)
|
|
365
|
+
except ValueError:
|
|
366
|
+
# Not JSON, return text
|
|
367
|
+
return {"result": response.text}
|
|
368
|
+
|
|
369
|
+
def _convert_dates(self, obj: Any) -> Any:
|
|
370
|
+
"""Convert date strings to datetime objects recursively."""
|
|
371
|
+
if isinstance(obj, dict):
|
|
372
|
+
return {k: self._convert_dates(v) for k, v in obj.items()}
|
|
373
|
+
elif isinstance(obj, list):
|
|
374
|
+
return [self._convert_dates(item) for item in obj]
|
|
375
|
+
elif isinstance(obj, str):
|
|
376
|
+
# Try to parse as datetime
|
|
377
|
+
for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"]:
|
|
378
|
+
try:
|
|
379
|
+
return datetime.strptime(obj, fmt)
|
|
380
|
+
except ValueError:
|
|
381
|
+
continue
|
|
382
|
+
return obj
|
|
383
|
+
else:
|
|
384
|
+
return obj
|
|
385
|
+
|
|
386
|
+
def list_operations(self, api_name: str) -> List[Dict[str, str]]:
|
|
387
|
+
"""List all operations for an API.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
api_name: Name of the API
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
List of operation information dictionaries
|
|
394
|
+
"""
|
|
395
|
+
# Ensure openapi is loaded to populate operation map
|
|
396
|
+
self.get_openapi(api_name)
|
|
397
|
+
|
|
398
|
+
if api_name not in self._operation_map:
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
operations = []
|
|
402
|
+
for op_id, details in self._operation_map[api_name].items():
|
|
403
|
+
operations.append(
|
|
404
|
+
{
|
|
405
|
+
"id": op_id,
|
|
406
|
+
"method": details["method"],
|
|
407
|
+
"path": details["path"],
|
|
408
|
+
"summary": details["summary"],
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return operations
|
|
413
|
+
|
|
414
|
+
def get_client(
|
|
415
|
+
self,
|
|
416
|
+
api_name: str,
|
|
417
|
+
base_url: Optional[str] = None,
|
|
418
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
419
|
+
mailto: Optional[str] = None,
|
|
420
|
+
) -> "OpenAPIClientFactory":
|
|
421
|
+
"""Get a client instance for the specified API.
|
|
422
|
+
|
|
423
|
+
Note: This returns the factory itself since we don't use a separate client object.
|
|
424
|
+
The factory provides all necessary methods.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
api_name: Name of the API
|
|
428
|
+
base_url: Override base URL
|
|
429
|
+
timeout: Request timeout
|
|
430
|
+
mailto: Email for polite pool
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
The factory instance (for compatibility)
|
|
434
|
+
"""
|
|
435
|
+
# Ensure the API spec is loaded
|
|
436
|
+
self.get_openapi(api_name)
|
|
437
|
+
return self
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# Global factory instance
|
|
441
|
+
_factory = OpenAPIClientFactory()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def get_client(
|
|
445
|
+
api_name: str,
|
|
446
|
+
base_url: Optional[str] = None,
|
|
447
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
448
|
+
mailto: Optional[str] = None,
|
|
449
|
+
) -> OpenAPIClientFactory:
|
|
450
|
+
"""Get API client for specified API.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
api_name: Name of the API
|
|
454
|
+
base_url: Base URL for the API. If not provided, uses default.
|
|
455
|
+
timeout: Timeout in seconds for HTTP requests.
|
|
456
|
+
mailto: Email address for polite pool access.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
Configured client factory instance
|
|
460
|
+
"""
|
|
461
|
+
return _factory.get_client(api_name, base_url=base_url, timeout=timeout, mailto=mailto)
|