awsnav 0.1.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.
- awsnav/__init__.py +3 -0
- awsnav/__main__.py +4 -0
- awsnav/catalog.py +623 -0
- awsnav/cli.py +138 -0
- awsnav/formatting.py +141 -0
- awsnav/scanner.py +745 -0
- awsnav/session.py +179 -0
- awsnav/ui/__init__.py +0 -0
- awsnav/ui/actions.py +66 -0
- awsnav/ui/app.py +202 -0
- awsnav/ui/bedrock.py +169 -0
- awsnav/ui/common.py +481 -0
- awsnav/ui/s3.py +177 -0
- awsnav/ui/scan.py +364 -0
- awsnav/ui/screens.py +645 -0
- awsnav-0.1.0.dist-info/METADATA +307 -0
- awsnav-0.1.0.dist-info/RECORD +21 -0
- awsnav-0.1.0.dist-info/WHEEL +5 -0
- awsnav-0.1.0.dist-info/entry_points.txt +2 -0
- awsnav-0.1.0.dist-info/licenses/LICENSE +21 -0
- awsnav-0.1.0.dist-info/top_level.txt +1 -0
awsnav/__init__.py
ADDED
awsnav/__main__.py
ADDED
awsnav/catalog.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Discovery of AWS services and operations from botocore's bundled API models.
|
|
2
|
+
|
|
3
|
+
Nothing here is hard-coded per service: every service that the installed
|
|
4
|
+
botocore knows about (EC2, S3, Bedrock, AgentCore, and anything released
|
|
5
|
+
later) is listed, and its browsable "resource" operations are derived from
|
|
6
|
+
the API model. Upgrading boto3/botocore is enough to pick up new services.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
import botocore
|
|
21
|
+
import botocore.session
|
|
22
|
+
from botocore.model import OperationModel, ServiceModel, Shape
|
|
23
|
+
|
|
24
|
+
# Operations whose name starts with one of these are treated as read-only.
|
|
25
|
+
READ_ONLY_PREFIXES = (
|
|
26
|
+
"List",
|
|
27
|
+
"Describe",
|
|
28
|
+
"Get",
|
|
29
|
+
"Search",
|
|
30
|
+
"Lookup",
|
|
31
|
+
"Head",
|
|
32
|
+
"BatchGet",
|
|
33
|
+
"Query",
|
|
34
|
+
"Scan",
|
|
35
|
+
"Filter",
|
|
36
|
+
"BatchDescribe",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Operations that may be offered as top-level "resource types" of a service.
|
|
40
|
+
ROOT_PREFIXES = ("List", "Describe", "Get")
|
|
41
|
+
|
|
42
|
+
# Input/output members that only exist for paging.
|
|
43
|
+
PAGINATION_NAMES = {
|
|
44
|
+
"nexttoken",
|
|
45
|
+
"marker",
|
|
46
|
+
"nextmarker",
|
|
47
|
+
"maxresults",
|
|
48
|
+
"maxitems",
|
|
49
|
+
"maxrecords",
|
|
50
|
+
"limit",
|
|
51
|
+
"continuationtoken",
|
|
52
|
+
"nextcontinuationtoken",
|
|
53
|
+
"pagesize",
|
|
54
|
+
"pagetoken",
|
|
55
|
+
"nextpagetoken",
|
|
56
|
+
"paginationtoken",
|
|
57
|
+
"startingtoken",
|
|
58
|
+
"istruncated",
|
|
59
|
+
"truncated",
|
|
60
|
+
"maxkeys",
|
|
61
|
+
"keymarker",
|
|
62
|
+
"nextkeymarker",
|
|
63
|
+
"startafter",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Parameter names that are too generic to auto-fill when they are optional.
|
|
67
|
+
GENERIC_PARAM_NAMES = {
|
|
68
|
+
"name",
|
|
69
|
+
"id",
|
|
70
|
+
"arn",
|
|
71
|
+
"type",
|
|
72
|
+
"status",
|
|
73
|
+
"state",
|
|
74
|
+
"description",
|
|
75
|
+
"filter",
|
|
76
|
+
"filters",
|
|
77
|
+
"owner",
|
|
78
|
+
"region",
|
|
79
|
+
"version",
|
|
80
|
+
"tags",
|
|
81
|
+
"key",
|
|
82
|
+
"value",
|
|
83
|
+
"prefix",
|
|
84
|
+
"include",
|
|
85
|
+
"scope",
|
|
86
|
+
"mode",
|
|
87
|
+
"sortby",
|
|
88
|
+
"sortorder",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Services shown at the top of the service list.
|
|
92
|
+
POPULAR_SERVICES = [
|
|
93
|
+
"ec2",
|
|
94
|
+
"s3",
|
|
95
|
+
"iam",
|
|
96
|
+
"lambda",
|
|
97
|
+
"bedrock",
|
|
98
|
+
"bedrock-runtime",
|
|
99
|
+
"bedrock-agent",
|
|
100
|
+
"bedrock-agentcore-control",
|
|
101
|
+
"rds",
|
|
102
|
+
"dynamodb",
|
|
103
|
+
"ecs",
|
|
104
|
+
"eks",
|
|
105
|
+
"ecr",
|
|
106
|
+
"cloudformation",
|
|
107
|
+
"cloudwatch",
|
|
108
|
+
"logs",
|
|
109
|
+
"sqs",
|
|
110
|
+
"sns",
|
|
111
|
+
"route53",
|
|
112
|
+
"cloudfront",
|
|
113
|
+
"elbv2",
|
|
114
|
+
"autoscaling",
|
|
115
|
+
"secretsmanager",
|
|
116
|
+
"ssm",
|
|
117
|
+
"kms",
|
|
118
|
+
"sagemaker",
|
|
119
|
+
"apigateway",
|
|
120
|
+
"apigatewayv2",
|
|
121
|
+
"stepfunctions",
|
|
122
|
+
"events",
|
|
123
|
+
"sts",
|
|
124
|
+
"organizations",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
# Default parameters for root operations that would otherwise return
|
|
128
|
+
# enormous public data sets (e.g. every public AMI in the region).
|
|
129
|
+
CURATED_DEFAULT_PARAMS: dict[tuple[str, str], dict[str, Any]] = {
|
|
130
|
+
("ec2", "DescribeImages"): {"Owners": ["self"]},
|
|
131
|
+
("ec2", "DescribeSnapshots"): {"OwnerIds": ["self"]},
|
|
132
|
+
("ec2", "DescribeFpgaImages"): {"Owners": ["self"]},
|
|
133
|
+
("ssm", "ListDocuments"): {"Filters": [{"Key": "Owner", "Values": ["Self"]}]},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# JMESPath expressions that pick the interesting items out of a response
|
|
137
|
+
# when the default "first list in the output" rule is not good enough.
|
|
138
|
+
CURATED_ITEM_PATHS: dict[tuple[str, str], str] = {
|
|
139
|
+
("ec2", "DescribeInstances"): "Reservations[].Instances[]",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
143
|
+
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
|
144
|
+
_ITEM_KEY_SUFFIXES = (
|
|
145
|
+
"Summaries",
|
|
146
|
+
"Summary",
|
|
147
|
+
"List",
|
|
148
|
+
"Details",
|
|
149
|
+
"Detail",
|
|
150
|
+
"Infos",
|
|
151
|
+
"Info",
|
|
152
|
+
"Items",
|
|
153
|
+
"Arns",
|
|
154
|
+
"Ids",
|
|
155
|
+
"Names",
|
|
156
|
+
"Urls",
|
|
157
|
+
"Set",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def split_camel(name: str) -> str:
|
|
162
|
+
"""``DBInstances`` -> ``DB Instances``."""
|
|
163
|
+
return _CAMEL_RE.sub(" ", name)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def strip_html(text: str | None) -> str:
|
|
167
|
+
if not text:
|
|
168
|
+
return ""
|
|
169
|
+
text = _HTML_TAG_RE.sub("", text)
|
|
170
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def operation_prefix(op_name: str) -> str:
|
|
174
|
+
for prefix in sorted(READ_ONLY_PREFIXES, key=len, reverse=True):
|
|
175
|
+
if op_name.startswith(prefix):
|
|
176
|
+
return prefix
|
|
177
|
+
m = re.match(r"[A-Z][a-z]*", op_name)
|
|
178
|
+
return m.group(0) if m else ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def operation_noun(op_name: str) -> str:
|
|
182
|
+
"""``ListFoundationModels`` -> ``FoundationModels``."""
|
|
183
|
+
prefix = operation_prefix(op_name)
|
|
184
|
+
noun = op_name[len(prefix) :]
|
|
185
|
+
return noun or op_name
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def resource_label(op_name: str) -> str:
|
|
189
|
+
"""Human label for a resource-listing operation."""
|
|
190
|
+
return split_camel(operation_noun(op_name))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def is_read_only(op_name: str) -> bool:
|
|
194
|
+
return op_name.startswith(READ_ONLY_PREFIXES)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def singular(word: str) -> str:
|
|
198
|
+
if word.endswith("ies") and len(word) > 3:
|
|
199
|
+
return word[:-3] + "y"
|
|
200
|
+
if word.endswith(("sses", "xes", "ches", "shes")):
|
|
201
|
+
return word[:-2]
|
|
202
|
+
if word.endswith("s") and not word.endswith("ss") and len(word) > 1:
|
|
203
|
+
return word[:-1]
|
|
204
|
+
return word
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def singular_candidates(word: str) -> set[str]:
|
|
208
|
+
out = {word}
|
|
209
|
+
if word.endswith("ies") and len(word) > 3:
|
|
210
|
+
out.add(word[:-3] + "y")
|
|
211
|
+
if word.endswith("es") and len(word) > 2:
|
|
212
|
+
out.add(word[:-2])
|
|
213
|
+
if word.endswith("s") and not word.endswith("ss") and len(word) > 1:
|
|
214
|
+
out.add(word[:-1])
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_scalar(value: Any) -> bool:
|
|
219
|
+
return isinstance(value, (str, int, float, bool)) and not isinstance(value, bytes)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class ItemContext:
|
|
224
|
+
"""Where an item came from, used to find drill-down operations."""
|
|
225
|
+
|
|
226
|
+
service: str
|
|
227
|
+
operation: str
|
|
228
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
229
|
+
items_key: str | None = None
|
|
230
|
+
# Region the item lives in; ``None`` means "whatever region is current".
|
|
231
|
+
region: str | None = None
|
|
232
|
+
|
|
233
|
+
def nouns(self) -> set[str]:
|
|
234
|
+
"""Lower-case nouns that describe the item (``bucket``, ``model``...)."""
|
|
235
|
+
nouns: set[str] = set()
|
|
236
|
+
|
|
237
|
+
def add(word: str) -> None:
|
|
238
|
+
if not word:
|
|
239
|
+
return
|
|
240
|
+
for cand in singular_candidates(word):
|
|
241
|
+
nouns.add(cand.lower())
|
|
242
|
+
parts = split_camel(word).split()
|
|
243
|
+
if len(parts) > 1:
|
|
244
|
+
for cand in singular_candidates(parts[-1]):
|
|
245
|
+
nouns.add(cand.lower())
|
|
246
|
+
|
|
247
|
+
add(operation_noun(self.operation))
|
|
248
|
+
if self.items_key:
|
|
249
|
+
last = re.split(r"[.\[\]]+", self.items_key.strip("[]."))
|
|
250
|
+
last_word = [p for p in last if p]
|
|
251
|
+
if last_word:
|
|
252
|
+
word = last_word[-1]
|
|
253
|
+
word = word[0].upper() + word[1:]
|
|
254
|
+
add(word)
|
|
255
|
+
for suffix in _ITEM_KEY_SUFFIXES:
|
|
256
|
+
if word.endswith(suffix) and len(word) > len(suffix):
|
|
257
|
+
add(word[: -len(suffix)])
|
|
258
|
+
break
|
|
259
|
+
return nouns
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class RelatedOperation:
|
|
264
|
+
operation: str
|
|
265
|
+
params: dict[str, Any]
|
|
266
|
+
via_required: bool
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def label(self) -> str:
|
|
270
|
+
args = ", ".join(f"{k}={_short(v)}" for k, v in self.params.items())
|
|
271
|
+
return f"{self.operation}({args})"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _short(value: Any, limit: int = 40) -> str:
|
|
275
|
+
text = json.dumps(value, default=str) if not isinstance(value, str) else value
|
|
276
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
_SUFFIX_PREFERENCES = {
|
|
280
|
+
"identifier": ["id", "arn", "name", "identifier"],
|
|
281
|
+
"arn": ["arn"],
|
|
282
|
+
"id": ["id"],
|
|
283
|
+
"name": ["name"],
|
|
284
|
+
"url": ["url"],
|
|
285
|
+
"": ["name", "arn", "id", "identifier", "url"],
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _scalar_map(item: dict[str, Any]) -> dict[str, tuple[str, Any]]:
|
|
290
|
+
return {k.lower(): (k, v) for k, v in item.items() if _is_scalar(v)}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def match_param_value(param: str, item_map: dict[str, tuple[str, Any]], nouns: set[str]) -> Any:
|
|
294
|
+
"""Find a value in ``item_map`` for an API parameter called ``param``."""
|
|
295
|
+
pl = param.lower()
|
|
296
|
+
if pl in item_map:
|
|
297
|
+
return item_map[pl][1]
|
|
298
|
+
base, suffix = pl, ""
|
|
299
|
+
for suf in ("identifier", "arn", "id", "name", "url"):
|
|
300
|
+
if pl.endswith(suf) and len(pl) > len(suf):
|
|
301
|
+
base, suffix = pl[: -len(suf)], suf
|
|
302
|
+
break
|
|
303
|
+
prefs = _SUFFIX_PREFERENCES[suffix]
|
|
304
|
+
for suf in prefs:
|
|
305
|
+
if base + suf in item_map:
|
|
306
|
+
return item_map[base + suf][1]
|
|
307
|
+
if base in nouns:
|
|
308
|
+
for suf in prefs:
|
|
309
|
+
if suf in item_map:
|
|
310
|
+
return item_map[suf][1]
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _fill_param(name: str, shape: Shape, item_map, nouns) -> Any:
|
|
315
|
+
if shape.type_name in ("string", "integer", "long", "boolean"):
|
|
316
|
+
value = match_param_value(name, item_map, nouns)
|
|
317
|
+
if value is None:
|
|
318
|
+
return None
|
|
319
|
+
if shape.type_name == "string" and not isinstance(value, str):
|
|
320
|
+
return None
|
|
321
|
+
if shape.type_name in ("integer", "long") and (isinstance(value, bool) or not isinstance(value, int)):
|
|
322
|
+
return None
|
|
323
|
+
if shape.type_name == "boolean" and not isinstance(value, bool):
|
|
324
|
+
return None
|
|
325
|
+
return value
|
|
326
|
+
if shape.type_name == "list" and shape.member.type_name == "string":
|
|
327
|
+
for cand in singular_candidates(name):
|
|
328
|
+
if cand == name:
|
|
329
|
+
continue
|
|
330
|
+
value = match_param_value(cand, item_map, nouns)
|
|
331
|
+
if isinstance(value, str):
|
|
332
|
+
return [value]
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class Catalog:
|
|
337
|
+
"""Cached access to botocore service models."""
|
|
338
|
+
|
|
339
|
+
def __init__(self, botocore_session: botocore.session.Session):
|
|
340
|
+
self._bs = botocore_session
|
|
341
|
+
self._models: dict[str, ServiceModel] = {}
|
|
342
|
+
self._lock = threading.Lock()
|
|
343
|
+
self._full_names: dict[str, str] = {}
|
|
344
|
+
|
|
345
|
+
# -- services -----------------------------------------------------
|
|
346
|
+
def services(self) -> list[str]:
|
|
347
|
+
return sorted(self._bs.get_available_services())
|
|
348
|
+
|
|
349
|
+
def ordered_services(self) -> list[str]:
|
|
350
|
+
available = set(self.services())
|
|
351
|
+
popular = [s for s in POPULAR_SERVICES if s in available]
|
|
352
|
+
rest = sorted(available - set(popular))
|
|
353
|
+
return popular + rest
|
|
354
|
+
|
|
355
|
+
def is_popular(self, service: str) -> bool:
|
|
356
|
+
return service in POPULAR_SERVICES
|
|
357
|
+
|
|
358
|
+
def model(self, service: str) -> ServiceModel:
|
|
359
|
+
with self._lock:
|
|
360
|
+
model = self._models.get(service)
|
|
361
|
+
if model is None:
|
|
362
|
+
model = self._bs.get_service_model(service)
|
|
363
|
+
self._models[service] = model
|
|
364
|
+
return model
|
|
365
|
+
|
|
366
|
+
def full_name(self, service: str) -> str:
|
|
367
|
+
if service in self._full_names:
|
|
368
|
+
return self._full_names[service]
|
|
369
|
+
name = self.model(service).metadata.get("serviceFullName", service)
|
|
370
|
+
self._full_names[service] = name
|
|
371
|
+
return name
|
|
372
|
+
|
|
373
|
+
def cached_full_names(self) -> dict[str, str]:
|
|
374
|
+
return dict(self._full_names)
|
|
375
|
+
|
|
376
|
+
def load_full_names(self, cache_dir: Path | None = None) -> dict[str, str]:
|
|
377
|
+
"""Load every service's full name; slow the first time, then cached on disk."""
|
|
378
|
+
cache_dir = cache_dir or Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "awsnav"
|
|
379
|
+
cache_file = cache_dir / f"service-names-{botocore.__version__}.json"
|
|
380
|
+
try:
|
|
381
|
+
names = json.loads(cache_file.read_text())
|
|
382
|
+
if isinstance(names, dict):
|
|
383
|
+
self._full_names.update(names)
|
|
384
|
+
except (OSError, ValueError):
|
|
385
|
+
pass
|
|
386
|
+
missing = [s for s in self.services() if s not in self._full_names]
|
|
387
|
+
if missing:
|
|
388
|
+
for service in missing:
|
|
389
|
+
try:
|
|
390
|
+
self.full_name(service)
|
|
391
|
+
except Exception: # noqa: BLE001 - a broken model must not break the list
|
|
392
|
+
self._full_names[service] = service
|
|
393
|
+
try:
|
|
394
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
cache_file.write_text(json.dumps(self._full_names))
|
|
396
|
+
except OSError:
|
|
397
|
+
pass
|
|
398
|
+
return dict(self._full_names)
|
|
399
|
+
|
|
400
|
+
# -- operations ---------------------------------------------------
|
|
401
|
+
def operation(self, service: str, op_name: str) -> OperationModel:
|
|
402
|
+
return self.model(service).operation_model(op_name)
|
|
403
|
+
|
|
404
|
+
def all_operations(self, service: str) -> list[str]:
|
|
405
|
+
return sorted(self.model(service).operation_names)
|
|
406
|
+
|
|
407
|
+
def required_params(self, service: str, op_name: str) -> list[str]:
|
|
408
|
+
shape = self.operation(service, op_name).input_shape
|
|
409
|
+
return list(shape.required_members) if shape is not None else []
|
|
410
|
+
|
|
411
|
+
def is_root_operation(self, service: str, op_name: str) -> bool:
|
|
412
|
+
if not op_name.startswith(ROOT_PREFIXES):
|
|
413
|
+
return False
|
|
414
|
+
op = self.operation(service, op_name)
|
|
415
|
+
if op.deprecated or op.has_streaming_output or op.has_event_stream_output:
|
|
416
|
+
return False
|
|
417
|
+
return not self.required_params(service, op_name)
|
|
418
|
+
|
|
419
|
+
def root_operations(self, service: str) -> list[str]:
|
|
420
|
+
ops = [o for o in self.all_operations(service) if self.is_root_operation(service, o)]
|
|
421
|
+
order = {"List": 0, "Describe": 1, "Get": 2}
|
|
422
|
+
return sorted(ops, key=lambda o: (order.get(operation_prefix(o), 9), operation_noun(o)))
|
|
423
|
+
|
|
424
|
+
def paginator_config(self, service: str, op_name: str) -> dict[str, Any] | None:
|
|
425
|
+
try:
|
|
426
|
+
return self._bs.get_paginator_model(service).get_paginator(op_name)
|
|
427
|
+
except Exception: # noqa: BLE001 - no paginators for this service/op
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
def default_params(self, service: str, op_name: str) -> dict[str, Any]:
|
|
431
|
+
return json.loads(json.dumps(CURATED_DEFAULT_PARAMS.get((service, op_name), {})))
|
|
432
|
+
|
|
433
|
+
def documentation(self, service: str, op_name: str) -> str:
|
|
434
|
+
return strip_html(self.operation(service, op_name).documentation)
|
|
435
|
+
|
|
436
|
+
def describe_params(self, service: str, op_name: str) -> list[tuple[str, str, bool, str]]:
|
|
437
|
+
"""``(name, type, required, doc)`` for each input member."""
|
|
438
|
+
shape = self.operation(service, op_name).input_shape
|
|
439
|
+
if shape is None:
|
|
440
|
+
return []
|
|
441
|
+
required = set(shape.required_members)
|
|
442
|
+
out = []
|
|
443
|
+
for name, member in shape.members.items():
|
|
444
|
+
out.append((name, _type_label(member), name in required, strip_html(member.documentation)))
|
|
445
|
+
out.sort(key=lambda r: (not r[2], r[0]))
|
|
446
|
+
return out
|
|
447
|
+
|
|
448
|
+
def params_template(self, service: str, op_name: str, prefill: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
449
|
+
"""Parameters skeleton: required members (prefilled where possible)."""
|
|
450
|
+
prefill = prefill or {}
|
|
451
|
+
shape = self.operation(service, op_name).input_shape
|
|
452
|
+
if shape is None:
|
|
453
|
+
return {}
|
|
454
|
+
out: dict[str, Any] = {}
|
|
455
|
+
for name in shape.required_members:
|
|
456
|
+
out[name] = prefill.get(name, _placeholder(shape.members[name]))
|
|
457
|
+
for name, value in prefill.items():
|
|
458
|
+
if name in shape.members and name not in out:
|
|
459
|
+
out[name] = value
|
|
460
|
+
return out
|
|
461
|
+
|
|
462
|
+
# -- results ------------------------------------------------------
|
|
463
|
+
def extract_items(self, service: str, op_name: str, raw: dict[str, Any]) -> tuple[list[Any] | None, str | None]:
|
|
464
|
+
"""Return the list of resources in a response, or ``(None, None)`` for a single object."""
|
|
465
|
+
import jmespath
|
|
466
|
+
|
|
467
|
+
curated = CURATED_ITEM_PATHS.get((service, op_name))
|
|
468
|
+
if curated:
|
|
469
|
+
return list(jmespath.search(curated, raw) or []), curated
|
|
470
|
+
|
|
471
|
+
keys: list[str] = []
|
|
472
|
+
pag = self.paginator_config(service, op_name)
|
|
473
|
+
if pag and pag.get("result_key"):
|
|
474
|
+
rk = pag["result_key"]
|
|
475
|
+
keys = list(rk) if isinstance(rk, list) else [rk]
|
|
476
|
+
else:
|
|
477
|
+
out = self.operation(service, op_name).output_shape
|
|
478
|
+
members = {
|
|
479
|
+
n: s
|
|
480
|
+
for n, s in (out.members.items() if out is not None else [])
|
|
481
|
+
if n.lower() not in PAGINATION_NAMES
|
|
482
|
+
}
|
|
483
|
+
lists = [n for n, s in members.items() if s.type_name == "list"]
|
|
484
|
+
if op_name.startswith("Get") and not (len(members) == 1 and lists):
|
|
485
|
+
lists = []
|
|
486
|
+
keys = lists
|
|
487
|
+
if not keys and len(members) == 1:
|
|
488
|
+
(name, shape), = members.items()
|
|
489
|
+
if shape.type_name == "structure":
|
|
490
|
+
keys = [f"{name}.{n2}" for n2, s2 in shape.members.items() if s2.type_name == "list"]
|
|
491
|
+
for key in keys:
|
|
492
|
+
value = jmespath.search(key, raw)
|
|
493
|
+
if isinstance(value, list) and value:
|
|
494
|
+
return value, key
|
|
495
|
+
if keys:
|
|
496
|
+
value = jmespath.search(keys[0], raw)
|
|
497
|
+
return (value if isinstance(value, list) else []), keys[0]
|
|
498
|
+
return None, None
|
|
499
|
+
|
|
500
|
+
# -- drill down ---------------------------------------------------
|
|
501
|
+
def related_operations(self, ctx: ItemContext, item: dict[str, Any], include_writes: bool = False) -> list[RelatedOperation]:
|
|
502
|
+
"""Operations that can be called using values from ``item`` (and its parent context)."""
|
|
503
|
+
if not isinstance(item, dict):
|
|
504
|
+
return []
|
|
505
|
+
nouns = ctx.nouns()
|
|
506
|
+
item_map = _scalar_map(item)
|
|
507
|
+
ctx_map = _scalar_map(ctx.params)
|
|
508
|
+
results: list[RelatedOperation] = []
|
|
509
|
+
for op_name in self.all_operations(ctx.service):
|
|
510
|
+
if op_name == ctx.operation:
|
|
511
|
+
continue
|
|
512
|
+
if not include_writes and not is_read_only(op_name):
|
|
513
|
+
continue
|
|
514
|
+
op = self.operation(ctx.service, op_name)
|
|
515
|
+
if op.deprecated or op.has_streaming_output or op.has_event_stream_output:
|
|
516
|
+
continue
|
|
517
|
+
shape = op.input_shape
|
|
518
|
+
if shape is None:
|
|
519
|
+
continue
|
|
520
|
+
required = list(shape.required_members)
|
|
521
|
+
params: dict[str, Any] = {}
|
|
522
|
+
from_item = False
|
|
523
|
+
ok = True
|
|
524
|
+
for name in required:
|
|
525
|
+
member = shape.members[name]
|
|
526
|
+
value = _fill_param(name, member, item_map, nouns)
|
|
527
|
+
if value is not None:
|
|
528
|
+
from_item = True
|
|
529
|
+
else:
|
|
530
|
+
if name.lower() in ctx_map:
|
|
531
|
+
value = ctx_map[name.lower()][1]
|
|
532
|
+
else:
|
|
533
|
+
value = ctx.params.get(name)
|
|
534
|
+
if value is None:
|
|
535
|
+
ok = False
|
|
536
|
+
break
|
|
537
|
+
params[name] = value
|
|
538
|
+
if not ok:
|
|
539
|
+
continue
|
|
540
|
+
via_required = from_item
|
|
541
|
+
if not from_item:
|
|
542
|
+
for name, member in shape.members.items():
|
|
543
|
+
if name in params or name.lower() in GENERIC_PARAM_NAMES or name.lower() in PAGINATION_NAMES:
|
|
544
|
+
continue
|
|
545
|
+
value = _fill_param(name, member, item_map, nouns)
|
|
546
|
+
if value is not None:
|
|
547
|
+
params[name] = value
|
|
548
|
+
from_item = True
|
|
549
|
+
if from_item:
|
|
550
|
+
results.append(RelatedOperation(op_name, params, via_required))
|
|
551
|
+
results.sort(key=lambda r: (not r.via_required, operation_prefix(r.operation) != "Get", r.operation))
|
|
552
|
+
return results
|
|
553
|
+
|
|
554
|
+
def prefill_for(self, service: str, op_name: str, ctx: ItemContext | None, item: dict[str, Any] | None) -> dict[str, Any]:
|
|
555
|
+
"""Best-effort parameter values for any operation from an item."""
|
|
556
|
+
shape = self.operation(service, op_name).input_shape
|
|
557
|
+
if shape is None:
|
|
558
|
+
return {}
|
|
559
|
+
out: dict[str, Any] = {}
|
|
560
|
+
nouns = ctx.nouns() if ctx else set()
|
|
561
|
+
item_map = _scalar_map(item) if isinstance(item, dict) else {}
|
|
562
|
+
ctx_params = ctx.params if ctx else {}
|
|
563
|
+
for name, member in shape.members.items():
|
|
564
|
+
value = _fill_param(name, member, item_map, nouns) if item_map else None
|
|
565
|
+
if value is None and name in ctx_params:
|
|
566
|
+
value = ctx_params[name]
|
|
567
|
+
if value is not None and (name in shape.required_members or name.lower() not in GENERIC_PARAM_NAMES):
|
|
568
|
+
out[name] = value
|
|
569
|
+
return out
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def wrap_scalar_items(items: Iterable[Any], items_key: str | None) -> list[Any]:
|
|
573
|
+
"""Turn ``["a", "b"]`` from ``TableNames`` into ``[{"TableName": "a"}, ...]``."""
|
|
574
|
+
items = list(items)
|
|
575
|
+
if not items or not all(_is_scalar(i) for i in items):
|
|
576
|
+
return items
|
|
577
|
+
key = "Value"
|
|
578
|
+
if items_key:
|
|
579
|
+
parts = [p for p in re.split(r"[.\[\]]+", items_key) if p]
|
|
580
|
+
if parts:
|
|
581
|
+
key = singular(parts[-1])
|
|
582
|
+
return [{key: i} for i in items]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _type_label(shape: Shape) -> str:
|
|
586
|
+
if shape.type_name == "list":
|
|
587
|
+
return f"list<{_type_label(shape.member)}>"
|
|
588
|
+
if shape.type_name == "map":
|
|
589
|
+
return f"map<{_type_label(shape.key)},{_type_label(shape.value)}>"
|
|
590
|
+
if shape.type_name == "structure":
|
|
591
|
+
return shape.name
|
|
592
|
+
enum = getattr(shape, "enum", None)
|
|
593
|
+
if enum:
|
|
594
|
+
return "enum(" + "|".join(enum[:6]) + ("|…" if len(enum) > 6 else "") + ")"
|
|
595
|
+
return shape.type_name
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _placeholder(shape: Shape, depth: int = 0) -> Any:
|
|
599
|
+
t = shape.type_name
|
|
600
|
+
if t == "string":
|
|
601
|
+
enum = getattr(shape, "enum", None)
|
|
602
|
+
return enum[0] if enum else f"<{shape.name}>"
|
|
603
|
+
if t in ("integer", "long"):
|
|
604
|
+
return 0
|
|
605
|
+
if t in ("float", "double"):
|
|
606
|
+
return 0.0
|
|
607
|
+
if t == "boolean":
|
|
608
|
+
return False
|
|
609
|
+
if t == "list":
|
|
610
|
+
return [] if depth > 1 else [_placeholder(shape.member, depth + 1)]
|
|
611
|
+
if t == "map":
|
|
612
|
+
return {}
|
|
613
|
+
if t == "structure":
|
|
614
|
+
if depth > 1:
|
|
615
|
+
return {}
|
|
616
|
+
return {n: _placeholder(shape.members[n], depth + 1) for n in shape.required_members}
|
|
617
|
+
if t == "timestamp":
|
|
618
|
+
return "2024-01-01T00:00:00Z"
|
|
619
|
+
if t == "blob":
|
|
620
|
+
return ""
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|