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.
@@ -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)