diff --git a/pyproject.toml b/pyproject.toml index d275a9c..585d43d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "ruff>=0.15.0", "ty>=0.0.12", ] test = [ diff --git a/src/env_example/main.py b/src/env_example/main.py index d95e706..a2fbc17 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -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" @@ -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: @@ -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. @@ -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) @@ -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 @@ -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" @@ -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: @@ -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]: diff --git a/tests/cases/inherited_field_override/.env.example.expected b/tests/cases/inherited_field_override/.env.example.expected new file mode 100644 index 0000000..2e5b67c --- /dev/null +++ b/tests/cases/inherited_field_override/.env.example.expected @@ -0,0 +1,7 @@ +# ChildSettings +CHILD_FIELD= +REQUIRED_FIELD= + +# ParentSettings +REQUIRED_FIELD= +SHARED_FIELD= diff --git a/tests/cases/inherited_field_override/project/package/__init__.py b/tests/cases/inherited_field_override/project/package/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/inherited_field_override/project/package/module.py b/tests/cases/inherited_field_override/project/package/module.py new file mode 100644 index 0000000..ad904e1 --- /dev/null +++ b/tests/cases/inherited_field_override/project/package/module.py @@ -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 diff --git a/tests/cases/optional_fields/.env.example.expected b/tests/cases/optional_fields/.env.example.expected new file mode 100644 index 0000000..65233fc --- /dev/null +++ b/tests/cases/optional_fields/.env.example.expected @@ -0,0 +1,3 @@ +# Settings +REQUIRED_FIELD= +STILL_REQUIRED= diff --git a/tests/cases/optional_fields/project/package/__init__.py b/tests/cases/optional_fields/project/package/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/optional_fields/project/package/module.py b/tests/cases/optional_fields/project/package/module.py new file mode 100644 index 0000000..16818ff --- /dev/null +++ b/tests/cases/optional_fields/project/package/module.py @@ -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) diff --git a/tests/test_run.py b/tests/test_run.py index 05e43dd..c7c0b0c 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -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=[ @@ -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), ] @@ -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() @@ -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() @@ -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, + ) diff --git a/uv.lock b/uv.lock index bf68ac3..99aa59b 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "ruff" }, { name = "ty" }, ] test = [ @@ -37,7 +38,10 @@ test = [ [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "ty", specifier = ">=0.0.12" }] +dev = [ + { name = "ruff", specifier = ">=0.15.0" }, + { name = "ty", specifier = ">=0.0.12" }, +] test = [ { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pytest", specifier = ">=9.0.2" }, @@ -172,6 +176,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + [[package]] name = "ty" version = "0.0.12"