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 ADDED
@@ -0,0 +1,3 @@
1
+ """awsnav: a terminal UI for browsing every AWS service with an access key and secret."""
2
+
3
+ __version__ = "0.1.0"
awsnav/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from awsnav.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
+