diff --git a/src/settings_doc/main.py b/src/settings_doc/main.py index 968bda2..d8b82b2 100644 --- a/src/settings_doc/main.py +++ b/src/settings_doc/main.py @@ -5,6 +5,7 @@ import re import shutil from enum import Enum, auto +from inspect import isclass from os import listdir from pathlib import Path from typing import Final, Iterator @@ -61,6 +62,18 @@ def _model_fields_recursive( prefix + field_name + env_nested_delimiter, env_nested_delimiter, ) + elif isclass(model_field.annotation) and issubclass(model_field.annotation, BaseSettings): + # There are nested fields that do not require a delimiter to be joined. Generate variable names recursively. + submodel_prefix = model_field.annotation.model_config.get("env_prefix", "") + + if not submodel_prefix: + submodel_prefix = prefix + field_name + "_" + + yield from _model_fields_recursive( + model_field.annotation, + submodel_prefix, + model_field.annotation.model_config.get("env_nested_delimiter", None), + ) else: yield prefix + field_name, model_field diff --git a/tests/fixtures/valid_settings.py b/tests/fixtures/valid_settings.py index c255264..76e7801 100644 --- a/tests/fixtures/valid_settings.py +++ b/tests/fixtures/valid_settings.py @@ -2,6 +2,7 @@ from pydantic import AliasChoices, AliasPath, BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Annotated SETTINGS_ATTR = "logging_level" SETTINGS_MARKDOWN_FIRST_LINE = f"# `{SETTINGS_ATTR}`\n" @@ -107,3 +108,42 @@ class EnvPrefixAndNestedDelimiterSettings(BaseSettings): direct: str sub_model: SubModel + + +class SimpleSubModel(BaseModel): + leaf: str + + +class SettingsSubModelWithSubModel(BaseSettings): + model_config = SettingsConfigDict(env_prefix="PREFIX_SUB_MODEL_", env_nested_delimiter="__") + + nested: str + sub_model_2: SimpleSubModel + + +class SettingsWithSettingsSubModel(BaseSettings): + """Expected environment variables. + + - `PREFIX_DIRECT` + - `PREFIX_SUB_MODEL_NESTED` + - `PREFIX_SUB_MODEL_SUB_MODEL_2__LEAF` + """ + + model_config = SettingsConfigDict(env_prefix="PREFIX_") + + direct: str + sub_model_1: Annotated[SettingsSubModelWithSubModel, Field(default_factory=SettingsSubModelWithSubModel)] + + +class SettingsSubModel(BaseSettings): + model_config = SettingsConfigDict(env_prefix="") + leaf: str + + +class SettingsWithSettingsSubModelNoPrefixOrDelimiter(BaseSettings): + """Expected environment variables. + + - `EMPTY_LOGGING_LEVEL` + """ + + my_leaf: SettingsSubModel diff --git a/tests/integration/generate/test_import_module_path.py b/tests/integration/generate/test_import_module_path.py index c79a16b..f839f3d 100644 --- a/tests/integration/generate/test_import_module_path.py +++ b/tests/integration/generate/test_import_module_path.py @@ -17,6 +17,10 @@ MultipleSettings, PossibleValuesSettings, RequiredSettings, + SettingsSubModel, + SettingsSubModelWithSubModel, + SettingsWithSettingsSubModel, + SettingsWithSettingsSubModelNoPrefixOrDelimiter, ValidationAliasChoicesSettings, ValidationAliasPathSettings, ValidationAliasSettings, @@ -50,6 +54,10 @@ class TestImportModulePath: EnvPrefixSettings: None, EnvNestedDelimiterSettings: None, EnvPrefixAndNestedDelimiterSettings: None, + SettingsSubModelWithSubModel: None, + SettingsWithSettingsSubModel: None, + SettingsSubModel: None, + SettingsWithSettingsSubModelNoPrefixOrDelimiter: None, }, id="for a module with multiple matching classes", ), @@ -69,6 +77,10 @@ class TestImportModulePath: EnvPrefixSettings: None, EnvNestedDelimiterSettings: None, EnvPrefixAndNestedDelimiterSettings: None, + SettingsSubModelWithSubModel: None, + SettingsWithSettingsSubModel: None, + SettingsSubModel: None, + SettingsWithSettingsSubModelNoPrefixOrDelimiter: None, }, id="for multiple modules with multiple matching classes", ), diff --git a/tests/unit/test_dotenv.py b/tests/unit/test_dotenv.py index 9f71354..bd283b3 100644 --- a/tests/unit/test_dotenv.py +++ b/tests/unit/test_dotenv.py @@ -16,6 +16,8 @@ ExamplesSettings, FullSettings, PossibleValuesSettings, + SettingsWithSettingsSubModel, + SettingsWithSettingsSubModelNoPrefixOrDelimiter, ValidationAliasChoicesSettings, ValidationAliasPathSettings, ValidationAliasSettings, @@ -138,6 +140,24 @@ def should_generate_env_prefix_and_nested_delimiter(runner: CliRunner, mocker: M assert "prefix_sub_model__nested=" in actual_string assert "prefix_sub_model__deep__leaf=" in actual_string + @staticmethod + def should_generate_settings_with_settings_sub_model(runner: CliRunner, mocker: MockerFixture): + actual_string = run_app_with_settings(mocker, runner, SettingsWithSettingsSubModel, fmt="dotenv") + + assert "prefix_direct=" in actual_string + assert "prefix_sub_model_nested=" in actual_string + assert "prefix_sub_model_sub_model_2__leaf=" in actual_string + + @staticmethod + def should_generate_settings_with_settings_sub_model_no_prefix_or_delimiter( + runner: CliRunner, mocker: MockerFixture + ): + actual_string = run_app_with_settings( + mocker, runner, SettingsWithSettingsSubModelNoPrefixOrDelimiter, fmt="dotenv" + ) + + assert "my_leaf_leaf=" in actual_string + @pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum is not available in Python 3.10 and below") class TestDotEnvFormatFromStrEnum: diff --git a/tests/unit/test_markdown.py b/tests/unit/test_markdown.py index ab6935c..4d48421 100644 --- a/tests/unit/test_markdown.py +++ b/tests/unit/test_markdown.py @@ -20,6 +20,8 @@ MultipleSettings, PossibleValuesSettings, RequiredSettings, + SettingsWithSettingsSubModel, + SettingsWithSettingsSubModelNoPrefixOrDelimiter, ValidationAliasChoicesSettings, ValidationAliasPathSettings, ValidationAliasSettings, @@ -225,6 +227,22 @@ def should_generate_env_prefix_and_nested_delimiter(runner: CliRunner, mocker: M assert "`prefix_sub_model__nested`" in actual_string assert "`prefix_sub_model__deep__leaf`" in actual_string + @staticmethod + def should_generate_settings_with_settings_sub_model(runner: CliRunner, mocker: MockerFixture): + actual_string = run_app_with_settings(mocker, runner, SettingsWithSettingsSubModel) + + assert "`prefix_direct`" in actual_string + assert "`prefix_sub_model_nested`" in actual_string + assert "`prefix_sub_model_sub_model_2__leaf`" in actual_string + + @staticmethod + def should_generate_settings_with_settings_sub_model_no_prefix_or_delimiter( + runner: CliRunner, mocker: MockerFixture + ): + actual_string = run_app_with_settings(mocker, runner, SettingsWithSettingsSubModelNoPrefixOrDelimiter) + + assert "`my_leaf_leaf`" in actual_string + @pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum is not available in Python 3.10 and below") class TestMarkdownFormatFromStrEnum: