Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Fixed
^^^^^
- Subclass defaults incorrectly taken from base class (`#743
<https://github.com/omni-us/jsonargparse/pull/743>`__).
- Linking entire dataclasses on instantiation not working (`#746
<https://github.com/omni-us/jsonargparse/pull/746>`__).


v4.40.1 (2025-07-24)
Expand Down
44 changes: 36 additions & 8 deletions jsonargparse/_link_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_ActionPrintConfig,
_ActionSubCommands,
_find_parent_action,
_find_parent_action_and_subcommand,
filter_default_actions,
)
from ._namespace import Namespace, split_key, split_key_leaf
Expand Down Expand Up @@ -47,7 +48,7 @@ def find_subclass_action_or_class_group(
parser: ArgumentParser,
key: str,
exclude: Optional[Union[Type[ArgparseAction], Tuple[Type[ArgparseAction], ...]]] = None,
) -> Optional[Union[ArgparseAction, "ArgumentGroup"]]:
) -> Optional[Union[ArgparseAction, ArgumentGroup]]:
from ._typehints import ActionTypeHint

action = _find_parent_action(parser, key, exclude=exclude)
Expand Down Expand Up @@ -142,23 +143,33 @@ def __init__(
]

# Set and check target action
self.target = (target, _find_parent_action(parser, target, exclude=exclude))
self.target = (target, find_parent_action_or_group(parser, target, exclude=exclude))
for key, action in self.source + [self.target]:
if action is None:
raise ValueError(f'No action for key "{key}".')
assert self.target[1] is not None

from ._core import ArgumentGroup
from ._typehints import ActionTypeHint

is_target_group = isinstance(self.target[1], ArgumentGroup)
is_target_subclass = ActionTypeHint.is_subclass_typehint(self.target[1], all_subtypes=False, also_lists=True)
valid_target_init_arg = is_target_subclass and target.startswith(f"{self.target[1].dest}.init_args.")
valid_target_leaf = self.target[1].dest == target
if not valid_target_leaf and is_target_subclass and not valid_target_init_arg:
prefix = f"{self.target[1].dest}.init_args."
raise ValueError(f'Target key expected to start with "{prefix}", got "{target}".')

# Remove target group and child actions
if is_target_group:
parser._action_groups.remove(self.target[1])
del parser.groups[target]
for action in list(parser._actions):
if action.dest == target or action.dest.startswith(f"{target}."):
parser._actions.remove(action)
# Replace target action with link action
if not is_target_subclass or valid_target_leaf:
elif not is_target_subclass or valid_target_leaf:
assert isinstance(self.target[1], ArgparseAction)
for key in self.target[1].option_strings:
parser._option_string_actions[key] = self
parser._actions[parser._actions.index(self.target[1])] = self
Expand All @@ -181,7 +192,7 @@ def __init__(
if target in parser.required_args:
parser.required_args.remove(target)
if is_target_subclass and not valid_target_leaf:
sub_add_kwargs = self.target[1].sub_add_kwargs # type: ignore[attr-defined]
sub_add_kwargs = self.target[1].sub_add_kwargs # type: ignore[union-attr]
if "linked_targets" not in sub_add_kwargs:
sub_add_kwargs["linked_targets"] = set()
subtarget = target.split(".init_args.", 1)[1]
Expand Down Expand Up @@ -209,10 +220,15 @@ def __init__(
type_attr = None
help_str = f"Use --{self.target[1].dest}.help for details."
else:
type_attr = getattr(self.target[1], "_typehint", self.target[1].type)
help_str = self.target[1].help
if is_target_group:
type_attr = self.target[1].group_class # type: ignore[union-attr]
help_str = self.target[1].title # type: ignore[union-attr]
else:
assert isinstance(self.target[1], ArgparseAction)
type_attr = getattr(self.target[1], "_typehint", self.target[1].type)
help_str = self.target[1].help
if help_str == import_module("jsonargparse._formatters").empty_help:
help_str = f"Target argument '{self.target[1].dest}' lacks type and help"
help_str = f"Target '{self.target[1].dest}' lacks type and help"

super().__init__(
[link_str],
Expand Down Expand Up @@ -392,8 +408,9 @@ def set_target_value(action: "ActionLink", value: Any, cfg: Namespace, logger) -

if ActionTypeHint.is_subclass_typehint(target_action, all_subtypes=False, also_lists=True):
if target_key == target_action.dest:
target_action._check_type(value) # type: ignore[attr-defined]
target_action._check_type(value) # type: ignore[union-attr]
else:
assert isinstance(target_action.dest, str)
parent = cfg.get(target_action.dest)
child_key = target_key[len(target_action.dest) + 1 :]
if isinstance(parent, list) and any(isinstance(i, Namespace) and child_key in i for i in parent):
Expand Down Expand Up @@ -473,6 +490,17 @@ def del_target_key(target_key):
ActionLink.strip_link_target_keys(subparsers[num], cfg[subcommand])


def find_parent_action_or_group(
parser: ArgumentParser,
key: str,
exclude: Optional[Union[Type[ArgparseAction], Tuple[Type[ArgparseAction], ...]]] = None,
) -> Optional[Union[ArgparseAction, ArgumentGroup]]:
action_or_group = _find_parent_action_and_subcommand(parser, key, exclude=exclude)[0]
if not action_or_group and parser.groups and key in parser.groups:
return parser.groups[key]
return action_or_group


def get_link_actions(parser: ArgumentParser, apply_on: str, skip=set()) -> List[ActionLink]:
if not hasattr(parser, "_links_group"):
return []
Expand Down
31 changes: 30 additions & 1 deletion jsonargparse_tests/test_link_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def test_on_parse_help_target_lacking_type_and_help(parser):
parser.add_argument("--b")
parser.link_arguments("a", "b")
help_str = get_parser_help(parser)
assert "Target argument 'b' lacks type and help" in help_str
assert "Target 'b' lacks type and help" in help_str


def test_on_parse_shallow_print_config(parser):
Expand Down Expand Up @@ -953,6 +953,35 @@ def test_on_instantiate_targets_passed_to_instantiator(parser):
assert init.model.applied_instantiation_links == {"model.init_args.optimizer.init_args.num_classes": 7}


@dataclass
class DataDep:
param: int = 1


@dataclass
class DepContainer:
dep: DataDep
ref: str = ""


def test_on_instantiate_target_entire_dataclass(parser, tmp_cwd):
parser.add_class_arguments(DataDep, "data")
parser.add_class_arguments(DepContainer, "container")
parser.link_arguments("data", "container.dep", apply_on="instantiate")

defaults = parser.get_defaults()
assert defaults == Namespace(data=Namespace(param=1), container=Namespace(ref=""))
cfg = parser.parse_args(["--data.param=2", "--container.ref=x"])
assert cfg == Namespace(data=Namespace(param=2), container=Namespace(ref="x"))
init = parser.instantiate_classes(cfg)
assert init.data is init.container.dep
assert init.container.dep.param == 2

help_str = get_parser_help(parser)
assert "data --> container.dep [applied on instantiate]" in help_str
assert "--container.dep" not in help_str


# link creation failures


Expand Down