Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ build-backend = "uv_build"

[dependency-groups]
dev = [
"ruff>=0.15.0",
"ty>=0.0.12",
]
test = [
Expand Down
99 changes: 73 additions & 26 deletions src/env_example/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from typing import Iterator, Self

ALWAYS_EXCLUDE_DIRS = {".venv", "site-packages"}
SETTINGS_CONFIG_CLASS = "SettingsConfigDict"
ENV_PREFIX_ARG = "env_prefix"
OUTPUT_FILE = ".env.example"

Expand Down Expand Up @@ -95,10 +94,16 @@ class ParsedModule:
classes: dict[str, ast.ClassDef]


@dataclass(frozen=True)
class SettingsField:
name: str
has_default: bool = False


@dataclass
class ParsedSettings:
prefix: str | None = None
fields: set[str] = field(default_factory=set)
fields: dict[str, SettingsField] = field(default_factory=dict)


def main() -> None:
Expand All @@ -109,18 +114,24 @@ def main() -> None:
type=Path,
action="append",
)
parser.add_argument(
"--ignore-optionals",
action="store_true",
)
namespace = parser.parse_args()

cwd = Path.cwd()
generate_env_example(
project_root=cwd,
exclude_relative=namespace.exclude_dir,
ignore_optionals=namespace.ignore_optionals,
)


def generate_env_example(
project_root: Path,
exclude_relative: list[Path] | None,
ignore_optionals: bool,
) -> None:
"""
Orchestrator function.
Expand Down Expand Up @@ -169,7 +180,9 @@ def generate_env_example(
parsed_settings=parsed_settings,
)

env_example_txt = build_env_example(parsed_settings)
env_example_txt = build_env_example(
parsed_settings, ignore_optionals=ignore_optionals
)
if env_example_txt:
(project_root / OUTPUT_FILE).write_text(env_example_txt)

Expand Down Expand Up @@ -228,11 +241,13 @@ def gather_settings_for_subtree(
an aggregator for both the currently considered class and its children.
"""
class_def = module_lookup[node.parent].classes[node.leaf]
fields = parse_fields_from_settings(class_def)
fields = [
f for elem in class_def.body if (f := parse_field(elem)) is not None
]
prefix = parse_settings_prefix(class_def)

parsed_settings[node].prefix = prefix
parsed_settings[node].fields.update(fields)
parsed_settings[node].fields.update({f.name: f for f in fields})

