Skip to content

Commit

Permalink
Release 4.3.0
Browse files Browse the repository at this point in the history
  • Loading branch information
radeklat committed Sep 14, 2024
1 parent b82daea commit 2bde1f7
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 31 deletions.
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
license = "MIT License"
Expand Down
30 changes: 3 additions & 27 deletions src/settings_doc/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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]]:
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/settings_doc/template_functions.py
Original file line number Diff line number Diff line change
@@ -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,
}
4 changes: 3 additions & 1 deletion src/settings_doc/templates/dotenv.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -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 %}
4 changes: 3 additions & 1 deletion src/settings_doc/templates/markdown.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}

Expand Down Expand Up @@ -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 %}

Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
48 changes: 48 additions & 0 deletions tests/fixtures/enum_settings.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions tests/unit/test_dotenv.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
from typing import Type

import pytest
Expand Down Expand Up @@ -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")
47 changes: 47 additions & 0 deletions tests/unit/test_markdown.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
from typing import Type

import pytest
Expand Down Expand Up @@ -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)

0 comments on commit 2bde1f7

Please sign in to comment.