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.
Files changed (84) hide show
  1. ecosyste_ms_cli-1.3.2.dist-info/METADATA +133 -0
  2. ecosyste_ms_cli-1.3.2.dist-info/RECORD +84 -0
  3. ecosyste_ms_cli-1.3.2.dist-info/WHEEL +5 -0
  4. ecosyste_ms_cli-1.3.2.dist-info/entry_points.txt +2 -0
  5. ecosyste_ms_cli-1.3.2.dist-info/licenses/LICENSE +21 -0
  6. ecosyste_ms_cli-1.3.2.dist-info/top_level.txt +1 -0
  7. ecosystems_cli/__init__.py +3 -0
  8. ecosystems_cli/__main__.py +4 -0
  9. ecosystems_cli/apis/__init__.py +0 -0
  10. ecosystems_cli/apis/advisories.openapi.yaml +347 -0
  11. ecosystems_cli/apis/archives.openapi.yaml +193 -0
  12. ecosystems_cli/apis/commits.openapi.yaml +391 -0
  13. ecosystems_cli/apis/dependabot.openapi.yaml +887 -0
  14. ecosystems_cli/apis/diff.openapi.yaml +90 -0
  15. ecosystems_cli/apis/docker.openapi.yaml +534 -0
  16. ecosystems_cli/apis/issues.openapi.yaml +839 -0
  17. ecosystems_cli/apis/licenses.openapi.yaml +80 -0
  18. ecosystems_cli/apis/opencollective.openapi.yaml +247 -0
  19. ecosystems_cli/apis/packages.openapi.yaml +2522 -0
  20. ecosystems_cli/apis/parser.openapi.yaml +97 -0
  21. ecosystems_cli/apis/registries.yaml +155 -0
  22. ecosystems_cli/apis/repos.openapi.yaml +1521 -0
  23. ecosystems_cli/apis/resolve.openapi.yaml +130 -0
  24. ecosystems_cli/apis/sbom.openapi.yaml +79 -0
  25. ecosystems_cli/apis/sponsors.openapi.yaml +283 -0
  26. ecosystems_cli/apis/summary.openapi.yaml +239 -0
  27. ecosystems_cli/apis/timeline.openapi.yaml +91 -0
  28. ecosystems_cli/cli.py +213 -0
  29. ecosystems_cli/commands/__init__.py +1 -0
  30. ecosystems_cli/commands/advisories.py +109 -0
  31. ecosystems_cli/commands/archives.py +5 -0
  32. ecosystems_cli/commands/commits.py +5 -0
  33. ecosystems_cli/commands/decorators.py +101 -0
  34. ecosystems_cli/commands/dependabot.py +5 -0
  35. ecosystems_cli/commands/diff.py +144 -0
  36. ecosystems_cli/commands/docker.py +5 -0
  37. ecosystems_cli/commands/execution.py +99 -0
  38. ecosystems_cli/commands/generator.py +127 -0
  39. ecosystems_cli/commands/handlers/__init__.py +45 -0
  40. ecosystems_cli/commands/handlers/advisories.py +73 -0
  41. ecosystems_cli/commands/handlers/archives.py +40 -0
  42. ecosystems_cli/commands/handlers/base.py +38 -0
  43. ecosystems_cli/commands/handlers/commits.py +76 -0
  44. ecosystems_cli/commands/handlers/default.py +40 -0
  45. ecosystems_cli/commands/handlers/dependabot.py +205 -0
  46. ecosystems_cli/commands/handlers/diff.py +72 -0
  47. ecosystems_cli/commands/handlers/docker.py +142 -0
  48. ecosystems_cli/commands/handlers/factory.py +60 -0
  49. ecosystems_cli/commands/handlers/issues.py +87 -0
  50. ecosystems_cli/commands/handlers/licenses.py +52 -0
  51. ecosystems_cli/commands/handlers/opencollective.py +86 -0
  52. ecosystems_cli/commands/handlers/packages.py +103 -0
  53. ecosystems_cli/commands/handlers/parser.py +57 -0
  54. ecosystems_cli/commands/handlers/repos.py +97 -0
  55. ecosystems_cli/commands/handlers/resolve.py +68 -0
  56. ecosystems_cli/commands/handlers/sbom.py +52 -0
  57. ecosystems_cli/commands/handlers/sponsors.py +52 -0
  58. ecosystems_cli/commands/handlers/summary.py +81 -0
  59. ecosystems_cli/commands/handlers/timeline.py +45 -0
  60. ecosystems_cli/commands/issues.py +5 -0
  61. ecosystems_cli/commands/licenses.py +135 -0
  62. ecosystems_cli/commands/mcp.py +54 -0
  63. ecosystems_cli/commands/opencollective.py +5 -0
  64. ecosystems_cli/commands/packages.py +151 -0
  65. ecosystems_cli/commands/parser.py +135 -0
  66. ecosystems_cli/commands/repos.py +5 -0
  67. ecosystems_cli/commands/resolve.py +160 -0
  68. ecosystems_cli/commands/sbom.py +135 -0
  69. ecosystems_cli/commands/sponsors.py +5 -0
  70. ecosystems_cli/commands/summary.py +5 -0
  71. ecosystems_cli/commands/timeline.py +5 -0
  72. ecosystems_cli/constants.py +92 -0
  73. ecosystems_cli/exceptions.py +149 -0
  74. ecosystems_cli/helpers/click_params.py +49 -0
  75. ecosystems_cli/helpers/flatten_dict.py +15 -0
  76. ecosystems_cli/helpers/format_value.py +30 -0
  77. ecosystems_cli/helpers/get_domain.py +68 -0
  78. ecosystems_cli/helpers/load_api_spec.py +31 -0
  79. ecosystems_cli/helpers/print_error.py +15 -0
  80. ecosystems_cli/helpers/print_operations.py +73 -0
  81. ecosystems_cli/helpers/print_output.py +183 -0
  82. ecosystems_cli/helpers/purl_parser.py +121 -0
  83. ecosystems_cli/mcp_server.py +267 -0
  84. 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)