boto3-s3-cli 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.
- boto3_s3_cli/__init__.py +26 -0
- boto3_s3_cli/autoprompt/__init__.py +19 -0
- boto3_s3_cli/autoprompt/completers.py +336 -0
- boto3_s3_cli/autoprompt/model.py +198 -0
- boto3_s3_cli/autoprompt/parser.py +271 -0
- boto3_s3_cli/autoprompt/prompt.py +150 -0
- boto3_s3_cli/autoprompt/prompter.py +24 -0
- boto3_s3_cli/cli.py +364 -0
- boto3_s3_cli/commands/__init__.py +1 -0
- boto3_s3_cli/commands/base.py +108 -0
- boto3_s3_cli/commands/cp.py +194 -0
- boto3_s3_cli/commands/ls.py +85 -0
- boto3_s3_cli/commands/mb.py +84 -0
- boto3_s3_cli/commands/mv.py +181 -0
- boto3_s3_cli/commands/presign.py +51 -0
- boto3_s3_cli/commands/rb.py +100 -0
- boto3_s3_cli/commands/rm.py +152 -0
- boto3_s3_cli/commands/sync.py +150 -0
- boto3_s3_cli/commands/transferargs.py +511 -0
- boto3_s3_cli/commands/website.py +65 -0
- boto3_s3_cli/filters.py +107 -0
- boto3_s3_cli/globals.py +384 -0
- boto3_s3_cli/output.py +109 -0
- boto3_s3_cli/progress.py +199 -0
- boto3_s3_cli/py.typed +0 -0
- boto3_s3_cli/runtimeconfig.py +356 -0
- boto3_s3_cli/shorthand.py +199 -0
- boto3_s3_cli-0.1.0.dist-info/METADATA +166 -0
- boto3_s3_cli-0.1.0.dist-info/RECORD +32 -0
- boto3_s3_cli-0.1.0.dist-info/WHEEL +4 -0
- boto3_s3_cli-0.1.0.dist-info/entry_points.txt +3 -0
- boto3_s3_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
boto3_s3_cli/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""boto3-s3-cli - `aws s3` compatible CLI built on the boto3-s3 library."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
__version__: str
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> Any:
|
|
12
|
+
"""Resolve ``__version__`` on first access (PEP 562).
|
|
13
|
+
|
|
14
|
+
importlib.metadata costs ~20ms to import; deferring it keeps that off
|
|
15
|
+
every CLI start (import contract, docs/imports.md).
|
|
16
|
+
"""
|
|
17
|
+
if name == "__version__":
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
value = version("boto3-s3-cli")
|
|
22
|
+
except PackageNotFoundError: # pragma: no cover - only from an unbuilt checkout
|
|
23
|
+
value = "0.0.0+unknown"
|
|
24
|
+
globals()[name] = value
|
|
25
|
+
return value
|
|
26
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Auto-prompt (``--cli-auto-prompt``) support: an interactive prompt with
|
|
2
|
+
``aws s3``-faithful completion, active only when ``prompt_toolkit`` is installed.
|
|
3
|
+
|
|
4
|
+
This package is imported lazily - only once ``--cli-auto-prompt`` actually fires
|
|
5
|
+
on an install that has the ``autoprompt`` extra (``prompt_toolkit``). Nothing
|
|
6
|
+
here is touched on the ``--help`` / ``--version`` / usage / normal-dispatch
|
|
7
|
+
paths (import contract, ``docs/imports.md``).
|
|
8
|
+
|
|
9
|
+
The completion engine (:mod:`model`, :mod:`parser`, :mod:`completers`) is a
|
|
10
|
+
port of aws-cli's ``awscli/autocomplete/`` scoped to the ``boto3-s3`` command
|
|
11
|
+
surface, and is pure Python (no ``prompt_toolkit``). Only :mod:`prompt` binds
|
|
12
|
+
``prompt_toolkit``. Design: ``docs/autoprompt.md``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from boto3_s3_cli.autoprompt.prompter import AutoPrompter
|
|
18
|
+
|
|
19
|
+
__all__ = ["AutoPrompter"]
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""The completers and the filter/combiner - a port of aws-cli's completion core.
|
|
2
|
+
|
|
3
|
+
Ports ``CompletionResult`` / ``AutoCompleter`` (``completer.py``),
|
|
4
|
+
``fuzzy_filter`` (``filters.py``), and the completers from
|
|
5
|
+
``autocomplete/local/basic.py``, scoped to what ``aws s3`` actually exercises:
|
|
6
|
+
|
|
7
|
+
- :class:`ModelIndexCompleter` - subcommand names and option names.
|
|
8
|
+
- :class:`RegionCompleter` / :class:`ProfileCompleter` - ``--region`` /
|
|
9
|
+
``--profile`` values (local botocore/config data).
|
|
10
|
+
- :class:`FilePathCompleter` - ``file://`` / ``fileb://`` paths.
|
|
11
|
+
- :class:`ChoicesCompleter` - values for any option with fixed ``choices``.
|
|
12
|
+
|
|
13
|
+
The auto-prompt UI is charter-exempt (``docs/autoprompt.md`` section 1), so these
|
|
14
|
+
completers favor *usability* over a byte-faithful port. :class:`ChoicesCompleter`
|
|
15
|
+
completes every choice-bearing option uniformly from the argparse model, where
|
|
16
|
+
aws only completes *global* choices (its command-level path reads
|
|
17
|
+
``argument_model.enum``, empty for the ``s3`` customization args whose values
|
|
18
|
+
live on ``.choices``) - so ``--storage-class`` etc. complete here but not in aws.
|
|
19
|
+
Reachability is widened the same way by the parser tuning (``parser.py``): option
|
|
20
|
+
values complete before any positional, and options keep completing after a
|
|
21
|
+
command's second positional path.
|
|
22
|
+
|
|
23
|
+
Pure Python - botocore is imported lazily, only when a region/profile value is
|
|
24
|
+
actually being completed (the interactive path), never on a parse path.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from collections.abc import Callable, Iterable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
34
|
+
|
|
35
|
+
from boto3_s3_cli.autoprompt.model import ROOT
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from boto3_s3_cli.autoprompt.model import CompletionModel
|
|
39
|
+
from boto3_s3_cli.autoprompt.parser import CLIParser, ParsedResult
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CompletionResult:
|
|
44
|
+
"""One completion candidate plus the metadata the prompt UI renders.
|
|
45
|
+
|
|
46
|
+
Mirrors aws-cli's ``CompletionResult``: ``name`` is inserted, ``display_text``
|
|
47
|
+
overrides the menu label, ``cli_type_name`` / ``help_text`` feed the
|
|
48
|
+
``display_meta`` line, ``required`` drives required-first ordering.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
starting_index: int = 0
|
|
53
|
+
required: bool = False
|
|
54
|
+
cli_type_name: str = ""
|
|
55
|
+
help_text: str | None = None
|
|
56
|
+
display_text: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# A response filter narrows + ranks candidates against the current fragment;
|
|
60
|
+
# auto-prompt uses fuzzy_filter, the default elsewhere is startswith_filter.
|
|
61
|
+
ResponseFilter = Callable[[str, "list[CompletionResult]"], "list[CompletionResult]"]
|
|
62
|
+
# A value provider sources region/profile candidates; injecting a fake keeps the
|
|
63
|
+
# unit tests free of botocore and the network.
|
|
64
|
+
ValueProvider = Callable[[], Iterable[str]]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _FuzzyMatch(NamedTuple):
|
|
68
|
+
match_length: int
|
|
69
|
+
start_pos: int
|
|
70
|
+
completion: CompletionResult
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fuzzy_filter(prefix: str, completions: list[CompletionResult]) -> list[CompletionResult]:
|
|
74
|
+
"""Subsequence match + rank, exactly as aws-cli's auto-prompt filter.
|
|
75
|
+
|
|
76
|
+
``"rmt"`` becomes ``/r.*?m.*?t/``; each candidate keeps its shortest,
|
|
77
|
+
left-most match, and results sort by (match length, start, display, name).
|
|
78
|
+
"""
|
|
79
|
+
if prefix and completions:
|
|
80
|
+
fuzzy_matches: list[_FuzzyMatch] = []
|
|
81
|
+
pattern = ".*?".join(map(re.escape, prefix))
|
|
82
|
+
pattern = f"(?=({pattern}))"
|
|
83
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
84
|
+
for completion in completions:
|
|
85
|
+
name = completion.display_text or completion.name
|
|
86
|
+
matches = list(regex.finditer(name))
|
|
87
|
+
if matches:
|
|
88
|
+
best = min(matches, key=lambda m: (m.start(), len(m.group(1))))
|
|
89
|
+
fuzzy_matches.append(_FuzzyMatch(len(best.group(1)), best.start(), completion))
|
|
90
|
+
return [
|
|
91
|
+
match.completion
|
|
92
|
+
for match in sorted(
|
|
93
|
+
fuzzy_matches,
|
|
94
|
+
key=lambda m: (
|
|
95
|
+
m.match_length,
|
|
96
|
+
m.start_pos,
|
|
97
|
+
m.completion.display_text or "",
|
|
98
|
+
m.completion.name,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
]
|
|
102
|
+
return completions
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def startswith_filter(prefix: str, completions: list[CompletionResult]) -> list[CompletionResult]:
|
|
106
|
+
return [c for c in completions if (c.display_text or c.name).startswith(prefix)]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _clean_help(text: str | None) -> str | None:
|
|
110
|
+
if not text:
|
|
111
|
+
return None
|
|
112
|
+
return re.sub("<.*?>", "", text).replace("\n", " ")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BaseCompleter:
|
|
116
|
+
"""Return ``None`` to defer to the next completer, or a (possibly empty) list."""
|
|
117
|
+
|
|
118
|
+
def complete(self, parsed: ParsedResult) -> list[CompletionResult] | None:
|
|
119
|
+
raise NotImplementedError("complete")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AutoCompleter:
|
|
123
|
+
"""Runs completers in order; the first non-``None`` result wins (aws-faithful)."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, parser: CLIParser, completers: list[BaseCompleter]) -> None:
|
|
126
|
+
self._parser = parser
|
|
127
|
+
self._completers = completers
|
|
128
|
+
|
|
129
|
+
def autocomplete(self, command_line: str, index: int | None = None) -> list[CompletionResult]:
|
|
130
|
+
parsed = self._parser.parse(command_line, index)
|
|
131
|
+
for completer in self._completers:
|
|
132
|
+
result = completer.complete(parsed)
|
|
133
|
+
if result is not None:
|
|
134
|
+
return result
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ModelIndexCompleter(BaseCompleter):
|
|
139
|
+
"""Completes subcommand names and option names from the model."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, model: CompletionModel, response_filter: ResponseFilter) -> None:
|
|
142
|
+
self._index = model
|
|
143
|
+
self._filter = response_filter
|
|
144
|
+
|
|
145
|
+
def complete(self, parsed: ParsedResult) -> list[CompletionResult] | None:
|
|
146
|
+
are_unparsed_items_paths = [
|
|
147
|
+
bool(re.search(r"[./\\:]|(--)", item)) for item in parsed.unparsed_items
|
|
148
|
+
]
|
|
149
|
+
if parsed.unparsed_items and all(are_unparsed_items_paths) and not parsed.current_param:
|
|
150
|
+
# cp/mv/sync take two positional paths but the model has one slot, so
|
|
151
|
+
# the second path lands in unparsed_items. Offer the option set: an
|
|
152
|
+
# empty trailing fragment shows the whole set, a `--frag` narrows it.
|
|
153
|
+
# (The faithful port used the command name as the prefix here, which
|
|
154
|
+
# fuzzy-filtered the options down to a useless subset; we favor
|
|
155
|
+
# usability - the auto-prompt UI is charter-exempt, docs/autoprompt.md
|
|
156
|
+
# section 2.) When current_param is set we are typing an option *value*, so
|
|
157
|
+
# we fall through to the value completers below instead.
|
|
158
|
+
fragment = parsed.current_fragment or ""
|
|
159
|
+
prefix = "" if fragment == "--" else fragment
|
|
160
|
+
return self._filter(prefix, self._complete_options(parsed))
|
|
161
|
+
elif parsed.unparsed_items or parsed.current_fragment is None or parsed.current_param:
|
|
162
|
+
# Unknown tokens, nothing being typed, or we're on an option value:
|
|
163
|
+
# defer (value completers handle the last case).
|
|
164
|
+
return None
|
|
165
|
+
current_fragment = parsed.current_fragment
|
|
166
|
+
if current_fragment.startswith("--"):
|
|
167
|
+
prefix = "" if current_fragment == "--" else current_fragment
|
|
168
|
+
return self._filter(prefix, self._complete_options(parsed))
|
|
169
|
+
commands = self._complete_command(parsed)
|
|
170
|
+
if not commands:
|
|
171
|
+
return self._filter(current_fragment, self._complete_options(parsed))
|
|
172
|
+
return self._filter(current_fragment, commands)
|
|
173
|
+
|
|
174
|
+
def _complete_command(self, parsed: ParsedResult) -> list[CompletionResult]:
|
|
175
|
+
lineage = parsed.lineage + ([parsed.current_command] if parsed.current_command else [])
|
|
176
|
+
offset = -len(parsed.current_fragment or "")
|
|
177
|
+
return [
|
|
178
|
+
CompletionResult(name, help_text=full_name, starting_index=offset)
|
|
179
|
+
for name, full_name in self._index.commands_with_full_name(lineage)
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
def _complete_options(self, parsed: ParsedResult) -> list[CompletionResult]:
|
|
183
|
+
offset = -len(parsed.current_fragment or "")
|
|
184
|
+
is_in_global_scope = parsed.lineage == [] and parsed.current_command == ROOT
|
|
185
|
+
results: list[CompletionResult] = []
|
|
186
|
+
if not is_in_global_scope:
|
|
187
|
+
for arg_name in self._index.arg_names(
|
|
188
|
+
lineage=parsed.lineage, command_name=parsed.current_command
|
|
189
|
+
):
|
|
190
|
+
arg = self._index.get_argument_data(
|
|
191
|
+
parsed.lineage, parsed.current_command, arg_name
|
|
192
|
+
)
|
|
193
|
+
if arg is None:
|
|
194
|
+
continue
|
|
195
|
+
results.append(
|
|
196
|
+
CompletionResult(
|
|
197
|
+
f"--{arg_name}",
|
|
198
|
+
starting_index=offset,
|
|
199
|
+
required=arg.required,
|
|
200
|
+
cli_type_name=arg.type_name,
|
|
201
|
+
help_text=_clean_help(arg.help_text),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
for arg in self._index.global_arg_data():
|
|
205
|
+
results.append(
|
|
206
|
+
CompletionResult(
|
|
207
|
+
f"--{arg.name}",
|
|
208
|
+
starting_index=offset,
|
|
209
|
+
required=False,
|
|
210
|
+
cli_type_name=arg.type_name,
|
|
211
|
+
help_text=_clean_help(arg.help_text),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
supplied = list(parsed.parsed_params) + list(parsed.global_params)
|
|
215
|
+
return [r for r in results if r.name.strip("-") not in supplied]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class RegionCompleter(BaseCompleter):
|
|
219
|
+
"""Completes ``--region`` values from local botocore endpoint data (no network)."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self, response_filter: ResponseFilter, regions_provider: ValueProvider | None = None
|
|
223
|
+
) -> None:
|
|
224
|
+
self._filter = response_filter
|
|
225
|
+
self._regions_provider = regions_provider
|
|
226
|
+
self._cache: list[str] | None = None
|
|
227
|
+
|
|
228
|
+
def complete(self, parsed: ParsedResult) -> list[CompletionResult] | None:
|
|
229
|
+
if parsed.current_param == "region" and parsed.current_fragment is not None:
|
|
230
|
+
results = [CompletionResult(name=r) for r in self._regions()]
|
|
231
|
+
return self._filter(parsed.current_fragment, results)
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
def _regions(self) -> list[str]:
|
|
235
|
+
# Cached for the completer's lifetime (one interactive prompt session):
|
|
236
|
+
# value completion fires on every keystroke, and a fresh
|
|
237
|
+
# boto3.Session().get_available_regions() costs ~40 ms each (vs ~3 ms once
|
|
238
|
+
# resolved). aws caches the session for the same reason (it reuses one
|
|
239
|
+
# botocore Session across completions - autocomplete/local/basic.py).
|
|
240
|
+
if self._cache is None:
|
|
241
|
+
if self._regions_provider is not None:
|
|
242
|
+
self._cache = list(self._regions_provider())
|
|
243
|
+
else:
|
|
244
|
+
import boto3 # lazy: only on an actual --region completion
|
|
245
|
+
|
|
246
|
+
# We are an S3-only CLI, so always resolve S3's regions (aws keys
|
|
247
|
+
# off the service in the command lineage, which for us is always s3).
|
|
248
|
+
self._cache = boto3.Session().get_available_regions("s3")
|
|
249
|
+
return self._cache
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ProfileCompleter(BaseCompleter):
|
|
253
|
+
"""Completes ``--profile`` values from the local AWS config (no network)."""
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self, response_filter: ResponseFilter, profiles_provider: ValueProvider | None = None
|
|
257
|
+
) -> None:
|
|
258
|
+
self._filter = response_filter
|
|
259
|
+
self._profiles_provider = profiles_provider
|
|
260
|
+
self._cache: list[str] | None = None
|
|
261
|
+
|
|
262
|
+
def complete(self, parsed: ParsedResult) -> list[CompletionResult] | None:
|
|
263
|
+
if parsed.current_param == "profile" and parsed.current_fragment is not None:
|
|
264
|
+
results = [CompletionResult(name=p) for p in self._profiles()]
|
|
265
|
+
return self._filter(parsed.current_fragment, results)
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def _profiles(self) -> list[str]:
|
|
269
|
+
# Cached for the prompt session, like the regions above (aws-faithful).
|
|
270
|
+
if self._cache is None:
|
|
271
|
+
if self._profiles_provider is not None:
|
|
272
|
+
self._cache = list(self._profiles_provider())
|
|
273
|
+
else:
|
|
274
|
+
import boto3 # lazy: only on an actual --profile completion
|
|
275
|
+
|
|
276
|
+
self._cache = boto3.Session().available_profiles
|
|
277
|
+
return self._cache
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class FilePathCompleter(BaseCompleter):
|
|
281
|
+
"""Completes local paths once the fragment starts with ``file://``/``fileb://``.
|
|
282
|
+
|
|
283
|
+
aws drives this with ``prompt_toolkit``'s ``PathCompleter``; we reimplement
|
|
284
|
+
the directory listing in pure Python so the completion engine carries no
|
|
285
|
+
``prompt_toolkit`` dependency (only :mod:`prompt` binds it).
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
_PREFIXES = ("file://", "fileb://")
|
|
289
|
+
|
|
290
|
+
def __init__(self, response_filter: ResponseFilter) -> None:
|
|
291
|
+
self._filter = response_filter
|
|
292
|
+
|
|
293
|
+
def complete(self, parsed: ParsedResult) -> list[CompletionResult] | None:
|
|
294
|
+
fragment = parsed.current_fragment
|
|
295
|
+
if not fragment or not fragment.startswith(self._PREFIXES):
|
|
296
|
+
return None
|
|
297
|
+
prefix = next(p for p in self._PREFIXES if fragment.startswith(p))
|
|
298
|
+
filename_part = fragment[len(prefix) :]
|
|
299
|
+
if filename_part == "~":
|
|
300
|
+
return [CompletionResult(f"{prefix}~{os.sep}")]
|
|
301
|
+
dirname = os.path.dirname(filename_part)
|
|
302
|
+
listing_dir = os.path.expanduser(dirname) if dirname else os.curdir
|
|
303
|
+
try:
|
|
304
|
+
entries = sorted(os.listdir(listing_dir))
|
|
305
|
+
except OSError:
|
|
306
|
+
return []
|
|
307
|
+
display_dir = f"{dirname}{os.sep}" if dirname and dirname != os.sep else dirname
|
|
308
|
+
results: list[CompletionResult] = []
|
|
309
|
+
for entry in entries:
|
|
310
|
+
display = entry + (os.sep if os.path.isdir(os.path.join(listing_dir, entry)) else "")
|
|
311
|
+
results.append(
|
|
312
|
+
CompletionResult(f"{prefix}{display_dir}{display}", display_text=display)
|
|
313
|
+
)
|
|
314
|
+
return self._filter(os.path.basename(filename_part), results)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class ChoicesCompleter(BaseCompleter):
|
|
318
|
+
"""Completes option values for any option with fixed ``choices`` (gap fix)."""
|
|
319
|
+
|
|
320
|
+
def __init__(self, model: CompletionModel, response_filter: ResponseFilter) -> None:
|
|
321
|
+
self._index = model
|
|
322
|
+
self._filter = response_filter
|
|
323
|
+
|
|
324
|
+
def complete(self, parsed: ParsedResult) -> list[CompletionResult] | None:
|
|
325
|
+
if parsed.current_param is None or parsed.current_fragment is None:
|
|
326
|
+
return None
|
|
327
|
+
arg = self._index.get_argument_data(
|
|
328
|
+
parsed.lineage, parsed.current_command, parsed.current_param
|
|
329
|
+
)
|
|
330
|
+
if arg is None or not arg.choices:
|
|
331
|
+
# Fall back to the global table - a global option's value (e.g.
|
|
332
|
+
# --output) is keyed under ROOT, not the current subcommand.
|
|
333
|
+
arg = self._index.get_argument_data([], ROOT, parsed.current_param)
|
|
334
|
+
if arg is None or not arg.choices:
|
|
335
|
+
return None
|
|
336
|
+
return self._filter(parsed.current_fragment, [CompletionResult(c) for c in arg.choices])
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""The completion model: the command/option surface the auto-prompt completes.
|
|
2
|
+
|
|
3
|
+
aws-cli drives auto-completion from a prebuilt SQLite index of its command
|
|
4
|
+
table. We derive the equivalent from our own ``argparse`` parser
|
|
5
|
+
(:func:`boto3_s3_cli.cli.build_parser`) so there is a single source of truth for
|
|
6
|
+
the option set - the same definitions that dispatch the commands also feed
|
|
7
|
+
completion, and the two cannot drift. :class:`CompletionModel` exposes the small
|
|
8
|
+
query surface the ported :class:`~boto3_s3_cli.autoprompt.parser.CLIParser` and
|
|
9
|
+
completers need (the methods aws-cli's ``ModelIndex`` provides).
|
|
10
|
+
|
|
11
|
+
Structure note: aws's hierarchy is ``aws s3 <sub>`` (three levels); ours is
|
|
12
|
+
``boto3-s3 <sub>`` (two). The parser normalizes the executable token to
|
|
13
|
+
:data:`ROOT`, so globals live under ``command_name == ROOT`` and the subcommands
|
|
14
|
+
hang directly off ``lineage == [ROOT]``.
|
|
15
|
+
|
|
16
|
+
Pure Python - no ``prompt_toolkit``, no AWS SDK - so the whole completion
|
|
17
|
+
pipeline is importable and testable without the optional extra.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import cast
|
|
25
|
+
|
|
26
|
+
# The executable token the parser normalizes to (aws-cli uses 'aws'). Globals
|
|
27
|
+
# are keyed under this name; the subcommands are its children.
|
|
28
|
+
ROOT = "boto3-s3"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ArgData:
|
|
33
|
+
"""Metadata for one option or positional, mirroring aws-cli's ``CLIArgument``.
|
|
34
|
+
|
|
35
|
+
``type_name`` is ``"boolean"`` for flags that take no value (so the parser
|
|
36
|
+
knows not to consume one) and ``"string"`` otherwise - that is the only
|
|
37
|
+
distinction the parser acts on. ``choices`` / ``help_text`` are ours, used
|
|
38
|
+
by the value/name completers; aws keeps the analogous data on its argument
|
|
39
|
+
model. ``name`` is the hyphenated CLI spelling without the ``--`` (e.g.
|
|
40
|
+
``"page-size"``), matching how the parser keys params.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
type_name: str
|
|
45
|
+
nargs: object
|
|
46
|
+
required: bool
|
|
47
|
+
choices: tuple[str, ...] | None
|
|
48
|
+
help_text: str | None
|
|
49
|
+
positional: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class CommandData:
|
|
54
|
+
"""One subcommand's completable surface (its own options + positionals)."""
|
|
55
|
+
|
|
56
|
+
help_text: str
|
|
57
|
+
options: dict[str, ArgData]
|
|
58
|
+
positionals: tuple[ArgData, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CompletionModel:
|
|
62
|
+
"""The query surface the ported parser and completers consume.
|
|
63
|
+
|
|
64
|
+
Methods mirror the names/semantics of aws-cli's ``ModelIndex`` so the port
|
|
65
|
+
stays faithful: :meth:`command_names`, :meth:`arg_names`,
|
|
66
|
+
:meth:`get_argument_data`, plus :meth:`global_arg_data` for the option-name
|
|
67
|
+
completer's global injection.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, globals_: dict[str, ArgData], commands: dict[str, CommandData]) -> None:
|
|
71
|
+
self._globals = globals_
|
|
72
|
+
self._commands = commands
|
|
73
|
+
|
|
74
|
+
def command_names(self, lineage: list[str]) -> list[str]:
|
|
75
|
+
# Subcommands hang off [ROOT]; nothing nests deeper.
|
|
76
|
+
if lineage == [ROOT]:
|
|
77
|
+
return list(self._commands)
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def commands_with_full_name(self, lineage: list[str]) -> list[tuple[str, str]]:
|
|
81
|
+
if lineage == [ROOT]:
|
|
82
|
+
return [(name, cmd.help_text) for name, cmd in self._commands.items()]
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
def arg_names(
|
|
86
|
+
self, lineage: list[str], command_name: str | None, positional_arg: bool = False
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
if command_name == ROOT:
|
|
89
|
+
return [] if positional_arg else list(self._globals)
|
|
90
|
+
command = self._commands.get(command_name or "")
|
|
91
|
+
if command is None:
|
|
92
|
+
return []
|
|
93
|
+
if positional_arg:
|
|
94
|
+
return [arg.name for arg in command.positionals]
|
|
95
|
+
return list(command.options)
|
|
96
|
+
|
|
97
|
+
def get_argument_data(
|
|
98
|
+
self, lineage: list[str], command_name: str | None, arg_name: str
|
|
99
|
+
) -> ArgData | None:
|
|
100
|
+
if command_name == ROOT:
|
|
101
|
+
return self._globals.get(arg_name)
|
|
102
|
+
command = self._commands.get(command_name or "")
|
|
103
|
+
if command is None:
|
|
104
|
+
return None
|
|
105
|
+
if arg_name in command.options:
|
|
106
|
+
return command.options[arg_name]
|
|
107
|
+
for positional in command.positionals:
|
|
108
|
+
if positional.name == arg_name:
|
|
109
|
+
return positional
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def global_arg_data(self) -> list[ArgData]:
|
|
113
|
+
return list(self._globals.values())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_help(action: argparse.Action) -> bool:
|
|
117
|
+
# Our CLI exposes -h/--help (argparse) but no `help` subcommand; aws-cli is
|
|
118
|
+
# the reverse (a `help` subcommand, no --help global). Neither tool's help
|
|
119
|
+
# mechanism is in the other's candidate set, and help is charter-exempt
|
|
120
|
+
# (overview.md section 3 exception 1), so it is excluded from completion entirely.
|
|
121
|
+
return "--help" in action.option_strings or "-h" in action.option_strings
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _arg_from_action(action: argparse.Action) -> ArgData:
|
|
125
|
+
# argparse sets nargs=0 for store_true/store_false and our --version action;
|
|
126
|
+
# those consume no value, so the parser must see them as 'boolean'.
|
|
127
|
+
type_name = "boolean" if action.nargs == 0 else "string"
|
|
128
|
+
name = next((opt[2:] for opt in action.option_strings if opt.startswith("--")), action.dest)
|
|
129
|
+
choices = tuple(str(c) for c in action.choices) if action.choices else None
|
|
130
|
+
return ArgData(
|
|
131
|
+
name=name,
|
|
132
|
+
type_name=type_name,
|
|
133
|
+
nargs=action.nargs,
|
|
134
|
+
required=bool(action.required),
|
|
135
|
+
choices=choices,
|
|
136
|
+
help_text=action.help,
|
|
137
|
+
positional=not action.option_strings,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _actions(parser: argparse.ArgumentParser) -> list[argparse.Action]:
|
|
142
|
+
return parser._actions # pyright: ignore[reportPrivateUsage]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def subparser_map(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]:
|
|
146
|
+
"""The subcommand-name -> subparser map, found via the subparsers action."""
|
|
147
|
+
for action in _actions(parser):
|
|
148
|
+
choices = getattr(action, "choices", None)
|
|
149
|
+
if isinstance(choices, dict):
|
|
150
|
+
members = cast("dict[object, object]", choices)
|
|
151
|
+
if all(isinstance(p, argparse.ArgumentParser) for p in members.values()):
|
|
152
|
+
return cast("dict[str, argparse.ArgumentParser]", choices)
|
|
153
|
+
raise RuntimeError("boto3-s3 parser has no subparsers") # pragma: no cover
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_model() -> CompletionModel:
|
|
157
|
+
"""Introspect :func:`boto3_s3_cli.cli.build_parser` into a completion model.
|
|
158
|
+
|
|
159
|
+
Globals are the top-level parser's options (added to every subparser via the
|
|
160
|
+
shared parent, so they are filtered out of each subcommand's own option set
|
|
161
|
+
by spelling). Imported lazily to keep the module SDK-free and avoid an import
|
|
162
|
+
cycle - only reached on the auto-prompt path.
|
|
163
|
+
"""
|
|
164
|
+
from boto3_s3_cli.cli import build_parser
|
|
165
|
+
|
|
166
|
+
parser = build_parser()
|
|
167
|
+
|
|
168
|
+
# The subparsers action is the top parser's only positional, so the
|
|
169
|
+
# `not option_strings` guard skips it along with any positional; globals are
|
|
170
|
+
# exactly the top-level options minus help.
|
|
171
|
+
global_option_strings: set[str] = set()
|
|
172
|
+
globals_: dict[str, ArgData] = {}
|
|
173
|
+
for action in _actions(parser):
|
|
174
|
+
if _is_help(action) or not action.option_strings:
|
|
175
|
+
continue
|
|
176
|
+
global_option_strings.update(action.option_strings)
|
|
177
|
+
arg = _arg_from_action(action)
|
|
178
|
+
globals_[arg.name] = arg
|
|
179
|
+
|
|
180
|
+
commands: dict[str, CommandData] = {}
|
|
181
|
+
for name, subparser in subparser_map(parser).items():
|
|
182
|
+
options: dict[str, ArgData] = {}
|
|
183
|
+
positionals: list[ArgData] = []
|
|
184
|
+
for action in _actions(subparser):
|
|
185
|
+
if _is_help(action):
|
|
186
|
+
continue
|
|
187
|
+
if not action.option_strings:
|
|
188
|
+
positionals.append(_arg_from_action(action))
|
|
189
|
+
elif not set(action.option_strings) & global_option_strings:
|
|
190
|
+
arg = _arg_from_action(action)
|
|
191
|
+
options[arg.name] = arg
|
|
192
|
+
commands[name] = CommandData(
|
|
193
|
+
help_text=subparser.description or "",
|
|
194
|
+
options=options,
|
|
195
|
+
positionals=tuple(positionals),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return CompletionModel(globals_, commands)
|