Skip to content

Commit 57364b3

Browse files
author
Moddingfox
committed
Adding support for pydantic extras in models
1 parent 6ad1dd6 commit 57364b3

File tree

4 files changed

+301
-2
lines changed

4 files changed

+301
-2
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ paths are considered internals and can change in minor and patch releases.
1414
v4.40.1 (2025-05-??)
1515
--------------------
1616

17+
Added
18+
^^^^^
19+
- Support for Pydantic models with ``extra`` field configuration (``allow``,
20+
``forbid``, ``ignore``). Models with ``extra="allow"`` now accept additional
21+
fields, while ``extra="forbid"`` properly rejects them and ``extra="ignore"``
22+
accepts but ignores extra fields during instantiation.
23+
1724
Fixed
1825
^^^^^
1926
- ``print_shtab`` incorrectly parsed from environment variable (`#725

jsonargparse/_core.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,8 +1147,24 @@ def check_values(cfg):
11471147
group_key = next((g for g in self.groups if key.startswith(g + ".")), None)
11481148
if group_key:
11491149
subkey = key[len(group_key) + 1 :]
1150-
raise NSKeyError(f"Group '{group_key}' does not accept nested key '{subkey}'")
1151-
raise NSKeyError(f"Key '{key}' is not expected")
1150+
# Check if this is a Pydantic model with extra configuration
1151+
group = self.groups[group_key]
1152+
should_raise_error = True
1153+
if hasattr(group, "group_class") and group.group_class:
1154+
from ._optionals import get_pydantic_extra_config
1155+
1156+
extra_config = get_pydantic_extra_config(group.group_class)
1157+
if extra_config == "allow":
1158+
# Allow extra fields - don't raise an error
1159+
should_raise_error = False
1160+
elif extra_config == "ignore":
1161+
# Ignore extra fields - don't raise an error, Pydantic will ignore during instantiation
1162+
should_raise_error = False
1163+
# For 'forbid' or None (default), raise error
1164+
if should_raise_error:
1165+
raise NSKeyError(f"Group '{group_key}' does not accept nested key '{subkey}'")
1166+
else:
1167+
raise NSKeyError(f"Key '{key}' is not expected")
11521168

11531169
try:
11541170
with parser_context(load_value_mode=self.parser_mode):

jsonargparse/_optionals.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,58 @@ def validate_annotated(value, typehint: type):
361361
from pydantic import TypeAdapter
362362

363363
return TypeAdapter(typehint).validate_python(value)
364+
365+
366+
def get_pydantic_extra_config(class_type) -> Optional[str]:
367+
"""Get the 'extra' configuration from a Pydantic model.
368+
369+
Args:
370+
class_type: The class to check for Pydantic extra configuration.
371+
372+
Returns:
373+
The extra configuration ('allow', 'forbid', 'ignore') or None if not a Pydantic model.
374+
"""
375+
pydantic_model_version = is_pydantic_model(class_type)
376+
if not pydantic_model_version:
377+
return None
378+
379+
try:
380+
381+
# Handle Pydantic v2 models
382+
if pydantic_model_version > 1:
383+
# Check for model_config attribute (Pydantic v2 style)
384+
if hasattr(class_type, "model_config"):
385+
config = class_type.model_config
386+
if hasattr(config, "get"):
387+
# ConfigDict case
388+
return config.get("extra")
389+
elif hasattr(config, "extra"):
390+
# Direct attribute access
391+
return config.extra
392+
393+
# Check for __config__ attribute (legacy support in v2)
394+
if hasattr(class_type, "__config__"):
395+
config = class_type.__config__
396+
if hasattr(config, "extra"):
397+
return config.extra
398+
399+
# Handle Pydantic v1 models (including v1 compatibility mode in v2)
400+
else:
401+
if hasattr(class_type, "__config__"):
402+
config = class_type.__config__
403+
if hasattr(config, "extra"):
404+
extra_value = config.extra
405+
# Handle Pydantic v1 Extra enum
406+
if hasattr(extra_value, "value"):
407+
return extra_value.value
408+
elif isinstance(extra_value, str):
409+
return extra_value
410+
else:
411+
# Convert enum to string by taking the last part after the dot
412+
return str(extra_value).split(".")[-1]
413+
414+
except Exception:
415+
# If anything goes wrong, return None to fall back to default behavior
416+
pass
417+
418+
return None

jsonargparse_tests/test_dataclass_like.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,62 @@ class NestedModel(pydantic.BaseModel):
825825
class PydanticNestedDict(pydantic.BaseModel):
826826
nested: Optional[Dict[str, NestedModel]] = None
827827

828+
# Helper function to create test models dynamically based on current pydantic version
829+
def _create_extra_models():
830+
"""Create pydantic models with extra field handling based on the current pydantic version."""
831+
# Check if the actual pydantic module supports v2 syntax
832+
# In pydantic v1, ConfigDict is just dict, in v2 it's a special class
833+
# In pydantic.v1 compatibility mode, ConfigDict exists but is in pydantic.v1.config module
834+
_pydantic_v2_syntax = (
835+
hasattr(pydantic, "ConfigDict")
836+
and hasattr(pydantic.ConfigDict, "__module__")
837+
and "pydantic.config" in pydantic.ConfigDict.__module__
838+
and "v1" not in pydantic.ConfigDict.__module__
839+
)
840+
841+
if _pydantic_v2_syntax:
842+
from pydantic import ConfigDict
843+
844+
class PydanticExtraAllow(pydantic.BaseModel):
845+
model_config = ConfigDict(extra="allow")
846+
name: str
847+
age: int = 25
848+
849+
class PydanticExtraForbid(pydantic.BaseModel):
850+
model_config = ConfigDict(extra="forbid")
851+
name: str
852+
age: int = 25
853+
854+
class PydanticExtraIgnore(pydantic.BaseModel):
855+
model_config = ConfigDict(extra="ignore")
856+
name: str
857+
age: int = 25
858+
859+
else:
860+
# Pydantic v1 style (including v1 compatibility mode)
861+
class PydanticExtraAllow(pydantic.BaseModel):
862+
name: str
863+
age: int = 25
864+
865+
class Config:
866+
extra = "allow"
867+
868+
class PydanticExtraForbid(pydantic.BaseModel):
869+
name: str
870+
age: int = 25
871+
872+
class Config:
873+
extra = "forbid"
874+
875+
class PydanticExtraIgnore(pydantic.BaseModel):
876+
name: str
877+
age: int = 25
878+
879+
class Config:
880+
extra = "ignore"
881+
882+
return PydanticExtraAllow, PydanticExtraForbid, PydanticExtraIgnore
883+
828884

829885
def none(x):
830886
return x
@@ -989,6 +1045,171 @@ def test_nested_dict(self, parser):
9891045
assert isinstance(init.model, PydanticNestedDict)
9901046
assert isinstance(init.model.nested["key"], NestedModel)
9911047

1048+
def test_extra_allow(self, parser):
1049+
"""Test that extra='allow' accepts and includes extra fields."""
1050+
PydanticExtraAllow, _, _ = _create_extra_models()
1051+
parser.add_argument("--model", type=PydanticExtraAllow, default=PydanticExtraAllow(name="default"))
1052+
1053+
# Test with parse_object (where validation logic applies)
1054+
cfg = parser.parse_object({"model": {"name": "John", "age": 30, "extra_field": "extra_value"}})
1055+
1056+
# Check that extra field is in the namespace
1057+
assert cfg.model.name == "John"
1058+
assert cfg.model.age == 30
1059+
assert cfg.model.extra_field == "extra_value"
1060+
1061+
# Check that instantiation includes the extra field
1062+
init = parser.instantiate_classes(cfg)
1063+
assert isinstance(init.model, PydanticExtraAllow)
1064+
assert init.model.name == "John"
1065+
assert init.model.age == 30
1066+
assert hasattr(init.model, "extra_field")
1067+
assert init.model.extra_field == "extra_value"
1068+
1069+
def test_extra_forbid(self, parser):
1070+
"""Test that extra='forbid' rejects extra fields with appropriate error."""
1071+
_, PydanticExtraForbid, _ = _create_extra_models()
1072+
parser.add_argument("--model", type=PydanticExtraForbid, default=PydanticExtraForbid(name="default"))
1073+
1074+
# Test with parse_object (where validation logic applies)
1075+
with pytest.raises(ArgumentError) as ctx:
1076+
parser.parse_object({"model": {"name": "John", "age": 30, "extra_field": "extra_value"}})
1077+
assert "does not accept nested key 'extra_field'" in str(ctx.value)
1078+
1079+
def test_extra_ignore(self, parser):
1080+
"""Test that extra='ignore' accepts but ignores extra fields."""
1081+
_, _, PydanticExtraIgnore = _create_extra_models()
1082+
parser.add_argument("--model", type=PydanticExtraIgnore, default=PydanticExtraIgnore(name="default"))
1083+
1084+
# Test with parse_object (where validation logic applies)
1085+
cfg = parser.parse_object({"model": {"name": "John", "age": 30, "extra_field": "extra_value"}})
1086+
1087+
# Check that extra field is in the namespace (parsing succeeded)
1088+
assert cfg.model.name == "John"
1089+
assert cfg.model.age == 30
1090+
assert cfg.model.extra_field == "extra_value"
1091+
1092+
# Check that instantiation ignores the extra field
1093+
init = parser.instantiate_classes(cfg)
1094+
assert isinstance(init.model, PydanticExtraIgnore)
1095+
assert init.model.name == "John"
1096+
assert init.model.age == 30
1097+
assert not hasattr(init.model, "extra_field")
1098+
1099+
def test_extra_default_behavior(self, parser):
1100+
"""Test that models without explicit extra config behave according to their Pydantic version defaults."""
1101+
parser.add_argument("--model", type=PydanticModel, default=PydanticModel(p1="default"))
1102+
1103+
from jsonargparse._optionals import is_pydantic_model
1104+
1105+
model_version = is_pydantic_model(PydanticModel)
1106+
1107+
if model_version == 1:
1108+
# Pydantic v1 models (including v1 compatibility mode) default to 'ignore'
1109+
cfg = parser.parse_object({"model": {"p1": "test", "p2": 5, "extra_field": "extra_value"}})
1110+
assert cfg.model.p1 == "test"
1111+
assert cfg.model.p2 == 5
1112+
assert cfg.model.extra_field == "extra_value"
1113+
1114+
# Check that instantiation ignores the extra field (Pydantic v1 default behavior)
1115+
init = parser.instantiate_classes(cfg)
1116+
assert isinstance(init.model, PydanticModel)
1117+
assert init.model.p1 == "test"
1118+
assert init.model.p2 == 5
1119+
assert not hasattr(init.model, "extra_field")
1120+
else:
1121+
# Pydantic v2 models default to 'forbid'
1122+
with pytest.raises(ArgumentError) as ctx:
1123+
parser.parse_object({"model": {"p1": "test", "p2": 5, "extra_field": "extra_value"}})
1124+
assert "does not accept nested key 'extra_field'" in str(ctx.value)
1125+
1126+
def test_extra_with_class_arguments(self, parser):
1127+
"""Test extra field handling when using add_class_arguments."""
1128+
PydanticExtraAllow, _, _ = _create_extra_models()
1129+
parser.add_class_arguments(PydanticExtraAllow, "model")
1130+
1131+
# Test with parse_object to include extra field
1132+
cfg = parser.parse_object({"model": {"name": "John", "age": 30, "extra_field": "extra_value"}})
1133+
1134+
assert cfg.model.name == "John"
1135+
assert cfg.model.age == 30
1136+
assert cfg.model.extra_field == "extra_value"
1137+
1138+
# Test instantiation
1139+
init = parser.instantiate_classes(cfg)
1140+
assert isinstance(init.model, PydanticExtraAllow)
1141+
assert init.model.extra_field == "extra_value"
1142+
1143+
def test_extra_config_function_coverage(self, parser):
1144+
"""Test edge cases in get_pydantic_extra_config function for coverage."""
1145+
from jsonargparse._optionals import get_pydantic_extra_config
1146+
1147+
# Test with non-pydantic class
1148+
class NonPydanticClass:
1149+
pass
1150+
1151+
assert get_pydantic_extra_config(NonPydanticClass) is None
1152+
1153+
# Test with pydantic model that has no extra config
1154+
class PydanticNoExtra(pydantic.BaseModel):
1155+
name: str
1156+
1157+
result = get_pydantic_extra_config(PydanticNoExtra)
1158+
# In pydantic v1, models without explicit extra config default to 'ignore'
1159+
# In pydantic v2, they default to 'forbid' (but our function returns None for default)
1160+
from jsonargparse._optionals import is_pydantic_model
1161+
1162+
model_version = is_pydantic_model(PydanticNoExtra)
1163+
if model_version == 1:
1164+
# Pydantic v1 has a default extra='ignore' behavior
1165+
assert result in [None, "ignore"] # Allow both since it depends on implementation details
1166+
else:
1167+
# Pydantic v2 models without explicit extra config
1168+
assert result is None
1169+
1170+
# Test with a model that has __config__ but no extra
1171+
class PydanticConfigNoExtra(pydantic.BaseModel):
1172+
name: str
1173+
1174+
class Config:
1175+
validate_assignment = True
1176+
1177+
result = get_pydantic_extra_config(PydanticConfigNoExtra)
1178+
# This should return None since no extra is specified
1179+
if model_version == 1:
1180+
assert result in [None, "ignore"] # v1 might have default behavior
1181+
else:
1182+
assert result is None
1183+
1184+
# Test with pydantic v1 enum if available
1185+
try:
1186+
# Import pydantic v1 directly to avoid regex replacement issues
1187+
from pydantic import v1 as pydantic_v1
1188+
1189+
class PydanticV1ExtraEnum(pydantic_v1.BaseModel):
1190+
name: str
1191+
1192+
class Config:
1193+
extra = pydantic_v1.Extra.allow
1194+
1195+
result = get_pydantic_extra_config(PydanticV1ExtraEnum)
1196+
assert result == "allow"
1197+
1198+
except (ImportError, AttributeError):
1199+
# pydantic v1 not available, skip this test
1200+
pass
1201+
1202+
# Test with a class that might cause an exception (edge case)
1203+
class ProblematicClass:
1204+
"""A class that might cause issues in the function."""
1205+
1206+
def __init__(self):
1207+
pass
1208+
1209+
# This should not raise an exception and should return None
1210+
result = get_pydantic_extra_config(ProblematicClass)
1211+
assert result is None
1212+
9921213

9931214
# attrs tests
9941215

0 commit comments

Comments
 (0)