for child in child_lookup[node]:
# add parent fields for the child settings class
Expand Down Expand Up @@ -297,19 +312,24 @@ def find_source_or_external_import(

def build_env_example(
parsed_settings: dict[QualifiedName, ParsedSettings],
ignore_optionals: bool,
) -> str:
if not parsed_settings:
return ""
sections = [
f"# {qn.leaf}\n"
+ "\n".join(
f"{parsed.prefix or ''}{field}=".upper()
for field in sorted(parsed.fields)
)
for qn, parsed in sorted(
parsed_settings.items(), key=lambda x: x[0].leaf
)
]

sections: list[str] = []
for qn, parsed in sorted(parsed_settings.items(), key=lambda x: x[0].leaf):
lines = [
f"{parsed.prefix or ''}{f.name}=".upper()
for f in sorted(parsed.fields.values(), key=lambda f: f.name)
if not (ignore_optionals and f.has_default)
]
if not lines:
continue
sections.append(f"# {qn.leaf}\n" + "\n".join(lines))

if not sections:
return ""
return "\n\n".join(sections) + "\n"


Expand Down Expand Up @@ -338,7 +358,7 @@ def parse_settings_prefix(cd: ClassDef) -> str | None:
# SettingsConfigDict case
if not (
isinstance(value.func, Name)
and value.func.id == SETTINGS_CONFIG_CLASS
and value.func.id == "SettingsConfigDict"
):
continue
for kw in value.keywords:
Expand Down Expand Up @@ -369,18 +389,45 @@ def parse_settings_prefix(cd: ClassDef) -> str | None:
return prefix


def parse_fields_from_settings(cd: ClassDef) -> list[str]:
fields: list[str] = []
def parse_field(node: ast.stmt) -> SettingsField | None:
if not isinstance(node, AnnAssign):
return None
if not isinstance(node.target, Name):
return None

for elem in cd.body:
if not isinstance(elem, AnnAssign):
continue
if not isinstance(elem.target, Name):
continue
name: str = elem.target.id
fields.append(name)
name = node.target.id
value = node.value
if value is None:
return SettingsField(name=name, has_default=False)

if not (
isinstance(value, Call)
and isinstance(value.func, Name)
and value.func.id == "Field"
):
return SettingsField(name=name, has_default=True)

# Pydantic Field provides a default only if an explicit non-Ellipsis
# default or a default_factory is provided.
if value.args:
is_ellipsis = (
isinstance(first_arg := value.args[0], Constant)
and first_arg.value is ...
)
if not is_ellipsis:
return SettingsField(name=name, has_default=True)

for kw in value.keywords:
if kw.arg == "default":
is_ellipsis = (
isinstance(kw.value, Constant) and kw.value.value is ...
)
if not is_ellipsis:
return SettingsField(name=name, has_default=True)
if kw.arg == "default_factory":
return SettingsField(name=name, has_default=True)

return fields
return SettingsField(name=name, has_default=False)


def get_bases_from_class(cd: ClassDef) -> list[QualifiedName]:
Expand Down
7 changes: 7 additions & 0 deletions tests/cases/inherited_field_override/.env.example.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# ChildSettings
CHILD_FIELD=
REQUIRED_FIELD=

# ParentSettings
REQUIRED_FIELD=
SHARED_FIELD=
Empty file.
11 changes: 11 additions & 0 deletions tests/cases/inherited_field_override/project/package/module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings


class ParentSettings(BaseSettings):
required_field: int
shared_field: str


class ChildSettings(ParentSettings):
shared_field: str = "default_value"
child_field: bool
3 changes: 3 additions & 0 deletions tests/cases/optional_fields/.env.example.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Settings
REQUIRED_FIELD=
STILL_REQUIRED=
Empty file.
9 changes: 9 additions & 0 deletions tests/cases/optional_fields/project/package/module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydantic import Field
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
required_field: int
still_required: int = Field(..., frozen=True)
optional_field: str | None = None
another_optional_field: str | None = Field(default=None)
53 changes: 36 additions & 17 deletions tests/test_run.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
from collections import namedtuple
from dataclasses import dataclass
from pathlib import Path

import pytest

from env_example.main import generate_env_example

Case = namedtuple("Case", ["name", "exclude_dirs"])

@dataclass
class Case:
name: str
exclude_dirs: list[str] | None = None
ignore_optionals: bool = False


test_cases: list[Case] = [
Case(name="prefix", exclude_dirs=None),
Case(name="alias_import", exclude_dirs=None),
Case(name="module_import", exclude_dirs=None),
Case(name="selective_import", exclude_dirs=None),
Case(name="multiple_settings", exclude_dirs=None),
Case(name="default_exclude", exclude_dirs=None),
Case(name="prefix"),
Case(name="alias_import"),
Case(name="module_import"),
Case(name="selective_import"),
Case(name="multiple_settings"),
Case(name="default_exclude"),
Case(name="transitive_inheritance"),
Case(name="two_level_transitive_inheritance"),
Case(name="reexport_inheritance"),
Case(name="qualified_module_import"),
Case(name="relative_import"),
Case(name="main_file"),
Case(
name="user_exclude",
exclude_dirs=[
Expand All @@ -23,12 +34,8 @@
"package/included/nested_excluded",
],
),
Case(name="transitive_inheritance", exclude_dirs=None),
Case(name="two_level_transitive_inheritance", exclude_dirs=None),
Case(name="reexport_inheritance", exclude_dirs=None),
Case(name="qualified_module_import", exclude_dirs=None),
Case(name="relative_import", exclude_dirs=None),
Case(name="main_file", exclude_dirs=None),
Case(name="optional_fields", ignore_optionals=True),
Case(name="inherited_field_override", ignore_optionals=True),
]


Expand All @@ -46,7 +53,11 @@ def run_case(test_case):
if test_case.exclude_dirs
else None
)
generate_env_example(project_root=dir_arg, exclude_relative=exclude_paths)
generate_env_example(
project_root=dir_arg,
exclude_relative=exclude_paths,
ignore_optionals=test_case.ignore_optionals,
)
yield
example_file = dir_arg / ".env.example"
example_file.unlink()
Expand Down Expand Up @@ -95,7 +106,11 @@ def test_no_settings_no_file_created() -> None:
project_dir = Path(__file__).parent / "cases" / "no_settings" / "project"
example_file = project_dir / ".env.example"

generate_env_example(project_root=project_dir, exclude_relative=None)
generate_env_example(
project_root=project_dir,
exclude_relative=None,
ignore_optionals=False,
)

try:
assert not example_file.exists()
Expand All @@ -109,4 +124,8 @@ def test_multiple_prefixes_raises_error() -> None:
)

with pytest.raises(ValueError, match="Multiple prefixes found"):
generate_env_example(project_root=project_dir, exclude_relative=None)
generate_env_example(
project_root=project_dir,
exclude_relative=None,
ignore_optionals=False,
)
31 changes: 30 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.