Skip to content

Commit

Permalink
Release v0.2.0 (#8)
Browse files Browse the repository at this point in the history
- Add new global settings to `.env.example` file.
    - Update `Configuration.md` to include new settings and correct table formatting.
    - Upgrade dependencies in `poetry.lock` file, including `pydantic`, `attrs`, `filelock`, and others.
    - Refactor `cli.py` to handle environment file loading and settings import.
    - Adjust `SettingsAction` and `GeneratorAction` classes.
    - Update the `Settings` class with new properties and methods for handling environment files and project directories.
    - Implement `PyprojectTomlConfigSettingsSource` and `SourcesMixin` for better configuration management.
    - Correct typos and improve docstrings across multiple files.
    - Modify `pyproject.toml` with updated author information and tool configurations.
    - Add .env “import” to avoid validation error (#3)

Signed-off-by: Jag_k <30597878+jag-k@users.noreply.github.com>
  • Loading branch information
jag-k authored Aug 20, 2024
1 parent e246a41 commit 68d46ed
Show file tree
Hide file tree
Showing 15 changed files with 432 additions and 292 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
### Global Settings

# PYDANTIC_SETTINGS_EXPORT_DEFAULT_SETTINGS=[]
# PYDANTIC_SETTINGS_EXPORT_ROOT_DIR="<project_dir>"
# PYDANTIC_SETTINGS_EXPORT_PROJECT_DIR="<project_dir>"
# PYDANTIC_SETTINGS_EXPORT_RESPECT_EXCLUDE=true
# PYDANTIC_SETTINGS_EXPORT_ENV_FILE=null

### Relative Directory Settings

Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ repos:
entry: ssort

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
rev: v0.6.1
hooks:
# Run the linter.
- id: ruff
Expand Down
14 changes: 8 additions & 6 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ Global settings for pydantic_settings_export.

**Environment Prefix**: `PYDANTIC_SETTINGS_EXPORT_`

| Name | Type | Default | Description | Example |
|---------------------------------------------|-----------|-------------------|-----------------------------------------------------------------------------------------|-------------------|
| `PYDANTIC_SETTINGS_EXPORT_DEFAULT_SETTINGS` | `list` | `[]` | The default settings to use. The settings are applied in the order they are listed. | `[]` |
| `PYDANTIC_SETTINGS_EXPORT_PROJECT_DIR` | `Path` | `"<project_dir>"` | The project directory. Used for relative paths in the configuration file and .env file. | `"<project_dir>"` |
| `PYDANTIC_SETTINGS_EXPORT_RESPECT_EXCLUDE` | `boolean` | `true` | Respect the exclude attribute in the fields. | `true` |
| Name | Type | Default | Description | Example |
|---------------------------------------------|------------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|-------------------|
| `PYDANTIC_SETTINGS_EXPORT_DEFAULT_SETTINGS` | `list` | `[]` | The default settings to use. The settings are applied in the order they are listed. | `[]` |
| `PYDANTIC_SETTINGS_EXPORT_ROOT_DIR` | `Path` | `"<project_dir>"` | The project directory. Used for relative paths in the configuration file and .env file. | `"<project_dir>"` |
| `PYDANTIC_SETTINGS_EXPORT_PROJECT_DIR` | `Path` | `"<project_dir>"` | The project directory. Used for relative paths in the configuration file and .env file. | `"<project_dir>"` |
| `PYDANTIC_SETTINGS_EXPORT_RESPECT_EXCLUDE` | `boolean` | `true` | Respect the exclude attribute in the fields. | `true` |
| `PYDANTIC_SETTINGS_EXPORT_ENV_FILE` | `Optional` | `null` | The path to the `.env` file to load environment variables. Useful, then you have a Settings class/instance, which require values while running. | `null` |

### Relative Directory Settings

Expand All @@ -27,7 +29,7 @@ Settings for the relative directory.

### Configuration File Settings

Settings for the markdown file.
Settings for the Markdown file.

**Environment Prefix**: `CONFIG_FILE_`

Expand Down
446 changes: 222 additions & 224 deletions poetry.lock

Large diffs are not rendered by default.

43 changes: 21 additions & 22 deletions pydantic_settings_export/cli.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
import argparse

import os
import sys
from collections.abc import Sequence
from inspect import isclass
from pathlib import Path
from typing import Any

from pydantic_settings import BaseSettings
from dotenv import dotenv_values

from pydantic_settings_export.exporter import Exporter
from pydantic_settings_export.generators import ALL_GENERATORS, AbstractGenerator
from pydantic_settings_export.settings import Settings
from pydantic_settings_export.settings import Settings, import_settings_from_string
from pydantic_settings_export.utils import ObjectImportAction
from pydantic_settings_export.version import __version__


CDW = Path.cwd()


class SettingsAction(ObjectImportAction):
"""The settings action."""

@staticmethod
def callback(obj: Any) -> type[BaseSettings]:
"""Check if the object is a settings class."""
if isclass(obj) and issubclass(obj, BaseSettings):
return obj
elif not isclass(obj) and isinstance(obj, BaseSettings):
return obj.__class__
raise ValueError(f"The {obj!r} is not a settings class.")


class GeneratorAction(ObjectImportAction):
"""The generator action."""

Expand Down Expand Up @@ -64,7 +51,7 @@ def dir_type(path: str) -> Path:
parser.add_argument(
"--project-dir",
"-d",
default=CDW,
default=None,
type=dir_type,
help="The project directory. (default: current directory)",
)
Expand All @@ -85,18 +72,30 @@ def dir_type(path: str) -> Path:
parser.add_argument(
"settings",
nargs="*",
action=SettingsAction,
help="The settings classes or objects to export.",
)
parser.add_argument(
"--env-file",
"-e",
default=None,
type=argparse.FileType("r"),
help="Use the .env file to load environment variables. (default: None)",
)


def main(parse_args: Sequence[str] | None = None): # noqa: D103
args = parser.parse_args(parse_args)
args: argparse.Namespace = parser.parse_args(parse_args)
if args.env_file:
print(args.env_file)
os.environ.update(dotenv_values(stream=args.env_file))
s = Settings.from_pyproject(args.config_file)

s.project_dir = args.project_dir
if args.project_dir:
s.project_dir = Path(args.project_dir).resolve().absolute()
sys.path.insert(0, str(s.project_dir))

s.generators = args.generator
settings = s.default_settings or args.settings
settings = s.settings or [import_settings_from_string(s) for s in args.settings]
if not settings:
parser.exit(0, parser.format_help())

Expand Down
1 change: 0 additions & 1 deletion pydantic_settings_export/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from pydantic import BeforeValidator, SecretStr


__all__ = (
"StrAsPath",
"FIELD_TYPE_MAP",
Expand Down
3 changes: 1 addition & 2 deletions pydantic_settings_export/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from pydantic_settings_export.models import SettingsInfoModel
from pydantic_settings_export.settings import Settings


__all__ = ("Exporter",)


Expand All @@ -25,7 +24,7 @@ def run_all(self, *settings: BaseSettings | type[BaseSettings]) -> list[Path]:
"""Run all generators for the given settings.
:param settings: The settings to generate documentation for.
:return: The paths to generated documentations.
:return: The paths to generated documentation.
"""
settings_infos: list[SettingsInfoModel] = [
SettingsInfoModel.from_settings_model(s, self.settings) for s in settings
Expand Down
9 changes: 4 additions & 5 deletions pydantic_settings_export/generators/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pydantic_settings_export.models import SettingsInfoModel
from pydantic_settings_export.settings import Settings


__all__ = ("AbstractGenerator",)


Expand Down Expand Up @@ -40,8 +39,8 @@ def generate(self, *settings_infos: SettingsInfoModel) -> str:
def write_to_files(self, generated_result: str) -> list[Path]:
"""Write the generated content to files.
:param generated_result: The result to write to files.
:return: The list of file paths written to.
:param generated_result: The result is to write to files.
:return: The list of file paths is written to.
"""
raise NotImplementedError

Expand All @@ -51,8 +50,8 @@ def run(cls, settings: Settings, settings_info: SettingsInfoModel) -> list[Path]
:param settings: The settings for the generator.
:param settings_info: The settings info to generate documentation for.
:return: The list of file paths written to.
:return: The list of file paths is written to.
"""
generator = cls(settings)
result = generator.generate(settings_info)
return generator.write_to_files(result)
return [path.resolve().absolute() for path in generator.write_to_files(result)]
7 changes: 3 additions & 4 deletions pydantic_settings_export/generators/dotenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from .abstract import AbstractGenerator


__all__ = ("DotEnvGenerator",)


Expand All @@ -14,10 +13,10 @@ class DotEnvGenerator(AbstractGenerator):
def write_to_files(self, generated_result: str) -> list[Path]:
"""Write the generated content to files.
:param generated_result: The result to write to files.
:return: The list of file paths written to.
:param generated_result: The result is to write to files.
:return: The list of file paths is written to.
"""
file_path = self.settings.project_dir / self.settings.dotenv.name
file_path = self.settings.root_dir / self.settings.dotenv.name
file_path.write_text(generated_result)
return [file_path]

Expand Down
32 changes: 23 additions & 9 deletions pydantic_settings_export/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import warnings
from inspect import getdoc, isclass
from pathlib import Path
from types import UnionType
from typing import Self
from typing import Any, Self

from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
from pydantic.fields import FieldInfo
Expand All @@ -11,13 +12,19 @@
from pydantic_settings_export.constants import FIELD_TYPE_MAP
from pydantic_settings_export.settings import Settings


__all__ = (
"FieldInfoModel",
"SettingsInfoModel",
)


def _prepare_example(example: Any) -> str:
"""Prepare the example for the field."""
if isinstance(example, set):
example = sorted(example)
return str(example)


class FieldInfoModel(BaseModel):
"""Info about the field of the settings model."""

Expand All @@ -41,11 +48,11 @@ def is_required(self) -> bool:

@staticmethod
def create_default(field: FieldInfo, global_settings: Settings | None = None) -> str | None:
"""Make default value for the field.
"""Make the default value for the field.
:param field: The field info to generate default value for.
:param field: The field info to generate the default value for.
:param global_settings: The global settings.
:return: The default value for the field as string, or None if there is no default value.
:return: The default value for the field as a string, or None if there is no default value.
"""
default: object | PydanticUndefined = field.default

Expand All @@ -60,13 +67,20 @@ def create_default(field: FieldInfo, global_settings: Settings | None = None) ->
and isinstance(default, Path)
and default.is_absolute()
):
# Make the default path relative to the global_settings
default = Path(global_settings.relative_to.alias) / default.relative_to(global_settings.project_dir)
try:
# Make the default path relative to the global_settings
default = Path(global_settings.relative_to.alias) / default.relative_to(
global_settings.project_dir.resolve().absolute()
)
except ValueError:
pass

if default is PydanticUndefined:
return None
try:
return TypeAdapter(field.annotation).dump_json(default).decode()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return TypeAdapter(field.annotation).dump_json(default).decode()
except PydanticSerializationError:
return str(default)

Expand Down Expand Up @@ -100,7 +114,7 @@ def from_settings_field(
# Get the description from the field if it exists
description: str | None = field.description or None
# Get the example from the field if it exists
example: str | None = str(field.examples[0]) if field.examples else default
example: str | None = _prepare_example(field.examples[0]) if field.examples else default

return cls(
name=name,
Expand Down
55 changes: 48 additions & 7 deletions pydantic_settings_export/settings.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Any, Self

from pydantic import Field, ImportString
from dotenv import load_dotenv
from pydantic import Field, ImportString, TypeAdapter, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

from pydantic_settings_export.constants import StrAsPath
from pydantic_settings_export.sources import SourcesMixin
from pydantic_settings_export.utils import get_config_from_pyproject_toml


if TYPE_CHECKING:
from pydantic_settings_export.generators.abstract import AbstractGenerator # noqa: F401

Expand All @@ -19,6 +20,14 @@
)


def import_settings_from_string(value: str) -> BaseSettings:
"""Import the settings from the string."""
obj = TypeAdapter(ImportString).validate_python(value)
if isinstance(obj, type) and not issubclass(obj, BaseSettings) and not isinstance(obj, BaseSettings):
raise ValueError(f"The {obj!r} is not a settings class.")
return obj


class RelativeToSettings(BaseSettings):
"""Settings for the relative directory."""

Expand All @@ -32,7 +41,7 @@ class RelativeToSettings(BaseSettings):


class MarkdownSettings(BaseSettings):
"""Settings for the markdown file."""
"""Settings for the Markdown file."""

model_config = SettingsConfigDict(
title="Configuration File Settings",
Expand Down Expand Up @@ -62,7 +71,7 @@ class DotEnvSettings(BaseSettings):
name: str = Field(".env.example", description="The name of the .env file.")


class Settings(BaseSettings):
class Settings(BaseSettings, SourcesMixin):
"""Global settings for pydantic_settings_export."""

model_config = SettingsConfigDict(
Expand All @@ -75,11 +84,16 @@ class Settings(BaseSettings):
},
)

default_settings: list[ImportString] = Field(
default_settings: list[str] = Field(
default_factory=list,
description="The default settings to use. The settings are applied in the order they are listed.",
)

root_dir: Path = Field(
Path.cwd(),
description="The project directory. Used for relative paths in the configuration file and .env file.",
)

project_dir: Path = Field(
Path.cwd(),
description="The project directory. Used for relative paths in the configuration file and .env file.",
Expand Down Expand Up @@ -109,11 +123,38 @@ class Settings(BaseSettings):
exclude=True,
)

env_file: Path | None = Field(
None,
description=(
"The path to the `.env` file to load environment variables. "
"Useful, then you have a Settings class/instance, which require values while running."
),
)

@property
def settings(self) -> list[BaseSettings]:
"""Get the settings."""
return [import_settings_from_string(i) for i in self.default_settings or []]

# noinspection PyNestedDecorators
@model_validator(mode="before")
@classmethod
def validate_env_file(cls, data: Any) -> Any:
"""Validate the env file."""
if isinstance(data, dict):
file = data.get("env_file")
if file is not None:
f = Path(file)
if f.is_file():
print("Loading env file", f)
load_dotenv(file)
return data

@classmethod
def from_pyproject(cls, base_path: Path | None = None) -> Self:
"""Load settings from the pyproject.toml file.
:param base_path: The base path to search for the pyproject.toml file, or this file itself.
:param base_path: The base path to search for the pyproject.toml file or this file itself.
The current working directory is used by default.
:return: The loaded settings.
"""
Expand Down
Loading

0 comments on commit 68d46ed

Please sign in to comment.