From 2bde1f7dacf41fd8dc02d804256457ddd19f68f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20L=C3=A1t?= Date: Sat, 14 Sep 2024 16:07:33 +0200 Subject: [PATCH] Release 4.3.0 --- CHANGELOG.md | 13 +++++- pyproject.toml | 2 +- src/settings_doc/main.py | 30 ++---------- src/settings_doc/template_functions.py | 56 +++++++++++++++++++++++ src/settings_doc/templates/dotenv.jinja | 4 +- src/settings_doc/templates/markdown.jinja | 4 +- tests/conftest.py | 3 ++ tests/fixtures/enum_settings.py | 48 +++++++++++++++++++ tests/unit/test_dotenv.py | 47 +++++++++++++++++++ tests/unit/test_markdown.py | 47 +++++++++++++++++++ 10 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 src/settings_doc/template_functions.py create mode 100644 tests/fixtures/enum_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5f54a..886f6af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ Types of changes are: ## [Unreleased] +## [4.3.0] - 2024-09-14 + +### Features + +- Correct rendering of default values `IntEnum`, `StrEnum` (Python >= 3.11) and `StrEnum(str, Enum)` (Python < 3.11). +- Rendering of possible values from `Enum` subclasses. +- New template global functions: + - `is_enum`: like `isinstance(field.annotation, EnumMeta)` + - `fix_str_enum_value`: for correct displaying of `StrEnum(str, Enum)` values for Python < 3.11 + ## [4.2.0] - 2024-08-13 ### Features @@ -230,7 +240,8 @@ Add classifiers to the package. - Initial release -[Unreleased]: https://github.com/radeklat/settings-doc/compare/4.2.0...HEAD +[Unreleased]: https://github.com/radeklat/settings-doc/compare/4.3.0...HEAD +[4.3.0]: https://github.com/radeklat/settings-doc/compare/4.2.0...4.3.0 [4.2.0]: https://github.com/radeklat/settings-doc/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/radeklat/settings-doc/compare/4.0.1...4.1.0 [4.0.1]: https://github.com/radeklat/settings-doc/compare/4.0.0...4.0.1 diff --git a/pyproject.toml b/pyproject.toml index e6ae5c5..e193e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "settings-doc" -version = "4.2.0" +version = "4.3.0" description = "A command line tool for generating Markdown documentation and .env files from pydantic BaseSettings." authors = ["Radek Lát "] license = "MIT License" diff --git a/src/settings_doc/main.py b/src/settings_doc/main.py index b9d9456..ae29cc5 100644 --- a/src/settings_doc/main.py +++ b/src/settings_doc/main.py @@ -2,20 +2,18 @@ import logging import re import shutil -import sys -from collections.abc import Iterable as IterableCollection from enum import Enum, auto from os import listdir from pathlib import Path -from typing import Any, Dict, Final, Iterator, List, Literal, Optional, Tuple, Type +from typing import Dict, Final, Iterator, List, Optional, Tuple, Type import click from jinja2 import Environment, FileSystemLoader, Template, select_autoescape from pydantic.fields import FieldInfo -from pydantic_core import PydanticUndefined from pydantic_settings import BaseSettings from settings_doc import importing +from settings_doc.template_functions import JINJA_ENV_GLOBALS TEMPLATES_FOLDER: Final[Path] = Path(__file__).parent / "templates" LOGGER = logging.getLogger(__name__) @@ -37,30 +35,10 @@ def _generate_next_value_(name, start, count, last_values): # pylint: disable=n DEBUG = auto() -def is_values_with_descriptions(value: Any) -> bool: - if not isinstance(value, IterableCollection): - click.secho(f"`examples` must be iterable but `{value}` used.", fg="red") - raise click.Abort() - - return all(list(map(lambda item: isinstance(item, list) and 2 >= len(item) >= 1, value))) - - -def has_default_value(field: FieldInfo) -> bool: - return field.default is not PydanticUndefined - - def get_template(env: Environment, output_format: OutputFormat) -> Template: return env.get_template(f"{output_format.value}.jinja") -def is_typing_literal(field: FieldInfo) -> bool: - if sys.version_info < (3, 9) and field.annotation is not None and hasattr(field.annotation, "__origin__"): - return field.annotation.__origin__ is Literal - - # The class doesn't exist in Python 3.8 and below - return field.annotation.__class__.__name__ == "_LiteralGenericAlias" - - def _model_fields_recursive( cls: Type[BaseSettings], prefix: str, env_nested_delimiter: Optional[str] ) -> Iterator[Tuple[str, FieldInfo]]: @@ -125,9 +103,7 @@ def render( lstrip_blocks=True, keep_trailing_newline=True, ) - env.globals["is_values_with_descriptions"] = is_values_with_descriptions - env.globals["has_default_value"] = has_default_value - env.globals["is_typing_literal"] = is_typing_literal + env.globals.update(JINJA_ENV_GLOBALS) return get_template(env, output_format).render( heading_offset=heading_offset, diff --git a/src/settings_doc/template_functions.py b/src/settings_doc/template_functions.py new file mode 100644 index 0000000..9b76171 --- /dev/null +++ b/src/settings_doc/template_functions.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import sys +from enum import Enum, EnumMeta, IntEnum +from typing import Any, Callable +from typing import Iterable as IterableCollection +from typing import Literal + +import click +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + + +def _has_default_value(field: FieldInfo) -> bool: + return field.default is not PydanticUndefined + + +def _is_values_with_descriptions(value: Any) -> bool: + if not isinstance(value, IterableCollection): + click.secho(f"`examples` must be iterable but `{value}` used.", fg="red") + raise click.Abort() + + return all(list(map(lambda item: isinstance(item, list) and 2 >= len(item) >= 1, value))) + + +def _is_typing_literal(field: FieldInfo) -> bool: + if sys.version_info < (3, 9) and field.annotation is not None and hasattr(field.annotation, "__origin__"): + return field.annotation.__origin__ is Literal + + # The class doesn't exist in Python 3.8 and below + return field.annotation.__class__.__name__ == "_LiteralGenericAlias" + + +def _fix_str_enum_value(value: Any) -> Any: + """Fixes the value of an enum that subclasses `str`. + + In Python 3.10 and below, str + Enum can be used to create a StrEnum available in Python 3.11+. + However, the value of the enum is not a string but an instance of the enum. + """ + if (isinstance(value, str) and isinstance(value, Enum)) or isinstance(value, IntEnum): + return value.value + + return value + + +def _is_enum(field: FieldInfo) -> bool: + return isinstance(field.annotation, EnumMeta) + + +JINJA_ENV_GLOBALS: dict[str, Callable] = { + "has_default_value": _has_default_value, + "is_values_with_descriptions": _is_values_with_descriptions, + "is_typing_literal": _is_typing_literal, + "fix_str_enum_value": _fix_str_enum_value, + "is_enum": _is_enum, +} diff --git a/src/settings_doc/templates/dotenv.jinja b/src/settings_doc/templates/dotenv.jinja index a0a5a7f..870a5d3 100644 --- a/src/settings_doc/templates/dotenv.jinja +++ b/src/settings_doc/templates/dotenv.jinja @@ -13,6 +13,8 @@ {% endif %} {% if is_typing_literal(field) %} {% set possible_values = field.annotation.__args__ %} + {% elif is_enum(field) %} + {% set possible_values = field.annotation.__members__.values() | map(attribute='value') | list %} {% elif field.json_schema_extra and "possible_values" in field.json_schema_extra %} {% set possible_values = field.json_schema_extra.possible_values %} {% endif %} @@ -40,7 +42,7 @@ {% endfor %} {% endif %} {% endif %} -{% if not field.is_required() %}# {% endif %}{{ env_name|upper }}={% if has_default_value(field) and field.default is not none %}{{ field.default }}{% endif %} +{% if not field.is_required() %}# {% endif %}{{ env_name|upper }}={% if has_default_value(field) and field.default is not none %}{{ fix_str_enum_value(field.default) }}{% endif %} {% endfor %} diff --git a/src/settings_doc/templates/markdown.jinja b/src/settings_doc/templates/markdown.jinja index 7ea920a..e741545 100644 --- a/src/settings_doc/templates/markdown.jinja +++ b/src/settings_doc/templates/markdown.jinja @@ -14,7 +14,7 @@ {% else %}{% endif %}{{ heading(1) }} `{{ env_name|upper }}` -*{% if field.is_required() %}*Required*{% else %}Optional{% endif %}*{% if has_default_value(field) %}, default value: `{{ field.default }}`{% endif %} +*{% if field.is_required() %}*Required*{% else %}Optional{% endif %}*{% if has_default_value(field) %}, default value: `{{ fix_str_enum_value(field.default) }}`{% endif %} {% if field.description %} @@ -68,6 +68,8 @@ {% set possible_values = field.json_schema_extra.possible_values %} {% elif is_typing_literal(field) %} {% set possible_values = field.annotation.__args__ %} + {% elif is_enum(field) %} + {% set possible_values = field.annotation.__members__.values() | map(attribute='value') | list %} {% endif %} {% if possible_values %} diff --git a/tests/conftest.py b/tests/conftest.py index 1a0cdef..a3ead9b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,3 +27,6 @@ def pyproject_toml(project_root): def poetry(pyproject_toml): assert pyproject_toml.tool.poetry return pyproject_toml.tool.poetry + + +pytest_plugins = ["tests.fixtures.enum_settings"] diff --git a/tests/fixtures/enum_settings.py b/tests/fixtures/enum_settings.py new file mode 100644 index 0000000..33220b6 --- /dev/null +++ b/tests/fixtures/enum_settings.py @@ -0,0 +1,48 @@ +import enum +import sys +from typing import Type + +import pytest +from pydantic_settings import BaseSettings + + +@pytest.fixture(scope="session") +def str_enum_settings() -> Type[BaseSettings]: + if sys.version_info < (3, 11): + StrEnum = enum.Enum # pylint: disable=invalid-name + pytest.fail("StrEnum is not available in Python 3.10 and below") + else: + from enum import StrEnum # pylint: disable=import-outside-toplevel + + class LoggingLevelEnum(StrEnum): # type: ignore[valid-type, misc, unused-ignore] + DEBUG = "debug" + INFO = "info" + + class StrEnumSettings(BaseSettings): + logging_level: LoggingLevelEnum = LoggingLevelEnum.DEBUG + + return StrEnumSettings + + +@pytest.fixture(scope="session") +def str_enum_subclass_settings() -> Type[BaseSettings]: + class LoggingLevelEnum(str, enum.Enum): + DEBUG = "debug" + INFO = "info" + + class StrEnumSubclassSettings(BaseSettings): + logging_level: LoggingLevelEnum = LoggingLevelEnum.DEBUG + + return StrEnumSubclassSettings + + +@pytest.fixture(scope="session") +def int_enum_settings() -> Type[BaseSettings]: + class LoggingLevelEnum(enum.IntEnum): + DEBUG = 10 + INFO = 20 + + class IntEnumSettings(BaseSettings): + logging_level: LoggingLevelEnum = LoggingLevelEnum.DEBUG + + return IntEnumSettings diff --git a/tests/unit/test_dotenv.py b/tests/unit/test_dotenv.py index c272af3..9f71354 100644 --- a/tests/unit/test_dotenv.py +++ b/tests/unit/test_dotenv.py @@ -1,3 +1,4 @@ +import sys from typing import Type import pytest @@ -136,3 +137,49 @@ def should_generate_env_prefix_and_nested_delimiter(runner: CliRunner, mocker: M assert "prefix_direct=" in actual_string assert "prefix_sub_model__nested=" in actual_string assert "prefix_sub_model__deep__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: + @staticmethod + def should_generate_default_value(runner: CliRunner, mocker: MockerFixture, str_enum_settings: Type[BaseSettings]): + expected_string = "# logging_level=debug" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_settings, fmt="dotenv") + + @staticmethod + def should_generate_possible_values( + runner: CliRunner, mocker: MockerFixture, str_enum_settings: Type[BaseSettings] + ): + expected_string = "# possible values:\n# `debug`, `info`" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_settings, fmt="dotenv") + + +@pytest.mark.skipif(sys.version_info >= (3, 11), reason="StrEnum should be used in Python 3.11 and above") +class TestDotEnvFormatFromStrSubclassedEnum: + @staticmethod + def should_generate_default_value_from_str_subclass_enums( + runner: CliRunner, mocker: MockerFixture, str_enum_subclass_settings: Type[BaseSettings] + ): + expected_string = "# logging_level=debug" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_subclass_settings, fmt="dotenv") + + @staticmethod + def should_generate_possible_values( + runner: CliRunner, mocker: MockerFixture, str_enum_subclass_settings: Type[BaseSettings] + ): + expected_string = "# possible values:\n# `debug`, `info`" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_subclass_settings, fmt="dotenv") + + +class TestDotEnvFormatFromIntEnum: + @staticmethod + def should_generate_default_value(runner: CliRunner, mocker: MockerFixture, int_enum_settings: Type[BaseSettings]): + expected_string = "# logging_level=10" + assert expected_string in run_app_with_settings(mocker, runner, int_enum_settings, fmt="dotenv") + + @staticmethod + def should_generate_possible_values( + runner: CliRunner, mocker: MockerFixture, int_enum_settings: Type[BaseSettings] + ): + expected_string = "# possible values:\n# `10`, `20`" + assert expected_string in run_app_with_settings(mocker, runner, int_enum_settings, fmt="dotenv") diff --git a/tests/unit/test_markdown.py b/tests/unit/test_markdown.py index 7e63b87..ab6935c 100644 --- a/tests/unit/test_markdown.py +++ b/tests/unit/test_markdown.py @@ -1,3 +1,4 @@ +import sys from typing import Type import pytest @@ -223,3 +224,49 @@ def should_generate_env_prefix_and_nested_delimiter(runner: CliRunner, mocker: M assert "`prefix_direct`" in actual_string assert "`prefix_sub_model__nested`" in actual_string assert "`prefix_sub_model__deep__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: + @staticmethod + def should_generate_default_value(runner: CliRunner, mocker: MockerFixture, str_enum_settings: Type[BaseSettings]): + expected_string = "# `logging_level`\n\n*optional*, default value: `debug`\n\n" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_settings) + + @staticmethod + def should_generate_possible_values( + runner: CliRunner, mocker: MockerFixture, str_enum_settings: Type[BaseSettings] + ): + expected_string = "## possible values\n\n`debug`, `info`" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_settings) + + +@pytest.mark.skipif(sys.version_info >= (3, 11), reason="StrEnum should be used in Python 3.11 and above") +class TestMarkdownFormatFromStrSubclassedEnum: + @staticmethod + def should_generate_default_value_from_str_subclass_enums( + runner: CliRunner, mocker: MockerFixture, str_enum_subclass_settings: Type[BaseSettings] + ): + expected_string = "# `logging_level`\n\n*optional*, default value: `debug`\n\n" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_subclass_settings) + + @staticmethod + def should_generate_possible_values( + runner: CliRunner, mocker: MockerFixture, str_enum_subclass_settings: Type[BaseSettings] + ): + expected_string = "## possible values\n\n`debug`, `info`" + assert expected_string in run_app_with_settings(mocker, runner, str_enum_subclass_settings) + + +class TestMarkdownFormatFromIntEnum: + @staticmethod + def should_generate_default_value(runner: CliRunner, mocker: MockerFixture, int_enum_settings: Type[BaseSettings]): + expected_string = "# `logging_level`\n\n*optional*, default value: `10`\n\n" + assert expected_string in run_app_with_settings(mocker, runner, int_enum_settings) + + @staticmethod + def should_generate_possible_values( + runner: CliRunner, mocker: MockerFixture, int_enum_settings: Type[BaseSettings] + ): + expected_string = "## possible values\n\n`10`, `20`" + assert expected_string in run_app_with_settings(mocker, runner, int_enum_settings)