pydantic-settings-export 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jag_k
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.1
2
+ Name: pydantic-settings-export
3
+ Version: 0.1.0
4
+ Summary: Export your Pydantic settings to a Markdown and .env.example files!
5
+ Home-page: https://github.com/jag-k/pydantic-settings-export#readme
6
+ License: MIT
7
+ Author: Jag_k
8
+ Author-email: jag-k@users.noreply.github.com
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: Environment :: Console
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Documentation
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: Utilities
21
+ Requires-Dist: pydantic (>=2,<3)
22
+ Requires-Dist: pydantic-settings (>=2,<3)
23
+ Project-URL: Repository, https://github.com/jag-k/pydantic-settings-export
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pydantic-settings-export
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/pydantic-settings-export?logo=pypi&label=pydantic-settings-export)](https://pypi.org/project/pydantic-settings-export/)
29
+
30
+ *Export your Pydantic settings to a Markdown and .env.example files!*
31
+
32
+ This package provides a way to use [pydantic](https://docs.pydantic.dev/) (and [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)) models to generate a Markdown file with the settings and their descriptions, and a `.env.example` file with the settings and their default values.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install pydantic-settings-export
38
+ # or
39
+ pipx install pydantic-settings-export # for a global installation and using as a CLI
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ You can see the examples of usage this package in the [./docs/Configuration.md](./docs/Configuration.md) and [.env.example](./.env.example).
45
+
46
+ ### As code
47
+
48
+ ```python
49
+ from pydantic import BaseSettings
50
+ from pydantic_settings_export import Exporter, MarkdownSettings, Settings as PSESettings
51
+
52
+
53
+ class Settings(BaseSettings):
54
+ my_setting: str = "default value"
55
+ another_setting: int = 42
56
+
57
+
58
+ # Export the settings to a Markdown file `docs/Configuration.md` and `.env.example` file
59
+ Exporter(
60
+ PSESettings(
61
+ markdown=MarkdownSettings(
62
+ save_dirs=["docs"],
63
+ ),
64
+ ),
65
+ ).run_all(Settings)
66
+ ```
67
+
68
+ ### As CLI
69
+
70
+ ```bash
71
+ pydantic-settings-export --help
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ You can add a `pydantic_settings_export` section to your `pyproject.toml` file to configure the exporter.
77
+
78
+ ```toml
79
+
80
+ [tool.pydantic_settings_export]
81
+ project_dir = "."
82
+ default_settings = [
83
+ "pydantic_settings_export.settings:Settings",
84
+ ]
85
+ dotenv = { "name" = ".env.example" }
86
+
87
+ [tool.pydantic_settings_export.markdown]
88
+ name = "Configuration.md"
89
+ save_dirs = [
90
+ "docs",
91
+ "wiki",
92
+ ]
93
+ ```
94
+
95
+ ## Todo
96
+
97
+ - [ ] Add tests
98
+ - [ ] Add more configuration options
99
+ - [ ] Add more output formats
100
+ - [ ] TOML (and `pyproject.toml`)
101
+ - [ ] JSON
102
+ - [ ] YAML
103
+
104
+
105
+ ## License
106
+
107
+ [MIT](./LICENCE)
108
+
@@ -0,0 +1,82 @@
1
+ # pydantic-settings-export
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/pydantic-settings-export?logo=pypi&label=pydantic-settings-export)](https://pypi.org/project/pydantic-settings-export/)
4
+
5
+ *Export your Pydantic settings to a Markdown and .env.example files!*
6
+
7
+ This package provides a way to use [pydantic](https://docs.pydantic.dev/) (and [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)) models to generate a Markdown file with the settings and their descriptions, and a `.env.example` file with the settings and their default values.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install pydantic-settings-export
13
+ # or
14
+ pipx install pydantic-settings-export # for a global installation and using as a CLI
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ You can see the examples of usage this package in the [./docs/Configuration.md](./docs/Configuration.md) and [.env.example](./.env.example).
20
+
21
+ ### As code
22
+
23
+ ```python
24
+ from pydantic import BaseSettings
25
+ from pydantic_settings_export import Exporter, MarkdownSettings, Settings as PSESettings
26
+
27
+
28
+ class Settings(BaseSettings):
29
+ my_setting: str = "default value"
30
+ another_setting: int = 42
31
+
32
+
33
+ # Export the settings to a Markdown file `docs/Configuration.md` and `.env.example` file
34
+ Exporter(
35
+ PSESettings(
36
+ markdown=MarkdownSettings(
37
+ save_dirs=["docs"],
38
+ ),
39
+ ),
40
+ ).run_all(Settings)
41
+ ```
42
+
43
+ ### As CLI
44
+
45
+ ```bash
46
+ pydantic-settings-export --help
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ You can add a `pydantic_settings_export` section to your `pyproject.toml` file to configure the exporter.
52
+
53
+ ```toml
54
+
55
+ [tool.pydantic_settings_export]
56
+ project_dir = "."
57
+ default_settings = [
58
+ "pydantic_settings_export.settings:Settings",
59
+ ]
60
+ dotenv = { "name" = ".env.example" }
61
+
62
+ [tool.pydantic_settings_export.markdown]
63
+ name = "Configuration.md"
64
+ save_dirs = [
65
+ "docs",
66
+ "wiki",
67
+ ]
68
+ ```
69
+
70
+ ## Todo
71
+
72
+ - [ ] Add tests
73
+ - [ ] Add more configuration options
74
+ - [ ] Add more output formats
75
+ - [ ] TOML (and `pyproject.toml`)
76
+ - [ ] JSON
77
+ - [ ] YAML
78
+
79
+
80
+ ## License
81
+
82
+ [MIT](./LICENCE)
@@ -0,0 +1,7 @@
1
+ from .constants import *
2
+ from .exporter import *
3
+ from .generators import *
4
+ from .models import *
5
+ from .settings import *
6
+ from .utils import *
7
+ from .version import __version__, __version_tuple__
@@ -0,0 +1,111 @@
1
+ import argparse
2
+
3
+ from collections.abc import Sequence
4
+ from inspect import isclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic_settings import BaseSettings
9
+
10
+ from pydantic_settings_export.exporter import Exporter
11
+ from pydantic_settings_export.generators import ALL_GENERATORS, AbstractGenerator
12
+ from pydantic_settings_export.settings import Settings
13
+ from pydantic_settings_export.utils import ObjectImportAction
14
+ from pydantic_settings_export.version import __version__
15
+
16
+
17
+ CDW = Path.cwd()
18
+
19
+
20
+ class SettingsAction(ObjectImportAction):
21
+ """The settings action."""
22
+
23
+ @staticmethod
24
+ def callback(obj: Any) -> type[BaseSettings]:
25
+ """Check if the object is a settings class."""
26
+ if isclass(obj) and issubclass(obj, BaseSettings):
27
+ return obj
28
+ elif not isclass(obj) and isinstance(obj, BaseSettings):
29
+ return obj.__class__
30
+ raise ValueError(f"The {obj!r} is not a settings class.")
31
+
32
+
33
+ class GeneratorAction(ObjectImportAction):
34
+ """The generator action."""
35
+
36
+ @staticmethod
37
+ def callback(obj: Any) -> type[AbstractGenerator]:
38
+ """Check if the object is a settings class."""
39
+ if isclass(obj) and issubclass(obj, AbstractGenerator):
40
+ return obj
41
+ elif not isclass(obj) and isinstance(obj, AbstractGenerator):
42
+ return obj.__class__
43
+ raise ValueError(f"The {obj!r} is not a generator class.")
44
+
45
+
46
+ def dir_type(path: str) -> Path:
47
+ """Check if the path is a directory."""
48
+ p = Path(path).resolve().absolute()
49
+ if p.is_dir():
50
+ return p
51
+ raise argparse.ArgumentTypeError(f"The {path} is not a directory.")
52
+
53
+
54
+ parser = argparse.ArgumentParser(
55
+ description="Export pydantic settings to a file",
56
+ )
57
+ parser.add_argument(
58
+ "--version",
59
+ "-v",
60
+ action="version",
61
+ version=f"pydantic-settings-export {__version__}",
62
+ )
63
+
64
+ parser.add_argument(
65
+ "--project-dir",
66
+ "-d",
67
+ default=CDW,
68
+ type=dir_type,
69
+ help="The project directory. (default: current directory)",
70
+ )
71
+ parser.add_argument(
72
+ "--config-file",
73
+ "-c",
74
+ default=CDW / "pyproject.toml",
75
+ type=argparse.FileType("rb"),
76
+ help="Path to `pyproject.toml` file. (default: ./pyproject.toml)",
77
+ )
78
+ parser.add_argument(
79
+ "--generator",
80
+ "-g",
81
+ default=ALL_GENERATORS,
82
+ action=GeneratorAction,
83
+ help=f"The generator class or object to use. (default: [{', '.join(g.__name__ for g in ALL_GENERATORS)}])",
84
+ )
85
+ parser.add_argument(
86
+ "settings",
87
+ nargs="*",
88
+ action=SettingsAction,
89
+ help="The settings classes or objects to export.",
90
+ )
91
+
92
+
93
+ def main(parse_args: Sequence[str] | None = None): # noqa: D103
94
+ args = parser.parse_args(parse_args)
95
+ s = Settings.from_pyproject(args.config_file)
96
+
97
+ s.project_dir = args.project_dir
98
+ s.generators = args.generator
99
+ settings = s.default_settings or args.settings
100
+ if not settings:
101
+ parser.exit(0, parser.format_help())
102
+
103
+ result = Exporter(s).run_all(*settings)
104
+ if result:
105
+ files = "\n".join(f"- {r}" for r in result)
106
+ parser.exit(0, f"Generated files ({len(result)}): \n{files}\n")
107
+ parser.exit(0, "No files generated.\n")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ from pydantic import BeforeValidator, SecretStr
5
+
6
+
7
+ __all__ = (
8
+ "StrAsPath",
9
+ "FIELD_TYPE_MAP",
10
+ )
11
+
12
+ StrAsPath = Annotated[Path, BeforeValidator(lambda v: Path(v))]
13
+
14
+ FIELD_TYPE_MAP = {
15
+ SecretStr: "string",
16
+ str: "string",
17
+ int: "integer",
18
+ float: "number",
19
+ bool: "boolean",
20
+ list: "array",
21
+ dict: "object",
22
+ None: "null",
23
+ }
@@ -0,0 +1,39 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+ from pydantic_settings_export.generators import AbstractGenerator
6
+ from pydantic_settings_export.models import SettingsInfoModel
7
+ from pydantic_settings_export.settings import Settings
8
+
9
+
10
+ __all__ = ("Exporter",)
11
+
12
+
13
+ class Exporter:
14
+ """The exporter for pydantic settings."""
15
+
16
+ def __init__(
17
+ self,
18
+ settings: Settings | None = None,
19
+ generators: list[type[AbstractGenerator]] | None = None,
20
+ ) -> None:
21
+ self.settings: Settings = settings or Settings.from_pyproject()
22
+ self.generators: list[type[AbstractGenerator]] = settings.generators if generators is None else generators
23
+
24
+ def run_all(self, *settings: BaseSettings | type[BaseSettings]) -> list[Path]:
25
+ """Run all generators for the given settings.
26
+
27
+ :param settings: The settings to generate documentation for.
28
+ :return: The paths to generated documentations.
29
+ """
30
+ settings_infos: list[SettingsInfoModel] = [
31
+ SettingsInfoModel.from_settings_model(s, self.settings) for s in settings
32
+ ]
33
+
34
+ return [
35
+ # Run all generators for each setting info
36
+ path
37
+ for generator in self.generators
38
+ for path in generator.run(self.settings, *settings_infos)
39
+ ]
@@ -0,0 +1,6 @@
1
+ from .abstract import *
2
+ from .dotenv import *
3
+ from .markdown import *
4
+
5
+
6
+ ALL_GENERATORS: list[type[AbstractGenerator]] = [DotEnvGenerator, MarkdownGenerator]
@@ -0,0 +1,58 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+
4
+ from pydantic_settings_export.models import SettingsInfoModel
5
+ from pydantic_settings_export.settings import Settings
6
+
7
+
8
+ __all__ = ("AbstractGenerator",)
9
+
10
+
11
+ class AbstractGenerator(ABC):
12
+ """The abstract class for the configuration file generator."""
13
+
14
+ def __init__(self, settings: Settings) -> None:
15
+ """Initialize the AbstractGenerator.
16
+
17
+ :param settings: The settings for the generator.
18
+ """
19
+ self.settings = settings
20
+
21
+ @abstractmethod
22
+ def generate_single(self, settings_info: SettingsInfoModel, level: int = 1) -> str:
23
+ """Generate the configuration file content.
24
+
25
+ :param settings_info: The settings class to generate documentation for.
26
+ :param level: The level of nesting. Used for indentation.
27
+ :return: The generated documentation.
28
+ """
29
+ raise NotImplementedError
30
+
31
+ def generate(self, *settings_infos: SettingsInfoModel) -> str:
32
+ """Generate the configuration file content.
33
+
34
+ :param settings_infos: The settings info classes to generate documentation for.
35
+ :return: The generated documentation.
36
+ """
37
+ return "\n\n".join(self.generate_single(s).strip() for s in settings_infos).strip() + "\n"
38
+
39
+ @abstractmethod
40
+ def write_to_files(self, generated_result: str) -> list[Path]:
41
+ """Write the generated content to files.
42
+
43
+ :param generated_result: The result to write to files.
44
+ :return: The list of file paths written to.
45
+ """
46
+ raise NotImplementedError
47
+
48
+ @classmethod
49
+ def run(cls, settings: Settings, settings_info: SettingsInfoModel) -> list[Path]:
50
+ """Run the generator.
51
+
52
+ :param settings: The settings for the generator.
53
+ :param settings_info: The settings info to generate documentation for.
54
+ :return: The list of file paths written to.
55
+ """
56
+ generator = cls(settings)
57
+ result = generator.generate(settings_info)
58
+ return generator.write_to_files(result)
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic_settings_export.models import SettingsInfoModel
4
+
5
+ from .abstract import AbstractGenerator
6
+
7
+
8
+ __all__ = ("DotEnvGenerator",)
9
+
10
+
11
+ class DotEnvGenerator(AbstractGenerator):
12
+ """The .env example generator."""
13
+
14
+ def write_to_files(self, generated_result: str) -> list[Path]:
15
+ """Write the generated content to files.
16
+
17
+ :param generated_result: The result to write to files.
18
+ :return: The list of file paths written to.
19
+ """
20
+ file_path = self.settings.project_dir / self.settings.dotenv.name
21
+ file_path.write_text(generated_result)
22
+ return [file_path]
23
+
24
+ def generate_single(self, settings_info: SettingsInfoModel, level=1) -> str:
25
+ """Generate a .env example for a pydantic settings class.
26
+
27
+ :param level: The level of nesting. Used for indentation.
28
+ :param settings_info: The settings class to generate a .env example for.
29
+ :return: The generated .env example.
30
+ """
31
+ result = f"### {settings_info.name}\n\n"
32
+ for field in settings_info.fields:
33
+ field_name = f"{settings_info.env_prefix}{field.name.upper()}"
34
+ field_string = f"{field_name}={field.default}\n"
35
+
36
+ if not field.is_required:
37
+ field_string = f"# {field_string}"
38
+ result += field_string
39
+
40
+ result = result.strip() + "\n\n"
41
+
42
+ for child in settings_info.child_settings:
43
+ result += self.generate_single(child)
44
+
45
+ return result
@@ -0,0 +1,87 @@
1
+ from pathlib import Path
2
+ from typing import TypedDict
3
+
4
+ from pydantic_settings_export.models import SettingsInfoModel
5
+ from pydantic_settings_export.utils import make_pretty_md_table_from_dict
6
+
7
+ from .abstract import AbstractGenerator
8
+
9
+
10
+ __all__ = ("MarkdownGenerator",)
11
+
12
+
13
+ class TableRowDict(TypedDict):
14
+ """The table row dictionary."""
15
+
16
+ Name: str
17
+ Type: str
18
+ Default: str
19
+ Description: str
20
+ Example: str | None
21
+
22
+
23
+ class MarkdownGenerator(AbstractGenerator):
24
+ """The Markdown configuration file generator."""
25
+
26
+ def generate_single(self, settings_info: SettingsInfoModel, level: int = 1) -> str: # noqa: C901
27
+ """Generate Markdown documentation for a pydantic settings class.
28
+
29
+ :param settings_info: The settings class to generate documentation for.
30
+ :param level: The level of nesting. Used for indentation.
31
+ :return: The generated documentation.
32
+ """
33
+ docs = ("\n\n" + settings_info.docs).rstrip()
34
+
35
+ # Generate header
36
+ result = f"{'#' * level} {settings_info.name}{docs}\n\n"
37
+
38
+ # Add environment prefix if it exists
39
+ if settings_info.env_prefix:
40
+ result += f"**Environment Prefix**: `{settings_info.env_prefix}`\n\n"
41
+
42
+ # Generate fields
43
+ rows: list[TableRowDict] = [
44
+ TableRowDict(
45
+ Name=f"`{settings_info.env_prefix}{field.name.upper()}`",
46
+ Type=f"`{field.type}`",
47
+ Default=f"`{field.default}`" if not field.is_required else "*required*",
48
+ Description=field.description,
49
+ Example=f"`{field.example}`" if field.example else None,
50
+ )
51
+ for field in settings_info.fields
52
+ ]
53
+
54
+ if rows:
55
+ result += make_pretty_md_table_from_dict(rows) + "\n\n"
56
+
57
+ # Generate child settings
58
+ result += "\n\n".join(self.generate_single(child, level + 1).strip() for child in settings_info.child_settings)
59
+
60
+ return result
61
+
62
+ def generate(self, *settings_infos: SettingsInfoModel) -> str:
63
+ """Generate Markdown documentation for a pydantic settings class.
64
+
65
+ :param settings_infos: The settings class to generate documentation for.
66
+ :return: The generated documentation.
67
+ """
68
+ return (
69
+ "# Configuration\n\n"
70
+ "Here you can find all available configuration options using ENV variables.\n\n"
71
+ + "\n\n".join(self.generate_single(s, 2).strip() for s in settings_infos)
72
+ ).strip() + "\n"
73
+
74
+ def write_to_files(self, generated_result: str) -> list[Path]:
75
+ """Write the generated content to files.
76
+
77
+ :param generated_result: The result to write to files.
78
+ :return: The list of file paths written to.
79
+ """
80
+ file_paths = []
81
+ for d in self.settings.markdown.save_dirs:
82
+ d = d.absolute().resolve()
83
+ d.mkdir(parents=True, exist_ok=True)
84
+ p = d / self.settings.markdown.name
85
+ file_paths.append(p)
86
+ p.write_text(generated_result)
87
+ return file_paths
@@ -0,0 +1,165 @@
1
+ from inspect import getdoc, isclass
2
+ from pathlib import Path
3
+ from types import UnionType
4
+ from typing import Self
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
7
+ from pydantic.fields import FieldInfo
8
+ from pydantic_core import PydanticSerializationError, PydanticUndefined
9
+ from pydantic_settings import BaseSettings
10
+
11
+ from pydantic_settings_export.constants import FIELD_TYPE_MAP
12
+ from pydantic_settings_export.settings import Settings
13
+
14
+
15
+ __all__ = (
16
+ "FieldInfoModel",
17
+ "SettingsInfoModel",
18
+ )
19
+
20
+
21
+ class FieldInfoModel(BaseModel):
22
+ """Info about the field of the settings model."""
23
+
24
+ model_config = ConfigDict(arbitrary_types_allowed=True)
25
+
26
+ name: str = Field(..., description="The name of the field.")
27
+ type: str = Field(..., description="The type of the field.")
28
+ default: str | None = Field(
29
+ None,
30
+ description="The default value of the field.",
31
+ validate_default=False,
32
+ )
33
+ description: str | None = Field(None, description="The description of the field.")
34
+ example: str | None = Field(None, description="The example of the field.")
35
+ alias: str | None = Field(None, description="The alias of the field.")
36
+
37
+ @property
38
+ def is_required(self) -> bool:
39
+ """Check if the field is required."""
40
+ return self.default is PydanticUndefined
41
+
42
+ @staticmethod
43
+ def create_default(field: FieldInfo, global_settings: Settings | None = None) -> str | None:
44
+ """Make default value for the field.
45
+
46
+ :param field: The field info to generate default value for.
47
+ :param global_settings: The global settings.
48
+ :return: The default value for the field as string, or None if there is no default value.
49
+ """
50
+ default: object | PydanticUndefined = field.default
51
+
52
+ if default is PydanticUndefined and field.default_factory:
53
+ default = field.default_factory()
54
+
55
+ if (
56
+ # if we need to replace absolute paths
57
+ global_settings
58
+ and global_settings.relative_to.replace_abs_paths
59
+ # Check if default is a Path and is absolute
60
+ and isinstance(default, Path)
61
+ and default.is_absolute()
62
+ ):
63
+ # Make the default path relative to the global_settings
64
+ default = Path(global_settings.relative_to.alias) / default.relative_to(global_settings.project_dir)
65
+
66
+ if default is PydanticUndefined:
67
+ return None
68
+ try:
69
+ return TypeAdapter(field.annotation).dump_json(default).decode()
70
+ except PydanticSerializationError:
71
+ return str(default)
72
+
73
+ @classmethod
74
+ def from_settings_field(
75
+ cls,
76
+ name: str,
77
+ field: FieldInfo,
78
+ global_settings: Settings | None = None,
79
+ ) -> Self:
80
+ """Generate FieldInfoModel using name and field.
81
+
82
+ :param name: The name of the field.
83
+ :param field: The field info to generate FieldInfoModel from.
84
+ :param global_settings: The global settings.
85
+ :return: Instance of FieldInfoModel.
86
+ """
87
+ # Parse the annotation of the field
88
+ annotation = field.annotation
89
+
90
+ if isinstance(annotation, UnionType):
91
+ args = list(filter(bool, getattr(annotation, "__args__", [])))
92
+ annotation = args[0] if args else None
93
+
94
+ # Get the name from the alias if it exists
95
+ name: str = field.alias or name
96
+ # Get the type from the FIELD_TYPE_MAP if it exists
97
+ type_: str = FIELD_TYPE_MAP.get(annotation, annotation.__name__ if annotation else "any")
98
+ # Get the default value from the field if it exists
99
+ default = cls.create_default(field, global_settings)
100
+ # Get the description from the field if it exists
101
+ description: str | None = field.description or None
102
+ # Get the example from the field if it exists
103
+ example: str | None = str(field.examples[0]) if field.examples else default
104
+
105
+ return cls(
106
+ name=name,
107
+ type=type_,
108
+ default=default,
109
+ description=description,
110
+ example=example,
111
+ alias=field.alias,
112
+ )
113
+
114
+
115
+ class SettingsInfoModel(BaseModel):
116
+ """Info about the settings model."""
117
+
118
+ name: str = Field(..., description="The name of the settings model.")
119
+ docs: str = Field("", description="The documentation of the settings model.")
120
+ env_prefix: str = Field("", description="The prefix of the environment variables.")
121
+ fields: list[FieldInfoModel] = Field(default_factory=list, description="The fields of the settings model.")
122
+ child_settings: list["SettingsInfoModel"] = Field(
123
+ default_factory=list, description="The child settings of the settings model."
124
+ )
125
+
126
+ @classmethod
127
+ def from_settings_model(
128
+ cls,
129
+ settings: BaseSettings | type[BaseSettings],
130
+ global_settings: Settings | None = None,
131
+ ) -> Self:
132
+ """Generate SettingsInfoModel using a settings model.
133
+
134
+ :param settings: The settings model to generate SettingsInfoModel from.
135
+ :param global_settings: The global settings.
136
+ :return: Instance of SettingsInfoModel.
137
+ """
138
+ conf = settings.model_config
139
+ fields_info = settings.model_fields
140
+
141
+ child_settings = []
142
+ fields = []
143
+ for name, field_info in fields_info.items():
144
+ if global_settings and global_settings.respect_exclude and field_info.exclude:
145
+ continue
146
+ annotation = field_info.annotation
147
+ if isclass(annotation) and issubclass(annotation, BaseSettings):
148
+ child_settings.append(cls.from_settings_model(annotation, global_settings=global_settings))
149
+ continue
150
+ fields.append(FieldInfoModel.from_settings_field(name, field_info, global_settings))
151
+
152
+ return cls(
153
+ name=(
154
+ # Get the title from the settings model if it exists
155
+ conf.get("title", None)
156
+ # Otherwise, get the name from the settings model if it exists
157
+ or getattr(settings, "__name__", None)
158
+ # Otherwise, get the class name from the settings model
159
+ or str(settings.__class__.__name__)
160
+ ),
161
+ docs=(getdoc(settings) or "").strip(),
162
+ env_prefix=conf.get("env_prefix", ""),
163
+ fields=fields,
164
+ child_settings=child_settings,
165
+ )
@@ -0,0 +1,122 @@
1
+ from pathlib import Path
2
+ from typing import TYPE_CHECKING, Self
3
+
4
+ from pydantic import Field, ImportString
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ from pydantic_settings_export.constants import StrAsPath
8
+ from pydantic_settings_export.utils import get_config_from_pyproject_toml
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from pydantic_settings_export.generators.abstract import AbstractGenerator # noqa: F401
13
+
14
+ __all__ = (
15
+ "MarkdownSettings",
16
+ "DotEnvSettings",
17
+ "RelativeToSettings",
18
+ "Settings",
19
+ )
20
+
21
+
22
+ class RelativeToSettings(BaseSettings):
23
+ """Settings for the relative directory."""
24
+
25
+ model_config = SettingsConfigDict(
26
+ title="Relative Directory Settings",
27
+ env_prefix="RELATIVE_TO_",
28
+ )
29
+
30
+ replace_abs_paths: bool = Field(True, description="Replace absolute paths with relative path to project root.")
31
+ alias: str = Field("<project_dir>", description="The alias for the relative directory.")
32
+
33
+
34
+ class MarkdownSettings(BaseSettings):
35
+ """Settings for the markdown file."""
36
+
37
+ model_config = SettingsConfigDict(
38
+ title="Configuration File Settings",
39
+ env_prefix="CONFIG_FILE_",
40
+ )
41
+
42
+ enabled: bool = Field(True, description="Enable the configuration file generation.")
43
+ name: str = Field("Configuration.md", description="The name of the configuration file.")
44
+
45
+ save_dirs: list[StrAsPath] = Field(
46
+ default_factory=list, description="The directories to save configuration files to."
47
+ )
48
+
49
+ def __bool__(self) -> bool:
50
+ """Check if the configuration file is set."""
51
+ return self.enabled and bool(self.save_dirs)
52
+
53
+
54
+ class DotEnvSettings(BaseSettings):
55
+ """Settings for the .env file."""
56
+
57
+ model_config = SettingsConfigDict(
58
+ title=".env File Settings",
59
+ env_prefix="DOTENV_",
60
+ )
61
+
62
+ name: str = Field(".env.example", description="The name of the .env file.")
63
+
64
+
65
+ class Settings(BaseSettings):
66
+ """Global settings for pydantic_settings_export."""
67
+
68
+ model_config = SettingsConfigDict(
69
+ title="Global Settings",
70
+ env_prefix="PYDANTIC_SETTINGS_EXPORT_",
71
+ plugin_settings={
72
+ "pyproject_toml": {
73
+ "package_name": "pydantic_settings_export",
74
+ }
75
+ },
76
+ )
77
+
78
+ default_settings: list[ImportString] = Field(
79
+ default_factory=list,
80
+ description="The default settings to use. The settings are applied in the order they are listed.",
81
+ )
82
+
83
+ project_dir: Path = Field(
84
+ Path.cwd(),
85
+ description="The project directory. Used for relative paths in the configuration file and .env file.",
86
+ )
87
+
88
+ relative_to: RelativeToSettings = Field(
89
+ default_factory=RelativeToSettings,
90
+ description="The relative directory settings.",
91
+ )
92
+ markdown: MarkdownSettings = Field(
93
+ default_factory=MarkdownSettings,
94
+ description="The configuration of markdown file settings.",
95
+ )
96
+ dotenv: DotEnvSettings = Field(
97
+ default_factory=DotEnvSettings,
98
+ description="The .env file settings.",
99
+ )
100
+
101
+ respect_exclude: bool = Field(
102
+ True,
103
+ description="Respect the exclude attribute in the fields.",
104
+ )
105
+
106
+ generators: list = Field( # type: list[type[AbstractGenerator]]
107
+ default_factory=list,
108
+ description="The list of generators to use.",
109
+ exclude=True,
110
+ )
111
+
112
+ @classmethod
113
+ def from_pyproject(cls, base_path: Path | None = None) -> Self:
114
+ """Load settings from the pyproject.toml file.
115
+
116
+ :param base_path: The base path to search for the pyproject.toml file, or this file itself.
117
+ The current working directory is used by default.
118
+ :return: The loaded settings.
119
+ """
120
+ config = get_config_from_pyproject_toml(cls, base_path)
121
+ config.setdefault("project_dir", str(base_path.parent))
122
+ return cls(**config)
@@ -0,0 +1,177 @@
1
+ import argparse
2
+ import importlib
3
+ import sys
4
+ import tomllib
5
+
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ __all__ = (
11
+ "find_pyproject_toml",
12
+ "make_pretty_md_table",
13
+ "make_pretty_md_table_from_dict",
14
+ )
15
+
16
+ from pydantic_settings import BaseSettings
17
+
18
+
19
+ def make_pretty_md_table(header: list[str], rows: list[list[str]]) -> str: # noqa: C901
20
+ """Make a pretty Markdown table with column alignment.
21
+
22
+ :param header: The header of the table.
23
+ :param rows: The rows of the table.
24
+ :return: The prettied Markdown table.
25
+ """
26
+ col_sizes = [len(h) for h in header]
27
+ for row in rows:
28
+ for i, cell in enumerate(row):
29
+ if cell is None:
30
+ cell = ""
31
+ col_sizes[i] = max(col_sizes[i], len(cell))
32
+
33
+ result = "|"
34
+ for i, h in enumerate(header):
35
+ result += f" {h}{' ' * (col_sizes[i] - len(h))} |"
36
+ result += "\n|"
37
+ for i, _ in enumerate(header):
38
+ result += f"{'-' * (col_sizes[i] + 2)}|"
39
+ for row in rows:
40
+ result += "\n|"
41
+ for i, cell in enumerate(row):
42
+ if cell is None:
43
+ cell = ""
44
+ result += f" {cell}{' ' * (col_sizes[i] - len(cell))} |"
45
+ return result
46
+
47
+
48
+ def make_pretty_md_table_from_dict(data: list[dict[str, str | None]]) -> str:
49
+ """Make a pretty Markdown table with column alignment from a list of dictionaries.
50
+
51
+ :param data: The rows of the table as dictionaries.
52
+ :return: The prettied Markdown table.
53
+ """
54
+ # Save unique keys from all rows and save order
55
+ header: list[str] = list(
56
+ {
57
+ # We need only key
58
+ key: 0
59
+ for row in data
60
+ for key in row.keys()
61
+ }.keys(),
62
+ )
63
+ rows = [[row.get(key, None) or "" for key in header] for row in data]
64
+ return make_pretty_md_table(header, rows)
65
+
66
+
67
+ def find_pyproject_toml(search_from: Path | None = None) -> Path | None:
68
+ """Find the pyproject.toml file in the current working directory or its parents.
69
+
70
+ :param search_from: The directory to start searching from.
71
+ :return: The path to the pyproject.toml file or None if it wasn't found.
72
+ """
73
+ if not search_from:
74
+ search_from = Path.cwd()
75
+ for parent in (search_from, *search_from.parents):
76
+ pyproject_toml = parent / "pyproject.toml"
77
+ if pyproject_toml.is_file():
78
+ return pyproject_toml
79
+ return None
80
+
81
+
82
+ def get_tool_name(settings: type[BaseSettings]) -> str | None:
83
+ """Get the tool name from the settings.
84
+
85
+ :param settings: The settings class to get the tool name from.
86
+ :return: The tool name.
87
+ """
88
+ return settings.model_config.get("plugin_settings", {}).get("pyproject_toml", {}).get("package_name", None)
89
+
90
+
91
+ def get_config_from_pyproject_toml(settings: type[BaseSettings], base_path: Path | None = None) -> dict:
92
+ """Get the configuration from the pyproject.toml file.
93
+
94
+ :param base_path: The base path to search for the pyproject.toml file, or this file itself.
95
+ The current working directory is used by default.
96
+ :param settings: The settings class to create the settings from.
97
+ :return: The created settings.
98
+ """
99
+ tool_name = get_tool_name(settings)
100
+
101
+ if not tool_name:
102
+ raise ValueError("The tool name is not set in the settings.")
103
+
104
+ if not base_path:
105
+ base_path = Path.cwd()
106
+
107
+ if not base_path.is_file():
108
+ base_path = find_pyproject_toml(base_path)
109
+
110
+ if not base_path:
111
+ raise FileNotFoundError("The pyproject.toml file was not found.")
112
+
113
+ with open(base_path, "rb") as file:
114
+ data = tomllib.load(file)
115
+
116
+ return data.get("tool", {}).get(tool_name, {})
117
+
118
+
119
+ class ObjectImportAction(argparse.Action):
120
+ """Import the object from the module."""
121
+
122
+ @staticmethod
123
+ def callback(obj: Any) -> Any:
124
+ """Check if the object is a settings class."""
125
+ return obj
126
+
127
+ @staticmethod
128
+ def import_obj(value: str) -> Any:
129
+ """Import the object from the module.
130
+
131
+ :param value: The value in the format 'module:class'.
132
+ :raise ValueError: If the value is not in the format 'module:class'.
133
+ :raise ValueError: If the class is not in the module.
134
+ :raise ModuleNotFoundError: If the module is not found.
135
+ :return: The imported object.
136
+ """
137
+ try:
138
+ module_name, class_name = value.rsplit(":", 1)
139
+ except ValueError:
140
+ raise ValueError(f"The {value!r} is not in the format 'module:class'.") from None
141
+
142
+ module = importlib.import_module(module_name)
143
+
144
+ obj = getattr(module, class_name, None)
145
+ if obj is None:
146
+ raise ValueError(f"The {class_name!r} is not in the module {module_name!r}.")
147
+ return obj
148
+
149
+ def __call__(
150
+ self,
151
+ parser: argparse.ArgumentParser,
152
+ namespace: argparse.Namespace,
153
+ values: list[str],
154
+ option_string: str | None = None,
155
+ ) -> None:
156
+ """Import the object from the module."""
157
+ # Add the project directory to the sys.path
158
+ sys.path.insert(0, str(namespace.project_dir))
159
+ importlib.invalidate_caches()
160
+
161
+ if isinstance(values, str):
162
+ values = [values]
163
+
164
+ result = getattr(namespace, self.dest, [])
165
+
166
+ # Reset the default value
167
+ if result == self.default:
168
+ result = []
169
+
170
+ for value in values:
171
+ try:
172
+ result.append(self.callback(self.import_obj(value)))
173
+ except (ValueError, ModuleNotFoundError) as e:
174
+ parser.print_usage(sys.stderr)
175
+ parser.exit(2, f"{parser.prog}: error: {argparse.ArgumentError(self, str(e))}\n")
176
+
177
+ setattr(namespace, self.dest, result)
@@ -0,0 +1,3 @@
1
+ # These version placeholders will be replaced later during substitution.
2
+ __version__ = "0.1.0"
3
+ __version_tuple__ = (0, 1, 0)
@@ -0,0 +1,149 @@
1
+ [tool.poetry]
2
+ name = "pydantic-settings-export"
3
+ version = "0.1.0"
4
+ description = "Export your Pydantic settings to a Markdown and .env.example files!"
5
+ authors = ["Jag_k <jag-k@users.noreply.github.com>"]
6
+ classifiers = [
7
+ "License :: OSI Approved :: MIT License",
8
+ "Operating System :: OS Independent",
9
+ "Topic :: Software Development :: Libraries :: Python Modules",
10
+ "Topic :: Software Development :: Code Generators",
11
+ "Topic :: Documentation",
12
+ "Topic :: Utilities",
13
+ "Environment :: Console",
14
+ "Programming Language :: Python",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+
19
+ ]
20
+ readme = "README.md"
21
+ license = "MIT"
22
+ repository = "https://github.com/jag-k/pydantic-settings-export"
23
+ homepage = "https://github.com/jag-k/pydantic-settings-export#readme"
24
+ packages = [
25
+ { include = "pydantic_settings_export", from = "." },
26
+ ]
27
+
28
+ [tool.poetry.scripts]
29
+ pydantic-settings-export = "pydantic_settings_export.cli:main"
30
+
31
+ [tool.poetry.dependencies]
32
+ python = "^3.11"
33
+ pydantic = "^2"
34
+ pydantic-settings = "^2"
35
+
36
+
37
+ [tool.poetry.group.dev.dependencies]
38
+ ruff = "*"
39
+ ruff-lsp = "*"
40
+ pre-commit = "*"
41
+ ssort = "^0.13.0"
42
+
43
+ [tool.poetry-dynamic-versioning]
44
+ enable = false
45
+ vcs = "git"
46
+ style = "semver"
47
+
48
+ [tool.poetry-dynamic-versioning.substitution]
49
+ files = [
50
+ "pydantic_settings_export/version.py",
51
+ ]
52
+
53
+ [tool.poetry-dynamic-versioning.files."pydantic_settings_export/version.py"]
54
+ persistent-substitution = true
55
+ initial-content = """
56
+ # These version placeholders will be replaced later during substitution.
57
+ __version__ = "0.0.0"
58
+ __version_tuple__ = (0, 0, 0)
59
+ """
60
+
61
+ [build-system]
62
+ requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
63
+ build-backend = "poetry_dynamic_versioning.backend"
64
+
65
+
66
+ # https://docs.astral.sh/ruff/
67
+ [tool.ruff]
68
+ target-version = "py311"
69
+ line-length = 120
70
+ extend-exclude = [
71
+ ".idea",
72
+ ".vscode",
73
+ ".fleet",
74
+ ]
75
+
76
+ # https://docs.astral.sh/ruff/settings/#lint
77
+ [tool.ruff.lint]
78
+ ignore-init-module-imports = true
79
+ select = [
80
+ 'F', # flake8
81
+ 'I', # isort
82
+ 'B', # flake8-bugbear
83
+ 'D', # pydocstyle
84
+ 'W', # pycodestyle (warnings)
85
+ 'E', # pycodestyle (errors)
86
+ 'N', # pep8-naming
87
+ 'PT', # flake8-pytest-style
88
+ 'C90', # mccabe
89
+ ]
90
+ ignore = [
91
+ 'B012', # {name} inside finally blocks cause exceptions to be silenced
92
+ 'D100', # Missing docstring in public module
93
+ 'D104', # Missing docstring in public package
94
+ 'D105', # Missing docstring in magic method
95
+ 'D106', # Missing docstring in public nested class
96
+ 'D107', # Missing docstring in __init__
97
+ 'D203', # 1 blank line required before class docstring
98
+ 'D401', # First line of docstring should be in imperative mood: "{first_line}"
99
+ 'D404', # First word of the docstring should not be "This"
100
+ 'D207', # Docstring is under-indented
101
+ 'D208', # Docstring is over-indented
102
+ ]
103
+
104
+
105
+
106
+ # https://docs.astral.sh/ruff/settings/#extend-per-file-ignores
107
+ [tool.ruff.lint.extend-per-file-ignores]
108
+ '__init__.py' = [
109
+ 'F401', # {name} imported but unused; consider using importlib.util.find_spec to test for availability
110
+ 'F403', # from {name} import * used; unable to detect undefined names
111
+ 'F405', # {name} may be undefined, or defined from star imports
112
+ ]
113
+
114
+ # https://docs.astral.sh/ruff/settings/#lintpydocstyle
115
+ [tool.ruff.lint.pydocstyle]
116
+ convention = 'pep257'
117
+
118
+
119
+ # https://docs.astral.sh/ruff/settings/#lintmccabe
120
+ [tool.ruff.lint.mccabe]
121
+ max-complexity = 6
122
+
123
+ # https://docs.astral.sh/ruff/settings/#lintisort
124
+ [tool.ruff.lint.isort]
125
+ section-order = [
126
+ 'future',
127
+ 'standard-library',
128
+ 'third-party',
129
+ 'first-party',
130
+ 'local-folder',
131
+ ]
132
+ known-first-party = ["pydantic_settings_export"]
133
+ lines-after-imports = 2
134
+ lines-between-types = 1
135
+
136
+ # https://github.com/jag-k/pydantic-settings-export
137
+ [tool.pydantic_settings_export]
138
+ project_dir = "."
139
+ default_settings = [
140
+ "pydantic_settings_export.settings:Settings",
141
+ ]
142
+ dotenv = {"name" = ".env.example"}
143
+
144
+ [tool.pydantic_settings_export.markdown]
145
+ name = "Configuration.md"
146
+ save_dirs = [
147
+ "docs",
148
+ "wiki",
149
+ ]