Skip to content

Commit

Permalink
Release 4.1.0 (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
radeklat authored Aug 13, 2024
1 parent e2bc581 commit 341e263
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 35 deletions.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ Types of changes are:

## [Unreleased]

## [4.1.0] - 2024-08-13

### Features

- Allow rendering documentation from code with `settings_doc.render()`.

## [4.0.1] - 2024-04-06

### Fixes
Expand Down Expand Up @@ -218,7 +224,8 @@ Add classifiers to the package.
- Initial release
[Unreleased]: https://github.com/radeklat/settings-doc/compare/4.0.1...HEAD
[Unreleased]: https://github.com/radeklat/settings-doc/compare/4.1.0...HEAD
[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
[4.0.0]: https://github.com/radeklat/settings-doc/compare/3.1.2...4.0.0
[3.1.2]: https://github.com/radeklat/settings-doc/compare/3.1.1...3.1.2
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<h1 align="center" style="border-bottom: none;">⚙&nbsp;📝&nbsp;&nbsp;Settings&nbsp;Doc&nbsp;&nbsp;📝&nbsp;⚙</h1>
<h3 align="center">A command line tool for generating Markdown documentation and .env files from <a href="">pydantic_settings.BaseSettings</a>.</h3>
<h3 align="center">A (command line) tool for generating Markdown documentation and .env files from <a href="">pydantic_settings.BaseSettings</a>.</h3>

<p align="center">
<a href="https://app.circleci.com/pipelines/github/radeklat/settings-doc?branch=main">
Expand Down Expand Up @@ -60,7 +60,7 @@ It is powered by the [Jinja2](https://jinja.palletsprojects.com/en/latest/) temp
pip install settings-doc
```

# Usage
# Command-line usage

See `settings-doc --help` for all options.

Expand Down Expand Up @@ -211,6 +211,22 @@ Log level.

# Advanced usage

## Rendering documentation in code

The `settings_doc.render()` function can be used to render the documentation in code. It returns a string with the rendered documentation. Using the [Minimal example](#minimal-example) from the command line usage, the code usage is as follows:

```python
from settings_doc import render, OutputFormat

print(
render(
class_name="AppSettings",
module="src.settings",
output_format=OutputFormat.MARKDOWN,
)
)
```

## Custom templates

`settings-doc` comes with a few built-in templates. You can override them or write completely new ones.
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.0.1"
version = "4.1.0"
description = "A command line tool for generating Markdown documentation and .env files from pydantic BaseSettings."
authors = ["Radek Lát <radek.lat@gmail.com>"]
license = "MIT License"
Expand Down
3 changes: 3 additions & 0 deletions src/settings_doc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from settings_doc.main import OutputFormat, render

__all__ = ["render", "OutputFormat"]
81 changes: 55 additions & 26 deletions src/settings_doc/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,53 @@ def _model_fields(cls: Type[BaseSettings]) -> Iterator[Tuple[str, FieldInfo]]:
yield from _model_fields_recursive(cls, cls.model_config["env_prefix"], cls.model_config["env_nested_delimiter"])


def render(
output_format: OutputFormat,
module_path: Optional[List[str]] = None,
class_path: Optional[List[str]] = None,
heading_offset: int = 0,
templates: Optional[List[Path]] = None,
) -> str:
"""Render the settings documentation."""
if not class_path and not module_path:
raise ValueError("No sources of data were specified.")

if module_path is None:
module_path = []

if class_path is None:
class_path = []

if templates is None:
templates = []

settings: Dict[Type[BaseSettings], None] = importing.import_class_path(tuple(class_path))
settings.update(importing.import_module_path(tuple(module_path)))

if not settings:
raise ValueError("No sources of data were found.")

fields = itertools.chain.from_iterable(_model_fields(cls) for cls in settings)
classes: Dict[Type[BaseSettings], List[FieldInfo]] = {cls: list(cls.model_fields.values()) for cls in settings}

env = Environment(
loader=FileSystemLoader(templates + [TEMPLATES_FOLDER]),
autoescape=select_autoescape(),
trim_blocks=True,
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

return get_template(env, output_format).render(
heading_offset=heading_offset,
fields=fields,
classes=classes,
)


@app.command()
def generate(
module_path: List[str] = Option(
Expand Down Expand Up @@ -145,32 +192,14 @@ def generate(
),
):
"""Formats `pydantic.BaseSettings` into various formats. By default, the output is to STDOUT."""
settings: Dict[Type[BaseSettings], None] = importing.import_class_path(tuple(class_path))
settings.update(importing.import_module_path(tuple(module_path)))

if not settings:
secho("No sources of data were specified. Use the '--module' or '--class' options.", fg=colors.RED, err=True)
raise Abort()

fields = itertools.chain.from_iterable(_model_fields(cls) for cls in settings)
classes: Dict[Type[BaseSettings], List[FieldInfo]] = {cls: list(cls.model_fields.values()) for cls in settings}

render_kwargs = {"heading_offset": heading_offset, "fields": fields, "classes": classes}

env = Environment(
loader=FileSystemLoader(templates + [TEMPLATES_FOLDER]),
autoescape=select_autoescape(),
trim_blocks=True,
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
render = get_template(env, output_format).render(**render_kwargs)
try:
rendered_doc = render(output_format, module_path, class_path, heading_offset, templates)
except ValueError as exc:
secho(str(exc) + " Check the '--module' or '--class' options.", fg=colors.RED, err=True)
raise Abort() from exc

if update_file is None:
print(render)
print(rendered_doc)
return

with open(update_file, encoding="utf-8") as file:
Expand All @@ -189,9 +218,9 @@ def generate(
)
raise Abort()

new_content = pattern.sub(f"\\1{render}\\2", content, count=1)
new_content = pattern.sub(f"\\1{rendered_doc}\\2", content, count=1)
else:
new_content = render
new_content = rendered_doc

file.write(new_content)

Expand Down
31 changes: 26 additions & 5 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Iterable as IterableCollection
from typing import Iterable, List, Optional, Type, Union
from typing import Iterable, List, Literal, Optional, Type, Union

from click.testing import Result
from jinja2 import Environment, Template
Expand All @@ -10,6 +10,30 @@
from settings_doc.main import app


def _mock_import_path(
path_type: Literal["class", "module"],
mocker: MockerFixture,
settings: Union[Type[BaseSettings], Iterable[Type[BaseSettings]]],
) -> None:
if not isinstance(settings, IterableCollection):
settings = [settings]

settings = {_: None for _ in settings}
mocker.patch(f"settings_doc.importing.import_{path_type}_path", return_value=settings)


def mock_import_class_path(
mocker: MockerFixture, settings: Union[Type[BaseSettings], Iterable[Type[BaseSettings]]]
) -> None:
_mock_import_path("class", mocker, settings)


def mock_import_module_path(
mocker: MockerFixture, settings: Union[Type[BaseSettings], Iterable[Type[BaseSettings]]]
) -> None:
_mock_import_path("module", mocker, settings)


def run_app_with_settings(
mocker: MockerFixture,
runner: CliRunner,
Expand Down Expand Up @@ -42,12 +66,9 @@ def run_app_with_settings(

if args is None:
args = []
if not isinstance(settings, IterableCollection):
settings = [settings]

settings = {_: None for _ in settings}
mock_import_class_path(mocker, settings)

mocker.patch("settings_doc.importing.import_class_path", return_value=settings)
result = runner.invoke(
app, ["generate", "--class", "THIS_SHOULD_NOT_BE_USED", "--output-format", fmt] + args, catch_exceptions=False
)
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/test_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from typing import Type

import pytest
from pydantic_settings import BaseSettings
from pytest_mock import MockerFixture

from settings_doc import OutputFormat, render
from tests.fixtures.valid_settings import SETTINGS_ATTR, FullSettings, ValidationAliasSettings
from tests.helpers import mock_import_class_path, mock_import_module_path


class TestRenderDotEnvFormat:
@staticmethod
@pytest.mark.parametrize(
"expected_string, settings_class",
[
pytest.param(f"{SETTINGS_ATTR}=\n", ValidationAliasSettings, id="validation alias with required value"),
pytest.param(
f"{SETTINGS_ATTR}=some_value\n\n", FullSettings, id="variable name with optional default value"
),
pytest.param("# use fullsettings like this\n", FullSettings, id="description"),
],
)
def should_generate_from_class_path(
mocker: MockerFixture, expected_string: str, settings_class: Type[BaseSettings]
):
mock_import_class_path(mocker, settings_class)
assert expected_string in render(OutputFormat.DOTENV, class_path=["MockSettings"]).lower()

@staticmethod
@pytest.mark.parametrize(
"expected_string, settings_class",
[
pytest.param(f"{SETTINGS_ATTR}=\n", ValidationAliasSettings, id="validation alias with required value"),
pytest.param(
f"{SETTINGS_ATTR}=some_value\n\n", FullSettings, id="variable name with optional default value"
),
pytest.param("# use fullsettings like this\n", FullSettings, id="description"),
],
)
def should_generate_from_module_path(
mocker: MockerFixture, expected_string: str, settings_class: Type[BaseSettings]
):
mock_import_module_path(mocker, settings_class)
assert expected_string in render(OutputFormat.DOTENV, module_path=["MockSettings"]).lower()

@staticmethod
@pytest.mark.parametrize(
"kwargs",
[
pytest.param({}, id="class_path unset, module_path unset"),
pytest.param({"class_path": []}, id="class_path empty, module_path unset"),
pytest.param({"module_path": []}, id="module_path empty, class_path unset"),
pytest.param({"class_path": [], "module_path": []}, id="class_path and module_path empty"),
],
)
def should_raise_value_error_when_no_source_data_specified(kwargs):
with pytest.raises(ValueError, match="No sources of data were specified."):
render(OutputFormat.DOTENV, **kwargs)

@staticmethod
def should_raise_value_error_when_source_data_is_empty(mocker: MockerFixture):
mock_import_class_path(mocker, [])
with pytest.raises(ValueError, match="No sources of data were found."):
render(OutputFormat.DOTENV, class_path=["MockSettings"])

0 comments on commit 341e263

Please sign in to comment.