Skip to content

Commit cb26ead

Browse files
authored
shtab completions improved for unions with open values and literals (#851)
1 parent 98011cf commit cb26ead

File tree

3 files changed

+145
-28
lines changed

3 files changed

+145
-28
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ paths are considered internals and can change in minor and patch releases.
1515
v4.47.0 (unreleased)
1616
--------------------
1717

18+
Added
19+
^^^^^
20+
- Improved ``shtab`` bash typehint completions for ``Union`` and ``Literal``:
21+
choices for unions that also accept open values now require a prefix to be
22+
completed, and literal booleans/``None`` now complete as ``false``/``true``
23+
and ``null`` (`#851 <https://github.com/omni-us/jsonargparse/pull/851>`__).
24+
1825
Fixed
1926
^^^^^
2027
- Positional arguments with ``nargs="*"`` or ``nargs="?"`` now correctly allow

jsonargparse/_completions.py

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,18 @@ def shtab_prepare_action(action, parser) -> None:
174174
if isinstance(action, ActionTypeHint):
175175
skip = getattr(action, "sub_add_kwargs", {}).get("skip", set())
176176
prefix = action.option_strings[0] if action.option_strings else None
177-
choices = get_typehint_choices(action._typehint, prefix, parser, skip)
177+
choices, require_prefix = get_typehint_choices(action._typehint, prefix, parser, skip)
178178
if shtab_shell.get() == "bash":
179179
message = f"Expected type: {type_to_str(action._typehint)}"
180180
if action.option_strings == []:
181181
message = f"Argument: {action.dest}; " + message
182-
add_bash_typehint_completion(parser, action, message, choices)
182+
add_bash_typehint_completion(
183+
parser,
184+
action,
185+
message,
186+
choices,
187+
require_prefix=require_prefix,
188+
)
183189
choices = None
184190
elif isinstance(action, _ActionHelpClassPath):
185191
choices = get_help_class_choices(action._typehint)
@@ -197,15 +203,23 @@ def shtab_prepare_action(action, parser) -> None:
197203
fi
198204
}
199205
%(name)s() {
200-
local MATCH=( $(IFS=" " compgen -W "$1" "$2") )
206+
local REQUIRE_PREFIX="$4"
207+
local MATCH=()
208+
if [ "$REQUIRE_PREFIX" = 1 ] && [ -z "$2" ]; then
209+
MATCH=()
210+
else
211+
MATCH=( $(IFS=" " compgen -W "$1" "$2") )
212+
fi
201213
if [ ${#MATCH[@]} = 0 ]; then
202214
if [ "$COMP_TYPE" = 63 ]; then
203215
MATCHED=$(_jsonargparse_%%s_matched_choices "$1" "${MATCH[*]}")
204216
printf "%(b)s\\n$3$MATCHED\\n%(n)s" >&2
205217
kill -WINCH $$
206218
fi
207219
else
208-
IFS=" " compgen -W "$1" "$2"
220+
for match in "${MATCH[@]}"; do
221+
echo "$match"
222+
done
209223
if [ "$COMP_TYPE" = 63 ]; then
210224
MATCHED=$(_jsonargparse_%%s_matched_choices "$1" "${MATCH[*]}")
211225
printf "%(b)s\\n$3$MATCHED%(n)s" >&2
@@ -219,53 +233,77 @@ def shtab_prepare_action(action, parser) -> None:
219233
}
220234

221235

222-
def add_bash_typehint_completion(parser, action, message, choices) -> None:
236+
def add_bash_typehint_completion(parser, action, message, choices, require_prefix=False) -> None:
223237
fn_typehint = norm_name(bash_compgen_typehint_name % shtab_prog.get())
224238
fn_name = parser.prog.replace(" [options] ", "_")
225239
fn_name = norm_name(f"_jsonargparse_{fn_name}_{action.dest}_typehint")
226-
fn = '{fn_name}(){{ {fn_typehint} "{choices}" "$1" "{message}"; }}'.format(
240+
fn = '{fn_name}(){{ {fn_typehint} "{choices}" "$1" "{message}" {require_prefix}; }}'.format(
227241
fn_name=fn_name,
228242
fn_typehint=fn_typehint,
229243
choices=" ".join(choices),
230244
message=message,
245+
require_prefix=1 if require_prefix else 0,
231246
)
232247
shtab_preambles.get().append(fn)
233248
action.complete = {"bash": fn_name}
234249

235250

236-
def get_typehint_choices(typehint, prefix, parser, skip, choices=None, added_subclasses=None) -> list[str]:
237-
if choices is None:
238-
choices = []
251+
def get_typehint_choices(typehint, prefix, parser, skip, added_subclasses=None) -> tuple[list[str], bool]:
239252
if not added_subclasses:
240253
added_subclasses = set()
241-
if typehint is bool:
242-
choices.extend(["true", "false"])
243-
elif typehint is NoneType:
244-
choices.append("null")
245-
elif is_subclass(typehint, Enum):
246-
choices.extend(list(typehint.__members__))
247-
else:
254+
255+
def get_choices_state(typehint) -> tuple[list[str], bool, bool]:
256+
if typehint is bool:
257+
return ["true", "false"], True, False
258+
if typehint is NoneType:
259+
return ["null"], True, False
260+
if is_subclass(typehint, Enum):
261+
return list(typehint.__members__), True, False
262+
248263
origin = get_typehint_origin(typehint)
249264
if origin == Literal:
250-
choices.extend([str(a) for a in typehint.__args__ if isinstance(a, (str, int, float))])
251-
elif origin == Union:
265+
choices = []
266+
for arg in typehint.__args__:
267+
if isinstance(arg, bool):
268+
choices.append(str(arg).lower())
269+
elif arg is None:
270+
choices.append("null")
271+
elif isinstance(arg, (str, int, float)):
272+
choices.append(str(arg))
273+
return choices, True, False
274+
275+
if origin == Union:
276+
choices = []
277+
has_explicit_choices = False
278+
has_open_values = False
252279
for subtype in typehint.__args__:
253280
if subtype in added_subclasses or subtype is object:
254281
continue
255-
get_typehint_choices(subtype, prefix, parser, skip, choices, added_subclasses)
256-
elif ActionTypeHint.is_subclass_typehint(typehint):
282+
subchoices, subexplicit, subopen = get_choices_state(subtype)
283+
choices.extend(subchoices)
284+
has_explicit_choices = has_explicit_choices or subexplicit
285+
has_open_values = has_open_values or subopen
286+
return choices, has_explicit_choices, has_open_values
287+
288+
if ActionTypeHint.is_subclass_typehint(typehint):
257289
added_subclasses.add(typehint)
258-
choices.extend(add_subactions_and_get_subclass_choices(typehint, prefix, parser, skip, added_subclasses))
259-
elif origin in callable_origin_types:
290+
choices = add_subactions_and_get_subclass_choices(typehint, prefix, parser, skip, added_subclasses)
291+
return choices, True, False
292+
293+
if origin in callable_origin_types:
260294
return_type = get_callable_return_type(typehint)
261295
if return_type and ActionTypeHint.is_subclass_typehint(return_type):
262296
num_args = len(typehint.__args__) - 1
263297
skip.add(num_args)
264-
choices.extend(
265-
add_subactions_and_get_subclass_choices(return_type, prefix, parser, skip, added_subclasses)
266-
)
298+
choices = add_subactions_and_get_subclass_choices(return_type, prefix, parser, skip, added_subclasses)
299+
return choices, True, False
300+
return [], False, return_type is None
301+
302+
return [], False, True
267303

268-
return [] if choices == ["null"] else choices
304+
choices, has_explicit_choices, has_open_values = get_choices_state(typehint)
305+
require_prefix = get_typehint_origin(typehint) == Union and has_explicit_choices and has_open_values
306+
return choices, require_prefix
269307

270308

271309
def add_subactions_and_get_subclass_choices(typehint, prefix, parser, skip, added_subclasses) -> list[str]:
@@ -295,11 +333,19 @@ def add_subactions_and_get_subclass_choices(typehint, prefix, parser, skip, adde
295333
if option_string not in parser._option_string_actions:
296334
action = parser.add_argument(option_string)
297335
for subtype in unique(subtypes):
298-
subchoices = get_typehint_choices(subtype, option_string, parser, skip, None, added_subclasses)
336+
subchoices, require_prefix = get_typehint_choices(
337+
subtype, option_string, parser, skip, added_subclasses
338+
)
299339
if shtab_shell.get() == "bash":
300340
message = f"Expected type: {type_to_str(subtype)}; "
301341
message += f"Accepted by subclasses: {', '.join(subclasses[name])}"
302-
add_bash_typehint_completion(parser, action, message, subchoices)
342+
add_bash_typehint_completion(
343+
parser,
344+
action,
345+
message,
346+
subchoices,
347+
require_prefix=require_prefix,
348+
)
303349
elif subchoices:
304350
action.choices = subchoices
305351

jsonargparse_tests/test_shtab.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ def test_bash_optional_bool(parser, subtests):
115115
)
116116

117117

118+
def test_bash_optional_int(parser, subtests):
119+
parser.add_argument("--num", type=Optional[int])
120+
assert_bash_typehint_completions(
121+
subtests,
122+
parser,
123+
[
124+
("num", Optional[int], "", [], "0/1"),
125+
("num", Optional[int], "n", ["null"], "1/1"),
126+
],
127+
)
128+
129+
118130
def test_bash_argument_group(parser, subtests):
119131
group = parser.add_argument_group("Group1")
120132
group.add_argument("--bool", type=bool)
@@ -170,6 +182,19 @@ def test_bash_literal(parser, subtests):
170182
)
171183

172184

185+
def test_bash_literal_none(parser, subtests):
186+
typehint = Literal[None]
187+
parser.add_argument("--literal", type=typehint)
188+
assert_bash_typehint_completions(
189+
subtests,
190+
parser,
191+
[
192+
("literal", typehint, "", ["null"], "1/1"),
193+
("literal", typehint, "n", ["null"], "1/1"),
194+
],
195+
)
196+
197+
173198
def test_bash_union(parser, subtests):
174199
typehint = Optional[Union[bool, AXEnum]]
175200
parser.add_argument("--union", type=typehint)
@@ -183,6 +208,32 @@ def test_bash_union(parser, subtests):
183208
)
184209

185210

211+
def test_bash_union_literal_and_int(parser, subtests):
212+
typehint = Union[Literal[False], int]
213+
parser.add_argument("--union", type=typehint)
214+
assert_bash_typehint_completions(
215+
subtests,
216+
parser,
217+
[
218+
("union", typehint, "", [], "0/1"),
219+
("union", typehint, "f", ["false"], "1/1"),
220+
],
221+
)
222+
223+
224+
def test_bash_union_float_and_enum(parser, subtests):
225+
typehint = Union[float, AXEnum]
226+
parser.add_argument("--union", type=typehint)
227+
assert_bash_typehint_completions(
228+
subtests,
229+
parser,
230+
[
231+
("union", typehint, "", [], "0/3"),
232+
("union", typehint, "X", ["XY", "XZ"], "2/3"),
233+
],
234+
)
235+
236+
186237
def test_bash_positional(parser, subtests):
187238
typehint = Literal["Alice", "Bob"]
188239
parser.add_argument("name", type=typehint)
@@ -363,6 +414,19 @@ def test_bash_callable_return_class(parser, subtests):
363414
)
364415

365416

417+
def test_bash_callable_return_int(parser, subtests):
418+
typehint = Callable[[int], int]
419+
parser.add_argument("--num", type=typehint)
420+
assert_bash_typehint_completions(
421+
subtests,
422+
parser,
423+
[
424+
("num", typehint, "", [], None),
425+
("num", typehint, "1", [], None),
426+
],
427+
)
428+
429+
366430
def test_bash_subcommands(parser, subparser, subtests):
367431
subparser.add_argument("--enum", type=AXEnum)
368432
subparser2 = ArgumentParser()

0 commit comments

Comments
 (0)