From e64bc095f227bcad49a5203058744dfb82e28162 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 17 Jul 2026 00:48:08 +0900 Subject: [PATCH 1/3] Fix optional discriminator literals --- src/datamodel_code_generator/parser/base.py | 48 ++++++++++++++----- ...force_optional_discriminator_literals.json | 10 ++++ .../force_optional_pydantic_v2.py | 23 +++++++++ .../force_optional_pydantic_v2_dataclass.py | 29 +++++++++++ .../discriminator/required_pydantic_v2.py | 23 +++++++++ .../openapi/discriminator_force_optional.yaml | 38 +++++++++++++++ tests/main/openapi/test_main_openapi.py | 42 ++++++++++++++++ tests/main/test_dynamic_models.py | 21 ++++++++ tox.ini | 2 + 9 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 tests/data/expected/dynamic_models/force_optional_discriminator_literals.json create mode 100644 tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2.py create mode 100644 tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py create mode 100644 tests/data/expected/main/openapi/discriminator/required_pydantic_v2.py create mode 100644 tests/data/openapi/discriminator_force_optional.yaml diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 4c917aeb3..80f5ba184 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -2042,6 +2042,23 @@ def _get_enum_discriminator_literal(enum_source: Enum, value: DiscriminatorValue return member_value return value + def __set_force_optional_discriminator_literal_default( + self, + model: DataModel, + discriminator_field: DataModelFieldBase, + literal: DiscriminatorValue, + *, + can_retain_cache: bool, + ) -> None: + """Keep Pydantic v2 single-literal discriminator fields valid when forced optional.""" + if not self.force_optional_for_required_fields or not _is_pydantic_v2_data_model_field(discriminator_field): + return + + discriminator_field.default = literal + discriminator_field.required = False + discriminator_field.nullable = False + _clear_model_imports_cache_if_retained(model, can_retain_cache=can_retain_cache) + def __apply_discriminator_type( # noqa: PLR0912, PLR0914, PLR0915 self, models: list[DataModel], @@ -2127,24 +2144,31 @@ def __apply_discriminator_type( # noqa: PLR0912, PLR0914, PLR0915 for discriminator_field in discriminator_model.fields: if field_name not in {discriminator_field.original_name, discriminator_field.name}: continue - literals = discriminator_field.data_type.literals const_value = discriminator_field.extras.get("const") expected_value = discriminator_values[0] if discriminator_values else None - # Check if literals match (existing behavior) - literals_match = len(literals) == 1 and literals[0] == expected_value - # Check if const value matches (for msgspec with type: string + const) const_match = const_value is not None and const_value == expected_value - if literals_match: + if ( + len(literals := discriminator_field.data_type.literals) == 1 + and (literal := literals[0]) == expected_value + ): has_one_literal = True - if _is_msgspec_struct(discriminator_model): # pragma: no cover - _add_msgspec_base_class_kwarg(discriminator_model, "tag_field", f"'{field_name}'") - _add_msgspec_base_class_kwarg(discriminator_model, "tag", repr(expected_value)) - discriminator_field.extras["is_classvar"] = True - _clear_model_imports_cache_if_retained( - discriminator_model, can_retain_cache=can_retain_cache - ) + match discriminator_model: + case _ if _is_msgspec_struct(discriminator_model): # pragma: no cover + _add_msgspec_base_class_kwarg(discriminator_model, "tag_field", f"'{field_name}'") + _add_msgspec_base_class_kwarg(discriminator_model, "tag", repr(expected_value)) + discriminator_field.extras["is_classvar"] = True + _clear_model_imports_cache_if_retained( + discriminator_model, can_retain_cache=can_retain_cache + ) + case _: + self.__set_force_optional_discriminator_literal_default( + discriminator_model, + discriminator_field, + literal, + can_retain_cache=can_retain_cache, + ) # Found the discriminator field, no need to keep looking break diff --git a/tests/data/expected/dynamic_models/force_optional_discriminator_literals.json b/tests/data/expected/dynamic_models/force_optional_discriminator_literals.json new file mode 100644 index 000000000..185daad99 --- /dev/null +++ b/tests/data/expected/dynamic_models/force_optional_discriminator_literals.json @@ -0,0 +1,10 @@ +{ + "CardPayment": { + "kind": "card", + "card_number": null + }, + "Payment": { + "kind": "cash", + "received_amount": 50 + } +} diff --git a/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2.py b/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2.py new file mode 100644 index 000000000..c867afdd5 --- /dev/null +++ b/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: discriminator_force_optional.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, RootModel + + +class CardPayment(BaseModel): + kind: Literal['card'] = 'card' + card_number: int | None = None + + +class CashPayment(BaseModel): + kind: Literal['cash'] = 'cash' + received_amount: int | None = None + + +class Payment(RootModel[CardPayment | CashPayment | None]): + root: CardPayment | CashPayment | None = Field(None, discriminator='kind') diff --git a/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py b/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py new file mode 100644 index 000000000..7390f3c12 --- /dev/null +++ b/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py @@ -0,0 +1,29 @@ +# generated by datamodel-codegen: +# filename: discriminator_force_optional.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field +from pydantic.dataclasses import dataclass +from typing_extensions import TypeAliasType + + +@dataclass +class CardPayment: + kind: Literal['card'] = 'card' + card_number: int | None = None + + +@dataclass +class CashPayment: + kind: Literal['cash'] = 'cash' + received_amount: int | None = None + + +Payment = TypeAliasType( + "Payment", + Annotated[CardPayment | CashPayment | None, Field(None, discriminator='kind')], +) diff --git a/tests/data/expected/main/openapi/discriminator/required_pydantic_v2.py b/tests/data/expected/main/openapi/discriminator/required_pydantic_v2.py new file mode 100644 index 000000000..c22eedad1 --- /dev/null +++ b/tests/data/expected/main/openapi/discriminator/required_pydantic_v2.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: discriminator_force_optional.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, RootModel + + +class CardPayment(BaseModel): + kind: Literal['card'] + card_number: int + + +class CashPayment(BaseModel): + kind: Literal['cash'] + received_amount: int + + +class Payment(RootModel[CardPayment | CashPayment]): + root: CardPayment | CashPayment = Field(..., discriminator='kind') diff --git a/tests/data/openapi/discriminator_force_optional.yaml b/tests/data/openapi/discriminator_force_optional.yaml new file mode 100644 index 000000000..62d49b199 --- /dev/null +++ b/tests/data/openapi/discriminator_force_optional.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Force Optional Discriminator + version: "0.1.0" +paths: {} +components: + schemas: + CardPayment: + type: object + required: + - kind + - card_number + properties: + kind: + type: string + const: card + card_number: + type: integer + CashPayment: + type: object + required: + - kind + - received_amount + properties: + kind: + type: string + const: cash + received_amount: + type: integer + Payment: + oneOf: + - $ref: "#/components/schemas/CardPayment" + - $ref: "#/components/schemas/CashPayment" + discriminator: + propertyName: kind + mapping: + card: "#/components/schemas/CardPayment" + cash: "#/components/schemas/CashPayment" diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index c2959cb15..4b623b088 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -3653,6 +3653,48 @@ def test_main_openapi_discriminator_one_literal_as_default( ) +@pytest.mark.parametrize( + ("output_model", "optional_args", "expected_file"), + [ + ("pydantic_v2.BaseModel", (), "discriminator/required_pydantic_v2.py"), + ( + "pydantic_v2.BaseModel", + ("--force-optional",), + "discriminator/force_optional_pydantic_v2.py", + ), + ( + "pydantic_v2.BaseModel", + ("--force-optional", "--use-one-literal-as-default"), + "discriminator/force_optional_pydantic_v2.py", + ), + ( + "pydantic_v2.dataclass", + ("--force-optional",), + "discriminator/force_optional_pydantic_v2_dataclass.py", + ), + ], +) +def test_main_openapi_discriminator_optional_pydantic_v2( + output_model: str, optional_args: tuple[str, ...], expected_file: str, output_file: Path +) -> None: + """Keep required and force-optional Pydantic v2 discriminator literals importable.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "discriminator_force_optional.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file=expected_file, + extra_args=[ + "--target-python-version", + "3.10", + "--output-model-type", + output_model, + *optional_args, + ], + force_exec_validation=True, + ) + + @pytest.mark.skipif( black.__version__.split(".")[0] == "19", reason="Installed black doesn't support the old style", diff --git a/tests/main/test_dynamic_models.py b/tests/main/test_dynamic_models.py index 29fe8c1ce..7ba26ba6c 100644 --- a/tests/main/test_dynamic_models.py +++ b/tests/main/test_dynamic_models.py @@ -21,12 +21,14 @@ clear_dynamic_models_cache, generate, generate_dynamic_models, + load_yaml_dict_from_path, ) from datamodel_code_generator.config import GenerateConfig from datamodel_code_generator.enums import ModuleSplitMode from datamodel_code_generator.model.pydantic_v2 import UnionMode from datamodel_code_generator.types import StrictTypes from tests.conftest import assert_output +from tests.main.conftest import OPEN_API_DATA_PATH if TYPE_CHECKING: from typing import Any @@ -466,6 +468,25 @@ def test_openapi_auto_detection() -> None: ) +def test_force_optional_discriminator_literals() -> None: + """Keep force-optional discriminator literals valid in dynamic Pydantic v2 models.""" + schema = load_yaml_dict_from_path(OPEN_API_DATA_PATH / "discriminator_force_optional.yaml", "utf-8") + config = GenerateConfig( + input_file_type=InputFileType.OpenAPI, + output_model_type=DataModelType.PydanticV2BaseModel, + force_optional_for_required_fields=True, + ) + assert_dynamic_models( + schema, + { + "CardPayment": {}, + "Payment": {"kind": "cash", "received_amount": 50}, + }, + EXPECTED_PATH / "force_optional_discriminator_literals.json", + config=config, + ) + + def test_config_with_auto_input_type() -> None: """Test that input_file_type=Auto in config is auto-detected.""" schema = make_object_schema({"name": {"type": "string"}}) diff --git a/tox.ini b/tox.ini index 4ac5f534c..2fb181d49 100644 --- a/tox.ini +++ b/tox.ini @@ -366,6 +366,7 @@ commands = --junitxml {work_dir}{/}junit.{env_name}.xml \ tests/main/test_main_general.py::test_show_help_when_no_input \ tests/main/test_main_general.py::test_generated_pydantic_v2_model_accepts_runtime_value \ + tests/main/test_dynamic_models.py::test_force_optional_discriminator_literals \ tests/main/jsonschema/test_main_jsonschema.py::test_main_generate_pydantic_v2_dataclass_required_alias_field_pydantic20 \ tests/main/jsonschema/test_main_jsonschema.py::test_main_jsonschema_additional_properties_schema_with_properties \ tests/main/jsonschema/test_main_jsonschema.py::test_main_jsonschema_additional_properties_self_ref_target_python_314 \ @@ -374,6 +375,7 @@ commands = tests/main/jsonschema/test_main_jsonschema.py::test_main_jsonschema_additional_properties_nullable_self_ref_use_union_operator \ tests/main/jsonschema/test_main_jsonschema.py::test_main_jsonschema_legacy_pydantic_extra_custom_template \ tests/main/openapi/test_main_openapi.py::test_main_openapi_deprecated_field_pydantic26 \ + tests/main/openapi/test_main_openapi.py::test_main_openapi_discriminator_optional_pydantic_v2 \ tests/model/pydantic_v2/test_dataclass.py::test_data_model_field_keeps_existing_alias_fallback_state_pydantic20 dependency_groups = test From 1d8d2cc0b494b6c4734896cadf946df624843294 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 17 Jul 2026 00:55:19 +0900 Subject: [PATCH 2/3] Stabilize discriminator snapshots --- .../discriminator/force_optional_pydantic_v2_dataclass.py | 4 +++- tests/main/openapi/test_main_openapi.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py b/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py index 7390f3c12..200c1f3bf 100644 --- a/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py +++ b/tests/data/expected/main/openapi/discriminator/force_optional_pydantic_v2_dataclass.py @@ -25,5 +25,7 @@ class CashPayment: Payment = TypeAliasType( "Payment", - Annotated[CardPayment | CashPayment | None, Field(None, discriminator='kind')], + Annotated[ + CardPayment | CashPayment | None, Field(None, discriminator='kind') + ], ) diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 4b623b088..35df870ab 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -3685,6 +3685,8 @@ def test_main_openapi_discriminator_optional_pydantic_v2( assert_func=assert_file_content, expected_file=expected_file, extra_args=[ + "--formatters", + "builtin", "--target-python-version", "3.10", "--output-model-type", From 0a4370a46af2ca05fa2fec4dd9a880aa87f9fcf9 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 17 Jul 2026 01:08:55 +0900 Subject: [PATCH 3/3] Handle inferred discriminator literals --- src/datamodel_code_generator/parser/base.py | 50 +++++++++++++++---- ...force_optional_discriminator_literals.json | 4 ++ ...gle_value_anyof_use_enum_force_optional.py | 27 ++++++++++ ...integer_mapping_use_enum_force_optional.py | 31 ++++++++++++ .../openapi/discriminator_force_optional.yaml | 1 - tests/main/openapi/test_main_openapi.py | 37 ++++++++++++++ tests/main/test_dynamic_models.py | 1 + tox.ini | 1 + 8 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 tests/data/expected/main/openapi/discriminator/enum_single_value_anyof_use_enum_force_optional.py create mode 100644 tests/data/expected/main/openapi/discriminator/integer_mapping_use_enum_force_optional.py diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 80f5ba184..1f734d1b0 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -1120,6 +1120,23 @@ def _get_enum_from_base(discriminator_model: DataModel, field_name: str) -> Enum return None +def _get_single_discriminator_default( + data_type: DataType, + enum_source: Enum | None, + expected_value: DiscriminatorValue | None, +) -> DiscriminatorValue | Member | None: + """Return the only valid discriminator default after resolving its type.""" + if len(literals := data_type.literals) == 1: + return literals[0] + if ( + len(data_type.enum_member_literals) != 1 + or enum_source is None + or (member := enum_source.find_member(expected_value, coerce_strings=True)) is None + ): + return None + return member + + def _get_model_module_name(model: DataModel, model_path_to_module_name: Mapping[str, str]) -> str: return model_path_to_module_name.get(model.path, model.module_name) @@ -2046,7 +2063,7 @@ def __set_force_optional_discriminator_literal_default( self, model: DataModel, discriminator_field: DataModelFieldBase, - literal: DiscriminatorValue, + literal: DiscriminatorValue | Member, *, can_retain_cache: bool, ) -> None: @@ -2191,17 +2208,32 @@ def __apply_discriminator_type( # noqa: PLR0912, PLR0914, PLR0915 if field_data_type.reference: # pragma: no cover self.generation_store.detach_data_type_ref(field_data_type) - self.generation_store.replace_field_type( - discriminator_field, - self._create_discriminator_data_type( - enum_source, - discriminator_values, - discriminator_model, - imports, - ), + new_discriminator_data_type = self._create_discriminator_data_type( + enum_source, + discriminator_values, + discriminator_model, + imports, ) + self.generation_store.replace_field_type(discriminator_field, new_discriminator_data_type) discriminator_field.data_type.parent = discriminator_field discriminator_field.required = True + if ( + self.force_optional_for_required_fields + and ( + literal_default := _get_single_discriminator_default( + new_discriminator_data_type, + enum_source, + expected_value, + ) + ) + is not None + ): + self.__set_force_optional_discriminator_literal_default( + discriminator_model, + discriminator_field, + literal_default, + can_retain_cache=can_retain_cache, + ) imports.append(discriminator_field.imports) has_one_literal = True if not has_one_literal: diff --git a/tests/data/expected/dynamic_models/force_optional_discriminator_literals.json b/tests/data/expected/dynamic_models/force_optional_discriminator_literals.json index 185daad99..abdb2b8ed 100644 --- a/tests/data/expected/dynamic_models/force_optional_discriminator_literals.json +++ b/tests/data/expected/dynamic_models/force_optional_discriminator_literals.json @@ -3,6 +3,10 @@ "kind": "card", "card_number": null }, + "CashPayment": { + "kind": "cash", + "received_amount": null + }, "Payment": { "kind": "cash", "received_amount": 50 diff --git a/tests/data/expected/main/openapi/discriminator/enum_single_value_anyof_use_enum_force_optional.py b/tests/data/expected/main/openapi/discriminator/enum_single_value_anyof_use_enum_force_optional.py new file mode 100644 index 000000000..47fb8577d --- /dev/null +++ b/tests/data/expected/main/openapi/discriminator/enum_single_value_anyof_use_enum_force_optional.py @@ -0,0 +1,27 @@ +# generated by datamodel-codegen: +# filename: discriminator_enum_single_value_anyof.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, Field, RootModel + + +class ToolType(Enum): + function = 'function' + + +class FunctionToolCall(BaseModel): + id: str | None = None + type: Literal[ToolType.function] = ToolType.function + + +class CustomToolCall(BaseModel): + type: Literal['CustomToolCall'] = 'CustomToolCall' + + +class ToolCallUnion(RootModel[FunctionToolCall | CustomToolCall | None]): + root: FunctionToolCall | CustomToolCall | None = Field(None, discriminator='type') diff --git a/tests/data/expected/main/openapi/discriminator/integer_mapping_use_enum_force_optional.py b/tests/data/expected/main/openapi/discriminator/integer_mapping_use_enum_force_optional.py new file mode 100644 index 000000000..cdaec6d0a --- /dev/null +++ b/tests/data/expected/main/openapi/discriminator/integer_mapping_use_enum_force_optional.py @@ -0,0 +1,31 @@ +# generated by datamodel-codegen: +# filename: discriminator_integer_mapping.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from enum import IntEnum +from typing import Literal + +from pydantic import BaseModel, Field, RootModel + + +class Kind(IntEnum): + integer_1 = 1 + + +class Foo(BaseModel): + kind: Literal[Kind.integer_1] = Kind.integer_1 + + +class Kind1(IntEnum): + integer_2 = 2 + integer_3 = 3 + + +class Bar(BaseModel): + kind: Literal[Kind1.integer_2, Kind1.integer_3] + + +class Base(RootModel[Foo | Bar | None]): + root: Foo | Bar | None = Field(None, discriminator='kind') diff --git a/tests/data/openapi/discriminator_force_optional.yaml b/tests/data/openapi/discriminator_force_optional.yaml index 62d49b199..1eb3f13fd 100644 --- a/tests/data/openapi/discriminator_force_optional.yaml +++ b/tests/data/openapi/discriminator_force_optional.yaml @@ -24,7 +24,6 @@ components: properties: kind: type: string - const: cash received_amount: type: integer Payment: diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 35df870ab..baff67fc1 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -418,6 +418,43 @@ def test_main_openapi_discriminator_enum_single_value_anyof_use_enum(output_file ) +@pytest.mark.parametrize( + ("input_file", "expected_file"), + [ + ( + "discriminator_enum_single_value_anyof.yaml", + "discriminator/enum_single_value_anyof_use_enum_force_optional.py", + ), + ( + "discriminator_integer_mapping.yaml", + "discriminator/integer_mapping_use_enum_force_optional.py", + ), + ], +) +def test_main_openapi_discriminator_enum_use_values_force_optional( + input_file: str, expected_file: str, output_file: Path +) -> None: + """Default only single enum-member discriminator literals when forced optional.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / input_file, + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file=expected_file, + extra_args=[ + "--formatters", + "builtin", + "--target-python-version", + "3.10", + "--output-model-type", + "pydantic_v2.BaseModel", + "--force-optional", + "--use-enum-values-in-discriminator", + ], + force_exec_validation=True, + ) + + def test_main_openapi_discriminator_enum_single_value_msgspec(output_file: Path) -> None: """Single-value enum discriminator is used as the msgspec tag.""" run_main_and_assert( diff --git a/tests/main/test_dynamic_models.py b/tests/main/test_dynamic_models.py index 7ba26ba6c..e1b43efea 100644 --- a/tests/main/test_dynamic_models.py +++ b/tests/main/test_dynamic_models.py @@ -480,6 +480,7 @@ def test_force_optional_discriminator_literals() -> None: schema, { "CardPayment": {}, + "CashPayment": {}, "Payment": {"kind": "cash", "received_amount": 50}, }, EXPECTED_PATH / "force_optional_discriminator_literals.json", diff --git a/tox.ini b/tox.ini index 2fb181d49..5e191c20b 100644 --- a/tox.ini +++ b/tox.ini @@ -375,6 +375,7 @@ commands = tests/main/jsonschema/test_main_jsonschema.py::test_main_jsonschema_additional_properties_nullable_self_ref_use_union_operator \ tests/main/jsonschema/test_main_jsonschema.py::test_main_jsonschema_legacy_pydantic_extra_custom_template \ tests/main/openapi/test_main_openapi.py::test_main_openapi_deprecated_field_pydantic26 \ + tests/main/openapi/test_main_openapi.py::test_main_openapi_discriminator_enum_use_values_force_optional \ tests/main/openapi/test_main_openapi.py::test_main_openapi_discriminator_optional_pydantic_v2 \ tests/model/pydantic_v2/test_dataclass.py::test_data_model_field_keeps_existing_alias_fallback_state_pydantic20 dependency_groups = test