cisco-eox-query 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cisco_eox_query/__init__.py +55 -0
- cisco_eox_query/_base.py +279 -0
- cisco_eox_query/cli.py +272 -0
- cisco_eox_query/constants.py +29 -0
- cisco_eox_query/examples.py +133 -0
- cisco_eox_query/py.typed +0 -0
- cisco_eox_query/v5/__init__.py +26 -0
- cisco_eox_query/v5/client.py +221 -0
- cisco_eox_query/v5/constants.py +43 -0
- cisco_eox_query/v5/models.py +113 -0
- cisco_eox_query-1.0.0.dist-info/METADATA +171 -0
- cisco_eox_query-1.0.0.dist-info/RECORD +15 -0
- cisco_eox_query-1.0.0.dist-info/WHEEL +4 -0
- cisco_eox_query-1.0.0.dist-info/entry_points.txt +2 -0
- cisco_eox_query-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Client for the Cisco End-of-Life (EOX) API.
|
|
2
|
+
|
|
3
|
+
Versioning
|
|
4
|
+
----------
|
|
5
|
+
Library major versions track the EOX API version:
|
|
6
|
+
|
|
7
|
+
- ``cisco-eox-query`` 1.x implements the EOX API **v5**.
|
|
8
|
+
- ``cisco-eox-query`` 2.x will implement the EOX API **v6** (when released).
|
|
9
|
+
|
|
10
|
+
The versioned implementation lives in :mod:`cisco_eox_query.v5`; this module
|
|
11
|
+
re-exports it as the default. Pin an explicit version with::
|
|
12
|
+
|
|
13
|
+
from cisco_eox_query.v5 import EOXClient
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
|
|
20
|
+
from cisco_eox_query._base import PaginationError, RateLimitError, RetryError
|
|
21
|
+
from cisco_eox_query.v5 import (
|
|
22
|
+
EOX_ATTRIBS,
|
|
23
|
+
OS_TYPES,
|
|
24
|
+
EOXAPIError,
|
|
25
|
+
EOXClient,
|
|
26
|
+
EOXErrorInfo,
|
|
27
|
+
EOXRecord,
|
|
28
|
+
EOXResponse,
|
|
29
|
+
MigrationDetails,
|
|
30
|
+
PaginationResponseRecord,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
__version__ = version("cisco-eox-query")
|
|
35
|
+
except PackageNotFoundError:
|
|
36
|
+
__version__ = "1.0.0"
|
|
37
|
+
|
|
38
|
+
SUPPORTED_API_VERSION = 5
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"EOXClient",
|
|
42
|
+
"EOXRecord",
|
|
43
|
+
"EOXResponse",
|
|
44
|
+
"EOXErrorInfo",
|
|
45
|
+
"EOXAPIError",
|
|
46
|
+
"MigrationDetails",
|
|
47
|
+
"PaginationResponseRecord",
|
|
48
|
+
"RetryError",
|
|
49
|
+
"RateLimitError",
|
|
50
|
+
"PaginationError",
|
|
51
|
+
"EOX_ATTRIBS",
|
|
52
|
+
"OS_TYPES",
|
|
53
|
+
"SUPPORTED_API_VERSION",
|
|
54
|
+
"__version__",
|
|
55
|
+
]
|
cisco_eox_query/_base.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Shared transport, retries, and OAuth2 client-credentials authentication.
|
|
2
|
+
|
|
3
|
+
All Cisco Support APIs authenticate with a bearer token obtained from the
|
|
4
|
+
Cisco API Console using the client-credentials grant, and are subject to rate
|
|
5
|
+
limiting. This base class encapsulates token acquisition/caching/refresh,
|
|
6
|
+
request throttling, and retry handling so each versioned API client only
|
|
7
|
+
concerns itself with endpoints and models.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from email.utils import parsedate_to_datetime
|
|
16
|
+
from http import HTTPStatus
|
|
17
|
+
from typing import Any, Self
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from cisco_eox_query.constants import (
|
|
22
|
+
BASE_URL,
|
|
23
|
+
DEFAULT_MAX_RETRIES,
|
|
24
|
+
DEFAULT_MIN_REQUEST_INTERVAL,
|
|
25
|
+
DEFAULT_RETRY_DELAY,
|
|
26
|
+
DEFAULT_TIMEOUT,
|
|
27
|
+
MAX_REQUESTS_PER_DAY,
|
|
28
|
+
MAX_REQUESTS_PER_SECOND,
|
|
29
|
+
TOKEN_URL,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_RETRYABLE_STATUS_CODES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
HTTPStatus.REQUEST_TIMEOUT, # 408
|
|
37
|
+
HTTPStatus.TOO_EARLY, # 425
|
|
38
|
+
HTTPStatus.TOO_MANY_REQUESTS, # 429
|
|
39
|
+
HTTPStatus.INTERNAL_SERVER_ERROR, # 500
|
|
40
|
+
HTTPStatus.BAD_GATEWAY, # 502
|
|
41
|
+
HTTPStatus.SERVICE_UNAVAILABLE, # 503
|
|
42
|
+
HTTPStatus.GATEWAY_TIMEOUT, # 504
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RetryError(Exception):
|
|
48
|
+
"""Raised when an API request fails after exhausting all retries."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RateLimitError(RetryError):
|
|
52
|
+
"""Raised when a rate-limited request fails after exhausting all retries."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PaginationError(RetryError):
|
|
56
|
+
"""Raised when pagination does not terminate within the page limit."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _retry_after_seconds(response: httpx.Response, default: float) -> float:
|
|
60
|
+
value = response.headers.get("Retry-After")
|
|
61
|
+
if not value:
|
|
62
|
+
return default
|
|
63
|
+
try:
|
|
64
|
+
return max(0.0, float(value))
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
retry_at = parsedate_to_datetime(value)
|
|
69
|
+
if retry_at.tzinfo is None:
|
|
70
|
+
retry_at = retry_at.replace(tzinfo=timezone.utc)
|
|
71
|
+
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SupportClient:
|
|
77
|
+
"""Base client providing authenticated, rate-limit-aware httpx transport.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
client_id, client_secret: Cisco API Console credentials used to obtain
|
|
81
|
+
and refresh a bearer token via the client-credentials grant.
|
|
82
|
+
access_token: an already-obtained bearer token (skips token fetch).
|
|
83
|
+
base_url, token_url: service endpoints (defaults are current).
|
|
84
|
+
timeout: per-request httpx timeout.
|
|
85
|
+
max_retries: number of retries for transient failures (HTTP 429/5xx or
|
|
86
|
+
transport errors) before giving up.
|
|
87
|
+
retry_delay: base seconds between retries; the ``Retry-After`` response
|
|
88
|
+
header takes precedence when present.
|
|
89
|
+
min_request_interval: minimum seconds between requests to stay under
|
|
90
|
+
the API's requests-per-second limit (``0`` disables throttling).
|
|
91
|
+
transport: optional httpx transport (used by tests).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
client_id: str | None = None,
|
|
97
|
+
client_secret: str | None = None,
|
|
98
|
+
access_token: str | None = None,
|
|
99
|
+
*,
|
|
100
|
+
base_url: str = BASE_URL,
|
|
101
|
+
token_url: str = TOKEN_URL,
|
|
102
|
+
timeout: httpx.Timeout | float = DEFAULT_TIMEOUT,
|
|
103
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
104
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
105
|
+
min_request_interval: float = DEFAULT_MIN_REQUEST_INTERVAL,
|
|
106
|
+
transport: httpx.BaseTransport | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
if access_token is None and (client_id is None or client_secret is None):
|
|
109
|
+
raise ValueError("provide access_token or client_id with client_secret")
|
|
110
|
+
if max_retries < 0:
|
|
111
|
+
raise ValueError("max_retries must be >= 0")
|
|
112
|
+
self.client_id = client_id
|
|
113
|
+
self.client_secret = client_secret
|
|
114
|
+
self.token_url = token_url
|
|
115
|
+
self.max_retries = max_retries
|
|
116
|
+
self.retry_delay = retry_delay
|
|
117
|
+
self.min_request_interval = min_request_interval
|
|
118
|
+
self._access_token = access_token
|
|
119
|
+
self._token_expiry: float | None = None
|
|
120
|
+
self._next_request_at = 0.0
|
|
121
|
+
self._client = httpx.Client(base_url=base_url, timeout=timeout, transport=transport)
|
|
122
|
+
if self._access_token is None:
|
|
123
|
+
self._fetch_token()
|
|
124
|
+
|
|
125
|
+
def _fetch_token(self) -> None:
|
|
126
|
+
logger.debug("requesting access token from %s", self.token_url)
|
|
127
|
+
response = self._request_with_retry(
|
|
128
|
+
"POST",
|
|
129
|
+
self.token_url,
|
|
130
|
+
data={
|
|
131
|
+
"grant_type": "client_credentials",
|
|
132
|
+
"client_id": self.client_id,
|
|
133
|
+
"client_secret": self.client_secret,
|
|
134
|
+
},
|
|
135
|
+
headers={
|
|
136
|
+
"Accept": "application/json",
|
|
137
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
if response.status_code >= 400:
|
|
141
|
+
logger.error("token request failed with %s: %s", response.status_code, response.text[:300])
|
|
142
|
+
response.raise_for_status()
|
|
143
|
+
try:
|
|
144
|
+
payload = response.json()
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"invalid JSON from token endpoint {self.token_url}: {exc}"
|
|
148
|
+
) from exc
|
|
149
|
+
if "access_token" not in payload:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"token endpoint {self.token_url} returned no access_token "
|
|
152
|
+
f"(HTTP {response.status_code})"
|
|
153
|
+
)
|
|
154
|
+
self._access_token = payload["access_token"]
|
|
155
|
+
expires_in = float(payload.get("expires_in", 3600))
|
|
156
|
+
self._token_expiry = time.monotonic() + expires_in - 300
|
|
157
|
+
logger.info("obtained access token (expires_in=%ss)", expires_in)
|
|
158
|
+
|
|
159
|
+
def _ensure_token(self) -> None:
|
|
160
|
+
if self._access_token is None:
|
|
161
|
+
self._fetch_token()
|
|
162
|
+
elif (
|
|
163
|
+
self.client_id is not None
|
|
164
|
+
and self._token_expiry is not None
|
|
165
|
+
and time.monotonic() >= self._token_expiry
|
|
166
|
+
):
|
|
167
|
+
logger.debug("access token expired, refreshing")
|
|
168
|
+
self._fetch_token()
|
|
169
|
+
|
|
170
|
+
def _throttle(self) -> None:
|
|
171
|
+
if self.min_request_interval <= 0:
|
|
172
|
+
return
|
|
173
|
+
now = time.monotonic()
|
|
174
|
+
wait = self._next_request_at - now
|
|
175
|
+
if wait > 0:
|
|
176
|
+
logger.debug("throttling request for %.2fs", wait)
|
|
177
|
+
time.sleep(wait)
|
|
178
|
+
self._next_request_at = time.monotonic() + self.min_request_interval
|
|
179
|
+
|
|
180
|
+
def _request_with_retry(
|
|
181
|
+
self,
|
|
182
|
+
method: str,
|
|
183
|
+
url: str,
|
|
184
|
+
*,
|
|
185
|
+
params: dict[str, Any] | None = None,
|
|
186
|
+
headers: dict[str, str] | None = None,
|
|
187
|
+
data: dict[str, str] | None = None,
|
|
188
|
+
) -> httpx.Response:
|
|
189
|
+
request = self._client.build_request(method, url, params=params, headers=headers, data=data)
|
|
190
|
+
attempts = self.max_retries + 1
|
|
191
|
+
for attempt in range(attempts):
|
|
192
|
+
self._throttle()
|
|
193
|
+
try:
|
|
194
|
+
response = self._client.send(request)
|
|
195
|
+
except httpx.TransportError as exc:
|
|
196
|
+
if attempt >= self.max_retries:
|
|
197
|
+
raise RetryError(
|
|
198
|
+
f"request to {url} failed after {self.max_retries} retries: {exc}"
|
|
199
|
+
) from exc
|
|
200
|
+
logger.warning(
|
|
201
|
+
"transport error (attempt %d/%d), retrying in %.1fs: %s",
|
|
202
|
+
attempt + 1,
|
|
203
|
+
attempts,
|
|
204
|
+
self.retry_delay,
|
|
205
|
+
exc,
|
|
206
|
+
)
|
|
207
|
+
time.sleep(self.retry_delay)
|
|
208
|
+
continue
|
|
209
|
+
if response.status_code not in _RETRYABLE_STATUS_CODES:
|
|
210
|
+
return response
|
|
211
|
+
if attempt >= self.max_retries:
|
|
212
|
+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
|
213
|
+
raise RateLimitError(
|
|
214
|
+
f"exceeded the Cisco API rate limit after {self.max_retries} retries; "
|
|
215
|
+
f"this is likely the daily limit of {MAX_REQUESTS_PER_DAY} requests/day "
|
|
216
|
+
f"(or the {MAX_REQUESTS_PER_SECOND} requests/second burst limit); "
|
|
217
|
+
"please wait and try again later"
|
|
218
|
+
)
|
|
219
|
+
raise RetryError(
|
|
220
|
+
f"request to {url} returned HTTP {response.status_code} "
|
|
221
|
+
f"after {self.max_retries} retries"
|
|
222
|
+
)
|
|
223
|
+
delay = _retry_after_seconds(response, self.retry_delay)
|
|
224
|
+
logger.warning(
|
|
225
|
+
"HTTP %s (attempt %d/%d), retrying in %.1fs",
|
|
226
|
+
response.status_code,
|
|
227
|
+
attempt + 1,
|
|
228
|
+
attempts,
|
|
229
|
+
delay,
|
|
230
|
+
)
|
|
231
|
+
time.sleep(delay)
|
|
232
|
+
|
|
233
|
+
def _get_json(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
234
|
+
self._ensure_token()
|
|
235
|
+
logger.debug("GET %s params=%s", path, params or {})
|
|
236
|
+
response = self._request_with_retry(
|
|
237
|
+
"GET",
|
|
238
|
+
path,
|
|
239
|
+
params=params or {},
|
|
240
|
+
headers={
|
|
241
|
+
"Authorization": f"Bearer {self._access_token}",
|
|
242
|
+
"Accept": "application/json",
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
if response.status_code == HTTPStatus.UNAUTHORIZED and self.client_id is not None:
|
|
246
|
+
logger.warning(
|
|
247
|
+
"GET %s returned %s, refreshing token and retrying once",
|
|
248
|
+
path,
|
|
249
|
+
response.status_code,
|
|
250
|
+
)
|
|
251
|
+
self._fetch_token()
|
|
252
|
+
response = self._request_with_retry(
|
|
253
|
+
"GET",
|
|
254
|
+
path,
|
|
255
|
+
params=params or {},
|
|
256
|
+
headers={
|
|
257
|
+
"Authorization": f"Bearer {self._access_token}",
|
|
258
|
+
"Accept": "application/json",
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
if response.status_code >= 400:
|
|
262
|
+
logger.error("GET %s failed with %s: %s", path, response.status_code, response.text[:300])
|
|
263
|
+
response.raise_for_status()
|
|
264
|
+
logger.debug("GET %s -> %s (%d bytes)", path, response.status_code, len(response.content))
|
|
265
|
+
try:
|
|
266
|
+
return response.json()
|
|
267
|
+
except ValueError as exc:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"invalid JSON response from {path} (HTTP {response.status_code}): {exc}"
|
|
270
|
+
) from exc
|
|
271
|
+
|
|
272
|
+
def close(self) -> None:
|
|
273
|
+
self._client.close()
|
|
274
|
+
|
|
275
|
+
def __enter__(self) -> Self:
|
|
276
|
+
return self
|
|
277
|
+
|
|
278
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
279
|
+
self.close()
|
cisco_eox_query/cli.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Command-line interface for cisco-eox-query.
|
|
2
|
+
|
|
3
|
+
Installed as the ``eox-query`` console script.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from cisco_eox_query import (
|
|
17
|
+
EOXAPIError,
|
|
18
|
+
EOXClient,
|
|
19
|
+
EOXRecord,
|
|
20
|
+
PaginationError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
RetryError,
|
|
23
|
+
__version__,
|
|
24
|
+
)
|
|
25
|
+
from cisco_eox_query.constants import DEFAULT_MAX_PAGES
|
|
26
|
+
from cisco_eox_query.examples import (
|
|
27
|
+
COMMAND_EXAMPLES,
|
|
28
|
+
ROOT_EXAMPLES,
|
|
29
|
+
format_epilog,
|
|
30
|
+
format_examples,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
|
|
34
|
+
LOG_DATEFMT = "%Y-%m-%d %H:%M:%S"
|
|
35
|
+
|
|
36
|
+
EXIT_RATE_LIMIT = 2
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _ExamplesAction(argparse.Action):
|
|
42
|
+
"""Print usage examples and exit (mirrors the ``--version`` action)."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
option_strings,
|
|
47
|
+
dest,
|
|
48
|
+
nargs=0,
|
|
49
|
+
examples=None,
|
|
50
|
+
title=None,
|
|
51
|
+
**kwargs,
|
|
52
|
+
):
|
|
53
|
+
super().__init__(option_strings, dest, nargs=nargs, **kwargs)
|
|
54
|
+
self.examples = examples or []
|
|
55
|
+
self.title = title
|
|
56
|
+
|
|
57
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
58
|
+
print(format_examples(self.examples, title=self.title))
|
|
59
|
+
parser.exit()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
63
|
+
parser = argparse.ArgumentParser(
|
|
64
|
+
prog="eox-query",
|
|
65
|
+
description="Query the Cisco End-of-Life (EOX) API.",
|
|
66
|
+
epilog=format_epilog(ROOT_EXAMPLES)
|
|
67
|
+
+ "\n\nRun 'eox-query --examples' for more examples.",
|
|
68
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--examples",
|
|
73
|
+
action=_ExamplesAction,
|
|
74
|
+
examples=ROOT_EXAMPLES,
|
|
75
|
+
title="Examples",
|
|
76
|
+
help="show usage examples and exit",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--client-id",
|
|
80
|
+
default=os.environ.get("EOX_CLIENT_ID"),
|
|
81
|
+
help="Cisco API console client ID (or EOX_CLIENT_ID)",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--client-secret",
|
|
85
|
+
default=os.environ.get("EOX_CLIENT_SECRET"),
|
|
86
|
+
help="Cisco API console client secret (or EOX_CLIENT_SECRET)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--access-token",
|
|
90
|
+
default=os.environ.get("EOX_ACCESS_TOKEN"),
|
|
91
|
+
help="pre-obtained bearer token (or EOX_ACCESS_TOKEN)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--timeout", type=float, default=30.0, help="request timeout in seconds")
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"-v",
|
|
96
|
+
"--verbose",
|
|
97
|
+
action="count",
|
|
98
|
+
default=0,
|
|
99
|
+
help="increase log verbosity (-v info, -vv debug)",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
103
|
+
|
|
104
|
+
pid = subparsers.add_parser(
|
|
105
|
+
"pid",
|
|
106
|
+
help="search by product ID(s)",
|
|
107
|
+
description="Search the Cisco EOX API by product ID(s).",
|
|
108
|
+
epilog=format_epilog(COMMAND_EXAMPLES["pid"])
|
|
109
|
+
+ "\n\nRun 'eox-query pid --examples' for more examples.",
|
|
110
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
111
|
+
)
|
|
112
|
+
pid.add_argument(
|
|
113
|
+
"--examples",
|
|
114
|
+
action=_ExamplesAction,
|
|
115
|
+
examples=COMMAND_EXAMPLES["pid"],
|
|
116
|
+
title="Examples for 'pid'",
|
|
117
|
+
help="show usage examples and exit",
|
|
118
|
+
)
|
|
119
|
+
pid.add_argument("product_ids", nargs="+", help="one or more PIDs (wildcards allowed)")
|
|
120
|
+
|
|
121
|
+
serial = subparsers.add_parser(
|
|
122
|
+
"serial",
|
|
123
|
+
help="search by serial number(s)",
|
|
124
|
+
description="Search the Cisco EOX API by serial number(s).",
|
|
125
|
+
epilog=format_epilog(COMMAND_EXAMPLES["serial"])
|
|
126
|
+
+ "\n\nRun 'eox-query serial --examples' for more examples.",
|
|
127
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
128
|
+
)
|
|
129
|
+
serial.add_argument(
|
|
130
|
+
"--examples",
|
|
131
|
+
action=_ExamplesAction,
|
|
132
|
+
examples=COMMAND_EXAMPLES["serial"],
|
|
133
|
+
title="Examples for 'serial'",
|
|
134
|
+
help="show usage examples and exit",
|
|
135
|
+
)
|
|
136
|
+
serial.add_argument("serial_numbers", nargs="+", help="one or more serial numbers")
|
|
137
|
+
|
|
138
|
+
software = subparsers.add_parser(
|
|
139
|
+
"software",
|
|
140
|
+
help="search by software release string(s)",
|
|
141
|
+
description="Search the Cisco EOX API by software release string(s).",
|
|
142
|
+
epilog=format_epilog(COMMAND_EXAMPLES["software"])
|
|
143
|
+
+ "\n\nRun 'eox-query software --examples' for more examples.",
|
|
144
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
145
|
+
)
|
|
146
|
+
software.add_argument(
|
|
147
|
+
"--examples",
|
|
148
|
+
action=_ExamplesAction,
|
|
149
|
+
examples=COMMAND_EXAMPLES["software"],
|
|
150
|
+
title="Examples for 'software'",
|
|
151
|
+
help="show usage examples and exit",
|
|
152
|
+
)
|
|
153
|
+
software.add_argument("releases", nargs="+", help="SWversion[,OSType] tuples, e.g. 12.4(15)T,IOS")
|
|
154
|
+
|
|
155
|
+
dates = subparsers.add_parser(
|
|
156
|
+
"dates",
|
|
157
|
+
help="search by date range",
|
|
158
|
+
description="Search the Cisco EOX API by date range.",
|
|
159
|
+
epilog=format_epilog(COMMAND_EXAMPLES["dates"])
|
|
160
|
+
+ "\n\nRun 'eox-query dates --examples' for more examples.",
|
|
161
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
162
|
+
)
|
|
163
|
+
dates.add_argument(
|
|
164
|
+
"--examples",
|
|
165
|
+
action=_ExamplesAction,
|
|
166
|
+
examples=COMMAND_EXAMPLES["dates"],
|
|
167
|
+
title="Examples for 'dates'",
|
|
168
|
+
help="show usage examples and exit",
|
|
169
|
+
)
|
|
170
|
+
dates.add_argument("start", help="start date YYYY-MM-DD")
|
|
171
|
+
dates.add_argument("end", help="end date YYYY-MM-DD")
|
|
172
|
+
dates.add_argument("--attribs", help="comma-separated eoxAttrib values")
|
|
173
|
+
|
|
174
|
+
return parser
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def setup_logging(level: int = logging.WARNING) -> None:
|
|
178
|
+
logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=LOG_DATEFMT)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _log_level(verbose: int) -> int:
|
|
182
|
+
if verbose >= 2:
|
|
183
|
+
return logging.DEBUG
|
|
184
|
+
if verbose == 1:
|
|
185
|
+
return logging.INFO
|
|
186
|
+
return logging.WARNING
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
190
|
+
args = build_parser().parse_args(argv)
|
|
191
|
+
setup_logging(_log_level(args.verbose))
|
|
192
|
+
try:
|
|
193
|
+
with _build_client(args) as client:
|
|
194
|
+
records = _dispatch(client, args)
|
|
195
|
+
for record in records:
|
|
196
|
+
_print_record(record)
|
|
197
|
+
if records:
|
|
198
|
+
logger.info("found %d EOX record(s)", len(records))
|
|
199
|
+
else:
|
|
200
|
+
logger.warning("No EOX records found.")
|
|
201
|
+
except RateLimitError as exc:
|
|
202
|
+
logger.error("%s", exc)
|
|
203
|
+
return EXIT_RATE_LIMIT
|
|
204
|
+
except (ValueError, httpx.HTTPError, EOXAPIError, RetryError) as exc:
|
|
205
|
+
logger.error("%s", exc)
|
|
206
|
+
return 1
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_client(args: argparse.Namespace) -> EOXClient:
|
|
211
|
+
if args.access_token:
|
|
212
|
+
return EOXClient(access_token=args.access_token, timeout=args.timeout)
|
|
213
|
+
if args.client_id and args.client_secret:
|
|
214
|
+
return EOXClient(client_id=args.client_id, client_secret=args.client_secret, timeout=args.timeout)
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"credentials required: --client-id and --client-secret "
|
|
217
|
+
"(or EOX_CLIENT_ID/EOX_CLIENT_SECRET), or --access-token"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _dispatch(client: EOXClient, args: argparse.Namespace) -> list[EOXRecord]:
|
|
222
|
+
if args.command == "pid":
|
|
223
|
+
return _collect(client.search_by_product_ids, ",".join(args.product_ids))
|
|
224
|
+
if args.command == "serial":
|
|
225
|
+
return _collect(client.search_by_serial_numbers, ",".join(args.serial_numbers))
|
|
226
|
+
if args.command == "software":
|
|
227
|
+
return _collect(client.search_by_software_releases, *args.releases)
|
|
228
|
+
if args.command == "dates":
|
|
229
|
+
attribs = args.attribs.split(",") if args.attribs else None
|
|
230
|
+
return _collect(client.search_by_dates, args.start, args.end, attribs=attribs)
|
|
231
|
+
raise AssertionError(f"unhandled command: {args.command}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _collect(request_fn, *args, **kwargs) -> list[EOXRecord]:
|
|
235
|
+
records = []
|
|
236
|
+
page = 1
|
|
237
|
+
while True:
|
|
238
|
+
if page > DEFAULT_MAX_PAGES:
|
|
239
|
+
raise PaginationError(
|
|
240
|
+
f"pagination did not terminate after {DEFAULT_MAX_PAGES} pages"
|
|
241
|
+
)
|
|
242
|
+
response = request_fn(*args, page=page, **kwargs)
|
|
243
|
+
response.raise_for_error()
|
|
244
|
+
records.extend(response.records)
|
|
245
|
+
last = response.pagination.last_index if response.pagination and response.pagination.last_index else 1
|
|
246
|
+
if page >= last:
|
|
247
|
+
return records
|
|
248
|
+
page += 1
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _print_record(record: EOXRecord) -> None:
|
|
252
|
+
migration = record.migration_details
|
|
253
|
+
print(record.eol_product_id or "<no product id>")
|
|
254
|
+
for label, value in [
|
|
255
|
+
("Product Description", record.product_id_description),
|
|
256
|
+
("Bulletin", record.product_bulletin_number),
|
|
257
|
+
("Bulletin Link", record.link_to_product_bulletin_url),
|
|
258
|
+
("Announcement", record.eox_external_announcement_date),
|
|
259
|
+
("End Of Sale", record.end_of_sale_date),
|
|
260
|
+
("SW Maintenance Ends", record.end_of_sw_maintenance_releases),
|
|
261
|
+
("Security Vulnerability Support Ends", record.end_of_security_vul_support_date),
|
|
262
|
+
("Routine Failure Analysis Ends", record.end_of_routine_failure_analysis_date),
|
|
263
|
+
("Service Contract Renewal", record.end_of_service_contract_renewal),
|
|
264
|
+
("Last Date of Support", record.last_date_of_support),
|
|
265
|
+
("Service Attach Ends", record.end_of_svc_attach_date),
|
|
266
|
+
("Migration Product", migration.migration_information if migration else None),
|
|
267
|
+
("Migration Product Info URL", migration.migration_product_info_url if migration else None),
|
|
268
|
+
("Migration Product ID", migration.migration_product_id if migration else None),
|
|
269
|
+
("Migration Strategy", migration.migration_strategy if migration else None),
|
|
270
|
+
]:
|
|
271
|
+
if value is not None:
|
|
272
|
+
print(f" {label:<30} {value}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Constants shared across Cisco EOX API client versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
BASE_URL = "https://apix.cisco.com"
|
|
8
|
+
TOKEN_URL = "https://id.cisco.com/oauth2/default/v1/token"
|
|
9
|
+
DEFAULT_TIMEOUT = httpx.Timeout(30.0)
|
|
10
|
+
|
|
11
|
+
MAX_REQUESTS_PER_SECOND = 5
|
|
12
|
+
MAX_REQUESTS_PER_DAY = 5000
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_RETRIES = 3
|
|
15
|
+
DEFAULT_RETRY_DELAY = 5.0
|
|
16
|
+
DEFAULT_MIN_REQUEST_INTERVAL = 0.25
|
|
17
|
+
DEFAULT_MAX_PAGES = 1000
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BASE_URL",
|
|
21
|
+
"TOKEN_URL",
|
|
22
|
+
"DEFAULT_TIMEOUT",
|
|
23
|
+
"MAX_REQUESTS_PER_SECOND",
|
|
24
|
+
"MAX_REQUESTS_PER_DAY",
|
|
25
|
+
"DEFAULT_MAX_RETRIES",
|
|
26
|
+
"DEFAULT_RETRY_DELAY",
|
|
27
|
+
"DEFAULT_MIN_REQUEST_INTERVAL",
|
|
28
|
+
"DEFAULT_MAX_PAGES",
|
|
29
|
+
]
|