From e033b9b6d3251d549d94ac927ce92ce6bb170c33 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:54:43 +0200 Subject: [PATCH 1/5] Fix mangled types in help by building the string from the type hint instead of a regex --- CHANGELOG.rst | 9 +++ jsonargparse/_typehints.py | 77 ++++++++++++++++++- .../test_postponed_annotations.py | 2 +- jsonargparse_tests/test_typehints.py | 76 ++++++++++++++++-- 4 files changed, 153 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cbef856d..eb16ae21 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -150,6 +150,15 @@ Fixed decorators that mark a class as deprecated or experimental. The parameters were resolved from the wrapper instead of from ``__init__`` (`#953 `__). +- Types shown in the help being mangled when they contain a dot, since the + stripping of module names was done with a regex over the entire type string. + Affected floats in metadata, e.g. ``Annotated[float, Lt(lt=0.9)]`` shown as + ``Lt(lt=9)``, ``Literal`` values that have a dot, e.g. ``Literal['4.5']`` + shown as ``Literal['5']``, and the ellipsis of ``Tuple[int, ...]``, shown as + ``Tuple[int, ]`` (`#??? `__). +- ``None`` in a PEP 604 union shown as is in the help, e.g. ``date | None``, + instead of as ``date | null``, which is what a config file requires (`#??? + `__). Changed ^^^^^^^ diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 85684ff7..d098dc7f 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -2424,8 +2424,14 @@ def typehint_from_action(action_or_typehint): return action_or_typehint +module_prefix_pattern = re.compile(r"(? str: - return re.sub(r"[A-Za-z0-9_<>.]+\.", "", string) + return module_prefix_pattern.sub("", string) def type_to_str(obj): @@ -2433,9 +2439,74 @@ def type_to_str(obj): return "ModuleType" if obj in type_expression_types: return type_expression_types[obj] - if obj in {bool, tuple} or is_subclass(obj, (int, float, str, Path, Enum)): + # in python<=3.10 an Annotated is a subclass of its origin type, so it is excluded here + if not hasattr(obj, "__metadata__") and (obj in {bool, tuple} or is_subclass(obj, (int, float, str, Path, Enum))): return obj.__name__ - return strip_module_names(str(obj)).replace("NoneType", "null") + return typehint_to_str(obj) + + +def typehint_to_str(typehint) -> str: + """Type hint as a string, recreating it with subtypes replaced by their string form. + + Only the outermost level goes through ``strip_module_names``, such that literal + values and metadata, e.g. floats and dotted strings, are never mangled by it. + """ + if get_typehint_origin(typehint) is Literal: + values = ", ".join(repr_to_str(v) for v in typehint.__args__) + return f"Literal[{values}]" + if hasattr(typehint, "__metadata__"): + subtypes = [subtypehint_to_str(typehint.__origin__)] + [repr(m) for m in typehint.__metadata__] + return f"Annotated[{', '.join(subtypes)}]" + + args = getattr(typehint, "__args__", None) + if isinstance(args, tuple) and args: + subtypes = {} + new_args = [] + for num, arg in enumerate(args): + if arg is NoneType or arg is Ellipsis: + new_args.append(arg) + continue + name = f"{type_arg_prefix}{num}" + subtypes[name] = subtypehint_to_str(arg) + new_args.append(type(name, (), {})) + shallow = replace_typehint_args(typehint, new_args) + if shallow is not None: + string = none_type_pattern.sub("null", strip_module_names(str(shallow))) + return type_arg_pattern.sub(lambda match: subtypes[match.group()], string) + + return none_type_pattern.sub("null", strip_module_names(str(typehint))) + + +def subtypehint_to_str(typehint) -> str: + if isinstance(typehint, type) and not getattr(typehint, "__args__", None): + return strip_module_names(f"{typehint.__module__}.{typehint.__qualname__}") + return typehint_to_str(typehint) + + +def replace_typehint_args(typehint, args): + """Same type hint but with its subtypes replaced, or None if not possible.""" + if isinstance(typehint, UnionType): + union = args[0] + for arg in args[1:]: + union = union | arg + return union + if isinstance(typehint, GenericAlias): + origin = typehint.__origin__ + if origin in callable_origin_types: + # the input types of a callable are given as a list, e.g. Callable[[int], str] + return origin[args[0] if args[0] is Ellipsis else list(args[:-1]), args[-1]] + return GenericAlias(origin, tuple(args)) + copy_with = getattr(typehint, "copy_with", None) + if copy_with is None: + return None + try: + return copy_with(tuple(args)) + except Exception: # pragma: no cover + return None + + +def repr_to_str(val): + return "null" if val is None else repr(val) def literal_to_str(val): diff --git a/jsonargparse_tests/test_postponed_annotations.py b/jsonargparse_tests/test_postponed_annotations.py index 4cc01fd7..abb6ccae 100644 --- a/jsonargparse_tests/test_postponed_annotations.py +++ b/jsonargparse_tests/test_postponed_annotations.py @@ -388,7 +388,7 @@ def test_unresolvable_annotation_help(parser): if sys.version_info < (3, 14): optional = "Optional[Unvalidated]" else: - optional = "None | Unvalidated" + optional = "null | Unvalidated" assert f"--fn.typo TYPO (type: {optional}, default: null)" in help_str diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 7aafbbd1..2a77b8a3 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -17,6 +17,7 @@ from types import GenericAlias, MappingProxyType, ModuleType, UnionType from typing import ( AbstractSet, + Annotated, Any, Callable, Collection, @@ -1697,9 +1698,9 @@ def test_union_subtypes_order(parser, subtypes, arg, expected): # object last, since it accepts the import path of any class (Union[object, int], "Union[int, object]", "int | object"), # None second to last, so that Optional keeps its form - (Optional[str], "Optional[str]", "str | None"), - (Optional[int], "Optional[int]", "int | None"), - (Union[Any, None], "Optional[Any]", "None | Any"), + (Optional[str], "Optional[str]", "str | null"), + (Optional[int], "Optional[int]", "int | null"), + (Union[Any, None], "Optional[Any]", "null | Any"), # relative order otherwise kept (Union[str, int], "Union[str, int]", "str | int"), (Union[float, int], "Union[float, int]", "float | int"), @@ -1707,7 +1708,7 @@ def test_union_subtypes_order(parser, subtypes, arg, expected): (Union[List[int], Dict[str, int]], "Union[List[int], Dict[str, int]]", "List[int] | Dict[str, int]"), # nested unions also reordered (Dict[str, Union[Any, int]], "Dict[str, Union[int, Any]]", "Dict[str, int | Any]"), - (Optional[List[Union[Any, bool]]], "Optional[List[Union[bool, Any]]]", "List[bool | Any] | None"), + (Optional[List[Union[Any, bool]]], "Optional[List[Union[bool, Any]]]", "List[bool | Any] | null"), ( Tuple[Union[Any, int], Union[object, int]], "Tuple[Union[int, Any], Union[int, object]]", @@ -1729,8 +1730,8 @@ def test_union_subtypes_sorted_on_add_argument(parser, typehint, expected, expec ["typehint", "expected"], [ (object | int, "int | object"), - (int | None, "int | None"), - (object | int | None, "int | object | None"), + (int | None, "int | null"), + (object | int | None, "int | object | null"), (list[object | int], "list[int | object]"), ], ids=str, @@ -2588,7 +2589,7 @@ def test_unsupported_type_not_required_added(parser): if sys.version_info < (3, 14): optional = "Optional[Unvalidated]" else: - optional = "None | Unvalidated" + optional = "null | Unvalidated" assert f"--fn.p1 P1 (type: {optional}, default: null)" in help_str @@ -2637,6 +2638,67 @@ def test_namespace_signature_parameter_fails(parser): parser.add_function_arguments(function_namespace_parameter, "fn") +# type_to_str tests + + +class Lt: + def __init__(self, lt): + self.lt = lt + + def __repr__(self): + return f"Lt(lt={self.lt})" + + +@pytest.mark.parametrize( + ["typehint", "expected", "expected_py314"], + [ + (int, "int", None), + (date, "", None), + (Optional[date], "Optional[date]", "date | null"), + (date | None, "date | null", None), + (Optional[Path_fr], "Optional[Path_fr]", "Path_fr | null"), + (List[Optional[int]], "List[Optional[int]]", "List[int | null]"), + (list[int | None], "list[int | null]", None), + (Dict[str, List[Optional[date]]], "Dict[str, List[Optional[date]]]", "Dict[str, List[date | null]]"), + (Tuple[int, ...], "Tuple[int, ...]", None), + (Callable[[int], date], "Callable[[int], date]", None), + (Type[date], "Type[date]", None), + (type[date], "type[date]", None), + (Union[int, str], "Union[int, str]", "int | str"), + (Union[int, str, None], "Union[int, str, null]", "int | str | null"), + # dotted values inside literals must not be mangled + ( + Literal["significant", "4.5", "2.5", "1.0", "all"], + "Literal['significant', '4.5', '2.5', '1.0', 'all']", + None, + ), + (Literal["a.b.c", 4.5], "Literal['a.b.c', 4.5]", None), + (Literal[1, True, None], "Literal[1, True, null]", None), + (Optional[Literal["1.0"]], "Optional[Literal['1.0']]", "Literal['1.0'] | null"), + (List[Literal["a.b"]], "List[Literal['a.b']]", None), + # float constraints in annotated metadata must not be mangled + (Annotated[float, Lt(lt=0.9)], "Annotated[float, Lt(lt=0.9)]", None), + (Annotated[float, Lt(lt=10.5)], "Annotated[float, Lt(lt=10.5)]", None), + ( + Optional[Annotated[float, Lt(lt=0.9)]], + "Optional[Annotated[float, Lt(lt=0.9)]]", + "Annotated[float, Lt(lt=0.9)] | null", + ), + ], + ids=str, +) +def test_type_to_str(typehint, expected, expected_py314): + if expected_py314 and sys.version_info >= (3, 14): + expected = expected_py314 + assert type_to_str(typehint) == expected + + +def test_type_to_str_literal_dotted_values_help(parser): + parser.add_argument("--level", type=Literal["significant", "4.5", "1.0"], default="all") + help_str = get_parser_help(parser) + assert "type: Literal['significant', '4.5', '1.0']" in help_str + + # other tests From dac4fc546d5ea0e0a6a3cbf25f4f80d19fc44ee6 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:58:58 +0200 Subject: [PATCH 2/5] Fix AttributeError in --print_config=skip_default when a subclass spec is given for an argument whose default is None --- CHANGELOG.rst | 9 ++++++++- jsonargparse/_core.py | 9 ++++++++- jsonargparse_tests/test_subclasses.py | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index eb16ae21..db7c3510 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -155,10 +155,17 @@ Fixed Affected floats in metadata, e.g. ``Annotated[float, Lt(lt=0.9)]`` shown as ``Lt(lt=9)``, ``Literal`` values that have a dot, e.g. ``Literal['4.5']`` shown as ``Literal['5']``, and the ellipsis of ``Tuple[int, ...]``, shown as - ``Tuple[int, ]`` (`#??? `__). + ``Tuple[int, ]`` (`#??? + `__). - ``None`` in a PEP 604 union shown as is in the help, e.g. ``date | None``, instead of as ``date | null``, which is what a config file requires (`#??? `__). +- ``dump`` with ``skip_default=True``, i.e. ``--print_config=skip_default``, + failing with ``AttributeError: 'NoneType' object has no attribute 'get'`` when + a subclass spec is given for an argument whose default is ``None``, e.g. an + ``Optional[SomeClass]`` parameter. Now the ``class_path`` is kept in the dump + and only the ``init_args`` that are defaults are removed (`#??? + `__). Changed ^^^^^^^ diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py index 3f09ad16..96042403 100644 --- a/jsonargparse/_core.py +++ b/jsonargparse/_core.py @@ -887,8 +887,12 @@ def _dump_delete_default_entries(self, subcfg, subdefaults): val = subcfg[key] default = subdefaults[key] class_object_val = None + same_class_path = True if is_subclass_spec(val): + if not isinstance(default, dict): + default = {} if val["class_path"] != default.get("class_path"): + same_class_path = False with parser_context(parent_parser=self): parser = ActionTypeHint.get_class_parser(val["class_path"]) default = {"init_args": parser.get_defaults().as_dict()} @@ -896,7 +900,10 @@ def _dump_delete_default_entries(self, subcfg, subdefaults): val = val.get("init_args") default = default.get("init_args") if val == default: - del subcfg[key] + if class_object_val is not None and not same_class_path: + class_object_val.pop("init_args", None) + else: + del subcfg[key] elif isinstance(val, dict) and isinstance(default, dict): self._dump_delete_default_entries(val, default) if class_object_val and class_object_val.get("init_args") == {}: diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py index 8e9c7415..45464dc7 100644 --- a/jsonargparse_tests/test_subclasses.py +++ b/jsonargparse_tests/test_subclasses.py @@ -2215,6 +2215,25 @@ def test_subclass_print_config(parser): assert "Option 'invalid' is not accepted" in err +class PrintConfigOptional: + def __init__(self, sub: Optional[BaseC] = None): + pass # pragma: no cover + + +def test_subclass_print_config_skip_default_optional(parser): + parser.add_argument("--config", action="config") + parser.add_class_arguments(PrintConfigOptional, "g", sub_configs=True) + + out = get_parse_args_stdout(parser, [f"--g.sub={__name__}.BaseC", "--print_config=skip_default"]) + assert json_or_yaml_load(out) == {"g": {"sub": {"class_path": f"{__name__}.BaseC"}}} + + out = get_parse_args_stdout(parser, [f"--g.sub={__name__}.BaseC", "--g.sub.p=3", "--print_config=skip_default"]) + assert json_or_yaml_load(out) == {"g": {"sub": {"class_path": f"{__name__}.BaseC", "init_args": {"p": 3}}}} + + out = get_parse_args_stdout(parser, ["--print_config=skip_default"]) + assert json_or_yaml_load(out) in ({}, None) + + class PrintConfigRequired: def __init__(self, arg1: float): pass # pragma: no cover From 8ed89aa5ec3ee93729c73cc209aab979508207d7 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:14:13 +0200 Subject: [PATCH 3/5] Fix kwargs pop/get parameters disappearing when forwarded explicitly --- CHANGELOG.rst | 4 ++ jsonargparse/_parameter_resolvers.py | 4 ++ .../test_parameter_resolvers.py | 43 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db7c3510..8bbd9120 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -166,6 +166,10 @@ Fixed ``Optional[SomeClass]`` parameter. Now the ``class_path`` is kept in the dump and only the ``init_args`` that are defaults are removed (`#??? `__). +- Parameters popped or gotten from ``**kwargs`` disappearing when the value is + then forwarded explicitly as a keyword, e.g. ``x = kwargs.pop("x", None)`` + followed by ``super().__init__(x=x, **kwargs)`` (`#??? + `__). Changed ^^^^^^^ diff --git a/jsonargparse/_parameter_resolvers.py b/jsonargparse/_parameter_resolvers.py index ebfa7298..5580dcad 100644 --- a/jsonargparse/_parameter_resolvers.py +++ b/jsonargparse/_parameter_resolvers.py @@ -802,12 +802,14 @@ def get_parameters_args_and_kwargs(self) -> tuple[ParamList, ParamList]: params_list = [] removed_params: set[str] = set() + pop_or_get_params: set[str] = set() kwargs_value = kwargs_name and values_to_find[kwargs_name] kwargs_value_dump = kwargs_value and ast.dump(kwargs_value) for node, source in [(v, s) for k, v, s in values_found if k == kwargs_name]: if isinstance(node, ast.Call): if ast_is_kwargs_pop_or_get(node, kwargs_value_dump): param = self.get_kwargs_pop_or_get_parameter(node, self.component, self.parent, self.doc_params) + pop_or_get_params.add(param.name) params_list.append([param]) continue kwarg = ast_get_call_kwarg_with_value(node, kwargs_value) @@ -840,6 +842,8 @@ def get_parameters_args_and_kwargs(self) -> tuple[ParamList, ParamList]: self.log_debug(f"unsupported type of assign: {ast_str(node)}") params = group_parameters(params_list) + # a pop/get from kwargs means the parameter is accepted, even if the value is then given explicitly + removed_params -= pop_or_get_params params = [p for p in params if p.name not in removed_params] return split_args_and_kwargs(params) diff --git a/jsonargparse_tests/test_parameter_resolvers.py b/jsonargparse_tests/test_parameter_resolvers.py index c9f7e4fa..91245523 100644 --- a/jsonargparse_tests/test_parameter_resolvers.py +++ b/jsonargparse_tests/test_parameter_resolvers.py @@ -484,6 +484,35 @@ def function_pop_get_conditional(p1: str, **kw): # pragma: no cover kw.get("p3", "y") +class ClassPopParent: # pragma: no cover + def __init__(self, pp1: Optional[list] = None, pp2: int = 0): + """ + Args: + pp1: help for pp1 + pp2: help for pp2 + """ + + +class ClassPopForward(ClassPopParent): # pragma: no cover + def __init__(self, pf1: int = 0, **kwargs): + """ + Args: + pf1: help for pf1 + pp1: help for pp1 + """ + pp1 = list(kwargs.pop("pp1", None) or []) + super().__init__(pp1=pp1, **kwargs) + + +def function_pop_and_forward(**kwargs): # pragma: no cover + """ + Args: + k1: help for k1 + """ + k1 = kwargs.pop("k1", 3) + return function_with_kwargs(k1=k1, **kwargs) + + def function_with_bug(**kws): # pragma: no cover return does_not_exist(**kws) # noqa: F821 @@ -863,6 +892,20 @@ def test_get_params_function_pop_get_conditional(): ) +def test_get_params_class_pop_from_kwargs_and_forward(): + params = get_params(ClassPopForward) + assert_params(params, ["pf1", "pp1", "pp2"]) + assert params[1].annotation is inspect._empty + assert params[1].default is None + + +def test_get_params_function_pop_from_kwargs_and_forward(): + params = get_params(function_pop_and_forward) + assert_params(params, ["k1", "pk1", "k2"]) + assert params[0].annotation is inspect._empty + assert params[0].default == 3 + + def test_get_params_function_module_class(): params = get_params(function_module_class) assert ["firstweekday"] == [p.name for p in params] From fbeb26b23f5ca2d3cc801d785cf4af635d6fd53b Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:20:20 +0200 Subject: [PATCH 4/5] Fix shtab bash completion not redrawing the prompt when there are zero completions --- CHANGELOG.rst | 4 ++ jsonargparse/_completions.py | 7 ++- jsonargparse_tests/test_shtab.py | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8bbd9120..5ece546b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -170,6 +170,10 @@ Fixed then forwarded explicitly as a keyword, e.g. ``x = kwargs.pop("x", None)`` followed by ``super().__init__(x=x, **kwargs)`` (`#??? `__). +- ``shtab`` bash completion not redrawing the prompt after printing the type + guidance message when there are zero completions, leaving the cursor on an + empty line, observed since bash 5.3 (`#??? + `__). Changed ^^^^^^^ diff --git a/jsonargparse/_completions.py b/jsonargparse/_completions.py index 1c6b8880..71298e56 100644 --- a/jsonargparse/_completions.py +++ b/jsonargparse/_completions.py @@ -257,7 +257,12 @@ def shtab_prepare_action(action, parser) -> None: # "{prog}" is a placeholder replaced with the normalized prog name, see get_shtab_script. bash_compgen_typehint_name = "_jsonargparse_{prog}_compgen_typehint" +# When there are zero completions only a message is printed, so readline doesn't redraw the +# prompt. A SIGWINCH doesn't help since readline redraws only if the terminal size changed. +# Thus, ask the terminal for a device status report (\\e[5n) and bind its reply (\\e[0n) to +# redraw-current-line, making readline itself redraw once the completion function returns. bash_compgen_typehint = """ +[[ $- == *i* ]] && bind '"\\e[0n": redraw-current-line' 2>/dev/null %(name)s() { local CHOICES="$1" WORD="$2" MESSAGE="$3" REQUIRE_PREFIX="$4" TOTAL="$5" local IFS=$'\\n' # choices may contain spaces, so split matches on newline only @@ -274,7 +279,7 @@ def shtab_prepare_action(action, parser) -> None: if [ ${#MATCH[@]} = 0 ]; then if [ "$COMP_TYPE" = 63 ]; then printf "%(b)s\\n%%s%%s\\n%(n)s" "$MESSAGE" "$MATCHED" >&2 - kill -WINCH $$ + printf '\\033[5n' >&2 fi else for match in "${MATCH[@]}"; do diff --git a/jsonargparse_tests/test_shtab.py b/jsonargparse_tests/test_shtab.py index 15f555c1..8f6bcde4 100644 --- a/jsonargparse_tests/test_shtab.py +++ b/jsonargparse_tests/test_shtab.py @@ -1,8 +1,14 @@ import dataclasses +import os import re +import select import shlex +import struct import subprocess +import sys import tempfile +import time +from contextlib import suppress from enum import Enum from importlib.util import find_spec from os import PathLike @@ -87,6 +93,8 @@ def assert_bash_typehint_completions(subtests, shtab_script, completions): assert f"Expected type: {typehint}; Accepted by subclasses: {extra}" in err.decode() if is_positional(dest, parser): assert f"Argument: {dest}; Expected type: {typehint}" in err.decode() + redraw_requested = "\x1b[5n" in err.decode() + assert redraw_requested == (choices == []), "device status report expected iff there are no completions" def test_bash_any(parser, subtests): @@ -293,6 +301,77 @@ def test_shtab_bash_optionals_as_positionals(parser, subtests, parsing_settings_ ) +def test_bash_script_binds_redraw_current_line(parser): + parser.add_argument("--num", type=int) + shtab_script = get_shtab_script(parser, "bash") + assert "bind '\"\\e[0n\": redraw-current-line'" in shtab_script + + +def get_bash_major_version(): + out = subprocess.run(["bash", "-c", 'echo "${BASH_VERSINFO[0]}"'], capture_output=True) + try: + return int(out.stdout.strip()) + except ValueError: # pragma: no cover + return 0 + + +def read_from_pty_until(fd, pattern, timeout=10.0): + out = b"" + end = time.monotonic() + timeout + while pattern not in out and time.monotonic() < end: + ready, _, _ = select.select([fd], [], [], 0.5) + if ready: + try: + data = os.read(fd, 65536) + except OSError: + break + if not data: + break + out += data + return out + + +@pytest.mark.skipif(sys.platform == "win32", reason="pty is not available on Windows") +@pytest.mark.filterwarnings("ignore:.*multi-threaded, use of forkpty.*:DeprecationWarning") +def test_bash_interactive_no_completions_redraws_prompt(parser, tmp_path): + if get_bash_major_version() < 4: + pytest.skip("test requires bash>=4") + import fcntl + import pty + import termios + + parser.add_argument("--num", type=int) + shtab_script_path = tmp_path / "comp.sh" + shtab_script_path.write_text(get_shtab_script(parser, "bash")) + rcfile = tmp_path / "rcfile" + rcfile.write_text(f"PS1='PROMPT$ '\nsource {shtab_script_path}\n") + + pid, fd = pty.fork() + if pid == 0: # pragma: no cover + try: + os.environ["TERM"] = "xterm-256color" + os.execvp("bash", ["bash", "--noprofile", "--rcfile", str(rcfile), "-i"]) + finally: + os._exit(1) + try: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 200, 0, 0)) + read_from_pty_until(fd, b"PROMPT$ ") + os.write(fd, b"tool --num \t\t") + out = read_from_pty_until(fd, b"\x1b[5n") + assert b"Expected type: int" in out + assert b"\x1b[5n" in out, "completion should request a device status report from the terminal" + os.write(fd, b"\x1b[0n") # a real terminal replies this to the \x1b[5n device status report + out = read_from_pty_until(fd, b"PROMPT$ tool --num ") + assert b"PROMPT$ tool --num " in out, "prompt should be redrawn after the guidance message" + finally: + with suppress(OSError): + os.write(fd, b"\x03exit\n") + with suppress(OSError): + os.close(fd) + with suppress(OSError): + os.waitpid(pid, 0) + + def test_bash_config(parser): parser.add_argument("--cfg", action="config") shtab_script = get_shtab_script(parser, "bash") From 30e8dd9492ebe3754a6f5e65facd2cb9a604c094 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:47:02 +0200 Subject: [PATCH 5/5] Full coverage and some cleanup --- CHANGELOG.rst | 23 ++++++++++------------- jsonargparse/_core.py | 7 ++++--- jsonargparse/_typehints.py | 25 ++++++++++--------------- jsonargparse_tests/test_shtab.py | 6 +++--- jsonargparse_tests/test_typehints.py | 7 +++++++ 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5ece546b..76e348c3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -152,28 +152,25 @@ Fixed `__). - Types shown in the help being mangled when they contain a dot, since the stripping of module names was done with a regex over the entire type string. - Affected floats in metadata, e.g. ``Annotated[float, Lt(lt=0.9)]`` shown as - ``Lt(lt=9)``, ``Literal`` values that have a dot, e.g. ``Literal['4.5']`` - shown as ``Literal['5']``, and the ellipsis of ``Tuple[int, ...]``, shown as - ``Tuple[int, ]`` (`#??? - `__). + Affected floats, literals and ellipsis (`#954 + `__). - ``None`` in a PEP 604 union shown as is in the help, e.g. ``date | None``, - instead of as ``date | null``, which is what a config file requires (`#??? - `__). + instead of as ``date | null``, which is what parsing requires (`#954 + `__). - ``dump`` with ``skip_default=True``, i.e. ``--print_config=skip_default``, failing with ``AttributeError: 'NoneType' object has no attribute 'get'`` when a subclass spec is given for an argument whose default is ``None``, e.g. an ``Optional[SomeClass]`` parameter. Now the ``class_path`` is kept in the dump - and only the ``init_args`` that are defaults are removed (`#??? - `__). + and only the ``init_args`` that are defaults are removed (`#954 + `__). - Parameters popped or gotten from ``**kwargs`` disappearing when the value is then forwarded explicitly as a keyword, e.g. ``x = kwargs.pop("x", None)`` - followed by ``super().__init__(x=x, **kwargs)`` (`#??? - `__). + followed by ``super().__init__(x=x, **kwargs)`` (`#954 + `__). - ``shtab`` bash completion not redrawing the prompt after printing the type guidance message when there are zero completions, leaving the cursor on an - empty line, observed since bash 5.3 (`#??? - `__). + empty line, observed since bash 5.3 (`#954 + `__). Changed ^^^^^^^ diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py index 96042403..b92a0244 100644 --- a/jsonargparse/_core.py +++ b/jsonargparse/_core.py @@ -900,10 +900,11 @@ def _dump_delete_default_entries(self, subcfg, subdefaults): val = val.get("init_args") default = default.get("init_args") if val == default: - if class_object_val is not None and not same_class_path: - class_object_val.pop("init_args", None) - else: + if same_class_path: del subcfg[key] + else: + # only the init_args are defaults, so the class_path is kept + class_object_val.pop("init_args", None) elif isinstance(val, dict) and isinstance(default, dict): self._dump_delete_default_entries(val, default) if class_object_val and class_object_val.get("init_args") == {}: diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index d098dc7f..c2b39bc1 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -2439,8 +2439,8 @@ def type_to_str(obj): return "ModuleType" if obj in type_expression_types: return type_expression_types[obj] - # in python<=3.10 an Annotated is a subclass of its origin type, so it is excluded here - if not hasattr(obj, "__metadata__") and (obj in {bool, tuple} or is_subclass(obj, (int, float, str, Path, Enum))): + # is_subclass not used, since in python<3.12 it considers an Annotated a subclass of its origin type + if obj in {bool, tuple} or (isinstance(obj, type) and issubclass(obj, (int, float, str, Path, Enum))): return obj.__name__ return typehint_to_str(obj) @@ -2460,48 +2460,43 @@ def typehint_to_str(typehint) -> str: args = getattr(typehint, "__args__", None) if isinstance(args, tuple) and args: - subtypes = {} + arg_subtypes = {} new_args = [] for num, arg in enumerate(args): if arg is NoneType or arg is Ellipsis: new_args.append(arg) continue name = f"{type_arg_prefix}{num}" - subtypes[name] = subtypehint_to_str(arg) + arg_subtypes[name] = subtypehint_to_str(arg) new_args.append(type(name, (), {})) shallow = replace_typehint_args(typehint, new_args) if shallow is not None: string = none_type_pattern.sub("null", strip_module_names(str(shallow))) - return type_arg_pattern.sub(lambda match: subtypes[match.group()], string) + return type_arg_pattern.sub(lambda match: arg_subtypes[match.group()], string) return none_type_pattern.sub("null", strip_module_names(str(typehint))) def subtypehint_to_str(typehint) -> str: if isinstance(typehint, type) and not getattr(typehint, "__args__", None): - return strip_module_names(f"{typehint.__module__}.{typehint.__qualname__}") + return typehint.__name__ # the str of a class has a wrap and the module name return typehint_to_str(typehint) def replace_typehint_args(typehint, args): """Same type hint but with its subtypes replaced, or None if not possible.""" if isinstance(typehint, UnionType): - union = args[0] - for arg in args[1:]: - union = union | arg - return union + return reduce(or_, args) if isinstance(typehint, GenericAlias): origin = typehint.__origin__ if origin in callable_origin_types: # the input types of a callable are given as a list, e.g. Callable[[int], str] return origin[args[0] if args[0] is Ellipsis else list(args[:-1]), args[-1]] return GenericAlias(origin, tuple(args)) - copy_with = getattr(typehint, "copy_with", None) - if copy_with is None: - return None try: - return copy_with(tuple(args)) - except Exception: # pragma: no cover + return typehint.copy_with(tuple(args)) + except Exception: + # no copy_with, e.g. a parameterized type that is a class, or it rejects the given subtypes return None diff --git a/jsonargparse_tests/test_shtab.py b/jsonargparse_tests/test_shtab.py index 8f6bcde4..dc128b79 100644 --- a/jsonargparse_tests/test_shtab.py +++ b/jsonargparse_tests/test_shtab.py @@ -323,10 +323,10 @@ def read_from_pty_until(fd, pattern, timeout=10.0): if ready: try: data = os.read(fd, 65536) - except OSError: + except OSError: # pragma: no cover break if not data: - break + break # pragma: no cover out += data return out @@ -335,7 +335,7 @@ def read_from_pty_until(fd, pattern, timeout=10.0): @pytest.mark.filterwarnings("ignore:.*multi-threaded, use of forkpty.*:DeprecationWarning") def test_bash_interactive_no_completions_redraws_prompt(parser, tmp_path): if get_bash_major_version() < 4: - pytest.skip("test requires bash>=4") + pytest.skip("test requires bash>=4") # pragma: no cover import fcntl import pty import termios diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 2a77b8a3..6e8c9f28 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -2649,11 +2649,18 @@ def __repr__(self): return f"Lt(lt={self.lt})" +class ConstrainedListValue(list): + """Parameterized type that is a class, as created by pydantic v1's conlist, thus not rebuildable from its args.""" + + __args__ = (int,) + + @pytest.mark.parametrize( ["typehint", "expected", "expected_py314"], [ (int, "int", None), (date, "", None), + (ConstrainedListValue, "", None), (Optional[date], "Optional[date]", "date | null"), (date | None, "date | null", None), (Optional[Path_fr], "Optional[Path_fr]", "Path_fr | null"),