From 963ce0a37a301a842b99c64d02f2c37d72fed584 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:42:24 +0100 Subject: [PATCH 01/10] fix qn module import bug --- src/env_example/main.py | 20 +++++++++++-------- .../.env.example.expected | 5 +++++ .../project/package/__init__.py | 0 .../project/package/consumer.py | 5 +++++ .../project/package/subpackage/__init__.py | 0 .../project/package/subpackage/base.py | 5 +++++ tests/test_run.py | 1 + 7 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 tests/cases/qualified_module_import/.env.example.expected create mode 100644 tests/cases/qualified_module_import/project/package/__init__.py create mode 100644 tests/cases/qualified_module_import/project/package/consumer.py create mode 100644 tests/cases/qualified_module_import/project/package/subpackage/__init__.py create mode 100644 tests/cases/qualified_module_import/project/package/subpackage/base.py diff --git a/src/env_example/main.py b/src/env_example/main.py index 4faf5ab..f0a5d6c 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -213,7 +213,7 @@ def walk_dir( and item.name not in ALWAYS_EXCLUDE_DIRS and item not in exclude_paths ): - yield from walk_dir(item, parent_package=parent_package) + yield from walk_dir(item, parent_package=new_parent) yield from walk_dir(root, parent_package=QualifiedName(())) @@ -229,11 +229,8 @@ def find_source_or_external_import( - the fqn to the implementation of the searched symbol - None if none of the above, and no imports can be followed """ - match searched_symbol.parts: - case (symbol_object_name,): - symbol_module_ref = None - case (*_, symbol_module_ref, symbol_object_name): - pass + *module_parts, symbol_object_name = searched_symbol.parts + symbol_module_ref = ".".join(module_parts) or None parsed_module = module_lookup.get(search_module) @@ -335,8 +332,15 @@ def get_bases_from_class(cd: ClassDef) -> list[QualifiedName]: for base in cd.bases: if isinstance(base, Name): bases.append(QualifiedName((base.id,))) - elif isinstance(base, Attribute) and isinstance(base.value, Name): - bases.append(QualifiedName((base.value.id, base.attr))) + elif isinstance(base, Attribute): + parts: list[str] = [base.attr] + node = base.value + while isinstance(node, Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, Name): + parts.append(node.id) + bases.append(QualifiedName(tuple(reversed(parts)))) return bases diff --git a/tests/cases/qualified_module_import/.env.example.expected b/tests/cases/qualified_module_import/.env.example.expected new file mode 100644 index 0000000..80612ca --- /dev/null +++ b/tests/cases/qualified_module_import/.env.example.expected @@ -0,0 +1,5 @@ +# ChildSettings +CHILD_FIELD= + +# ParentSettings +PARENT_FIELD= diff --git a/tests/cases/qualified_module_import/project/package/__init__.py b/tests/cases/qualified_module_import/project/package/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/qualified_module_import/project/package/consumer.py b/tests/cases/qualified_module_import/project/package/consumer.py new file mode 100644 index 0000000..bd951ba --- /dev/null +++ b/tests/cases/qualified_module_import/project/package/consumer.py @@ -0,0 +1,5 @@ +import package.subpackage.base + + +class ChildSettings(package.subpackage.base.ParentSettings): + child_field: str diff --git a/tests/cases/qualified_module_import/project/package/subpackage/__init__.py b/tests/cases/qualified_module_import/project/package/subpackage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/qualified_module_import/project/package/subpackage/base.py b/tests/cases/qualified_module_import/project/package/subpackage/base.py new file mode 100644 index 0000000..a5ba1bc --- /dev/null +++ b/tests/cases/qualified_module_import/project/package/subpackage/base.py @@ -0,0 +1,5 @@ +from pydantic_settings import BaseSettings + + +class ParentSettings(BaseSettings): + parent_field: str diff --git a/tests/test_run.py b/tests/test_run.py index fbec5b2..af1e868 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -26,6 +26,7 @@ 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), ] From e3b4d137e7c8e8059d0eace781777fd32b732da4 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:12:36 +0100 Subject: [PATCH 02/10] fix qn module import bug --- src/env_example/main.py | 119 +++++++++++------- .../default_exclude/.env.example.expected | 2 - .../project/package/included/module.py | 5 - .../default_exclude/project/package/module.py | 0 .../.env.example.expected | 1 + .../relative_import/.env.example.expected | 11 ++ .../project/package/__init__.py | 0 .../relative_import/project/package/base.py | 5 + .../project/package/subpackage}/__init__.py | 0 .../project/package/subpackage/consumer.py | 5 + .../project/package/subpackage/middle.py | 5 + .../.env.example.expected | 1 + tests/test_run.py | 1 + 13 files changed, 102 insertions(+), 53 deletions(-) delete mode 100644 tests/cases/default_exclude/.env.example.expected delete mode 100644 tests/cases/default_exclude/project/package/included/module.py delete mode 100644 tests/cases/default_exclude/project/package/module.py create mode 100644 tests/cases/relative_import/.env.example.expected rename tests/cases/{default_exclude => relative_import}/project/package/__init__.py (100%) create mode 100644 tests/cases/relative_import/project/package/base.py rename tests/cases/{default_exclude/project/package/included => relative_import/project/package/subpackage}/__init__.py (100%) create mode 100644 tests/cases/relative_import/project/package/subpackage/consumer.py create mode 100644 tests/cases/relative_import/project/package/subpackage/middle.py diff --git a/src/env_example/main.py b/src/env_example/main.py index f0a5d6c..113eaf9 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -49,13 +49,6 @@ def __lt__(self, other: Self) -> bool: BASE_SETTINGS_FQN = QualifiedName(("pydantic_settings", "BaseSettings")) -@dataclass(frozen=True) -class SettingField: - name: str - settings_class: str - prefix: str | None = None - - @dataclass(frozen=True) class ModuleImport: module: str @@ -64,10 +57,33 @@ class ModuleImport: @dataclass(frozen=True) class NameImport: - module: str + module: str | None name: str + level: int alias: str | None = None + def __post_init__(self): + if not self.module and not self.level: + raise ValueError("Absolute imports must have a module component") + + def get_qualified_parent_module( + self, + current: QualifiedName, + ) -> QualifiedName: + """Resolve the qualified parent module for a relative import""" + if not self.level: + assert self.module + return QualifiedName.from_str(self.module) + + parent_qn = current + for _ in range(self.level): + parent_qn = parent_qn.parent + + if not self.module: + return parent_qn + + return parent_qn.child(self.module) + type ImportItem = ModuleImport | NameImport @@ -80,21 +96,15 @@ class ParsedModule: class InheritanceHierarchy: def __init__(self) -> None: - self._children: defaultdict[QualifiedName, set[QualifiedName]] = ( - defaultdict(set) + self._children: defaultdict[QualifiedName, list[QualifiedName]] = ( + defaultdict(list) ) def add_relation(self, parent: QualifiedName, child: QualifiedName): - self._children[parent].add(child) + self._children[parent].append(child) - def transitive_subclasses( - self, class_name: QualifiedName - ) -> set[QualifiedName]: - reachable = set() - for child in self._children[class_name]: - reachable.add(child) - reachable.update(self.transitive_subclasses(child)) - return reachable + def get_children(self, class_name: QualifiedName) -> list[QualifiedName]: + return self._children[class_name] def main() -> None: @@ -157,18 +167,37 @@ def generate_env_example( child=class_fqn, ) - settings_subclasses = inheritance.transitive_subclasses(BASE_SETTINGS_FQN) + # fields_per_class: dict[str, list[str]] = {} + # children = inheritance.get_children(BASE_SETTINGS_FQN) + # while children: + # for child in children: + + # for fqn in sorted(settings_subclasses): + # class_def = module_hierarchy[fqn.parent].classes[fqn.leaf] + # fields_per_class[class_def.name] = extract_fields_from_settings( + # class_def + # ) + + # env_example_txt = build_env_example(fields_per_class) + # if env_example_txt: + # write_to_file(env_example_txt, project_root / OUTPUT_FILE) + + +def extract_setting_fields( + node: QualifiedName, + inheritance: InheritanceHierarchy, + modules: dict[QualifiedName, ParsedModule], +): + pass - fields_per_class: dict[str, list[SettingField]] = {} - for fqn in sorted(settings_subclasses): - class_def = module_hierarchy[fqn.parent].classes[fqn.leaf] - fields_per_class[class_def.name] = extract_fields_from_settings( - class_def - ) - env_example_txt = build_env_example(fields_per_class) - if env_example_txt: - write_to_file(env_example_txt, project_root / OUTPUT_FILE) +def extract_all_setting_fields( + inheritance: InheritanceHierarchy, + modules: dict[QualifiedName, ParsedModule], +): + children = inheritance.get_children(BASE_SETTINGS_FQN) + for child in children: + extract_setting_fields(child, inheritance, modules) def write_to_file(text: str, file: Path) -> None: @@ -246,10 +275,13 @@ def find_source_or_external_import( imports = resolve_import_statements(parsed_module.ast_module) for imp in imports: match imp: - case NameImport(module, name, _) if name == symbol_object_name: + case NameImport(module, name) if name == symbol_object_name: + resolved = imp.get_qualified_parent_module( + current=search_module + ) return find_source_or_external_import( searched_symbol=QualifiedName((name,)), - search_module=QualifiedName.from_str(module), + search_module=resolved, module_lookup=module_lookup, ) case ModuleImport(module, alias) if ( @@ -264,20 +296,18 @@ def find_source_or_external_import( return None -def build_env_example(fields_per_class: dict[str, list[SettingField]]) -> str: +def build_env_example(fields_per_class: dict[str, list[str]]) -> str: if not fields_per_class: return "" sections = [ f"# {class_name}\n" - + "\n".join( - f"{field.prefix or ''}{field.name}=".upper() for field in fields - ) + + "\n".join(f"{field}=".upper() for field in fields) for class_name, fields in fields_per_class.items() ] return "\n\n".join(sections) + "\n" -def extract_fields_from_settings(cd: ClassDef) -> list[SettingField]: +def extract_fields_from_settings(cd: ClassDef) -> list[str]: prefixes: list[str] = [] for item in cd.body: @@ -308,7 +338,7 @@ def extract_fields_from_settings(cd: ClassDef) -> list[SettingField]: ) prefix = prefixes[0] if prefixes else None - fields: list[SettingField] = [] + fields: list[str] = [] for elem in cd.body: if not isinstance(elem, AnnAssign): @@ -316,13 +346,7 @@ def extract_fields_from_settings(cd: ClassDef) -> list[SettingField]: if not isinstance(elem.target, Name): continue name: str = elem.target.id - fields.append( - SettingField( - name=name, - settings_class=cd.name, - prefix=prefix, - ) - ) + fields.append(f"{prefix}{name}") return fields @@ -356,10 +380,13 @@ def resolve_import_statements(module: ast.Module) -> list[ImportItem]: ModuleImport(module=name.name, alias=name.asname) for name in item.names ) - elif isinstance(item, ast.ImportFrom) and item.module: + elif isinstance(item, ast.ImportFrom): imports.extend( NameImport( - module=item.module, name=name.name, alias=name.asname + module=item.module, + name=name.name, + alias=name.asname, + level=item.level, ) for name in item.names ) diff --git a/tests/cases/default_exclude/.env.example.expected b/tests/cases/default_exclude/.env.example.expected deleted file mode 100644 index f199de9..0000000 --- a/tests/cases/default_exclude/.env.example.expected +++ /dev/null @@ -1,2 +0,0 @@ -# IncludedSettings -FIELD= diff --git a/tests/cases/default_exclude/project/package/included/module.py b/tests/cases/default_exclude/project/package/included/module.py deleted file mode 100644 index 23fb179..0000000 --- a/tests/cases/default_exclude/project/package/included/module.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic_settings import BaseSettings - - -class IncludedSettings(BaseSettings): - field: int diff --git a/tests/cases/default_exclude/project/package/module.py b/tests/cases/default_exclude/project/package/module.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/cases/reexport_inheritance/.env.example.expected b/tests/cases/reexport_inheritance/.env.example.expected index 1bd040f..6257586 100644 --- a/tests/cases/reexport_inheritance/.env.example.expected +++ b/tests/cases/reexport_inheritance/.env.example.expected @@ -2,4 +2,5 @@ PARENT_FIELD= # ChildSettings +PARENT_FIELD= CHILD_FIELD= diff --git a/tests/cases/relative_import/.env.example.expected b/tests/cases/relative_import/.env.example.expected new file mode 100644 index 0000000..4114dcb --- /dev/null +++ b/tests/cases/relative_import/.env.example.expected @@ -0,0 +1,11 @@ +# ParentSettings +PARENT_FIELD= + +# ChildSettings +PARENT_FIELD= +MIDDLE_FIELD= +CHILD_FIELD= + +# MiddleSettings +PARENT_FIELD= +MIDDLE_FIELD= diff --git a/tests/cases/default_exclude/project/package/__init__.py b/tests/cases/relative_import/project/package/__init__.py similarity index 100% rename from tests/cases/default_exclude/project/package/__init__.py rename to tests/cases/relative_import/project/package/__init__.py diff --git a/tests/cases/relative_import/project/package/base.py b/tests/cases/relative_import/project/package/base.py new file mode 100644 index 0000000..a5ba1bc --- /dev/null +++ b/tests/cases/relative_import/project/package/base.py @@ -0,0 +1,5 @@ +from pydantic_settings import BaseSettings + + +class ParentSettings(BaseSettings): + parent_field: str diff --git a/tests/cases/default_exclude/project/package/included/__init__.py b/tests/cases/relative_import/project/package/subpackage/__init__.py similarity index 100% rename from tests/cases/default_exclude/project/package/included/__init__.py rename to tests/cases/relative_import/project/package/subpackage/__init__.py diff --git a/tests/cases/relative_import/project/package/subpackage/consumer.py b/tests/cases/relative_import/project/package/subpackage/consumer.py new file mode 100644 index 0000000..8e2473c --- /dev/null +++ b/tests/cases/relative_import/project/package/subpackage/consumer.py @@ -0,0 +1,5 @@ +from .middle import MiddleSettings + + +class ChildSettings(MiddleSettings): + child_field: str diff --git a/tests/cases/relative_import/project/package/subpackage/middle.py b/tests/cases/relative_import/project/package/subpackage/middle.py new file mode 100644 index 0000000..7277930 --- /dev/null +++ b/tests/cases/relative_import/project/package/subpackage/middle.py @@ -0,0 +1,5 @@ +from ..base import ParentSettings + + +class MiddleSettings(ParentSettings): + middle_field: str diff --git a/tests/cases/transitive_inheritance/.env.example.expected b/tests/cases/transitive_inheritance/.env.example.expected index 4e0bc99..7b4e0db 100644 --- a/tests/cases/transitive_inheritance/.env.example.expected +++ b/tests/cases/transitive_inheritance/.env.example.expected @@ -1,4 +1,5 @@ # ChildSettings +FIELD= OTHER_FIELD= # ParentSettings diff --git a/tests/test_run.py b/tests/test_run.py index af1e868..5553a3c 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -27,6 +27,7 @@ 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), ] From ef113b819a4e87f780116b48ce5904338e3f45b2 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:09:37 +0100 Subject: [PATCH 03/10] gathering --- src/env_example/main.py | 111 +++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 53 deletions(-) diff --git a/src/env_example/main.py b/src/env_example/main.py index 113eaf9..77480f9 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -94,34 +94,19 @@ class ParsedModule: classes: dict[str, ast.ClassDef] -class InheritanceHierarchy: +class InheritanceTree: def __init__(self) -> None: self._children: defaultdict[QualifiedName, list[QualifiedName]] = ( defaultdict(list) ) - def add_relation(self, parent: QualifiedName, child: QualifiedName): - self._children[parent].append(child) - - def get_children(self, class_name: QualifiedName) -> list[QualifiedName]: - return self._children[class_name] - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument( - "--exclude-dir", - default=None, - type=Path, - action="append", - ) - namespace = parser.parse_args() + def add_relation( + self, parent_class: QualifiedName, child_class: QualifiedName + ): + self._children[parent_class].append(child_class) - cwd = Path.cwd() - generate_env_example( - project_root=cwd, - exclude_relative=namespace.exclude_dir, - ) + def get_children(self, class_node: QualifiedName) -> list[QualifiedName]: + return self._children[class_node] def generate_env_example( @@ -151,7 +136,7 @@ def generate_env_example( classes={cd.name: cd for cd in classes}, ) - inheritance = InheritanceHierarchy() + inheritance = InheritanceTree() for fqn, parsed_module in module_hierarchy.items(): for class_def in parsed_module.classes.values(): class_fqn = fqn.child(class_def.name) @@ -163,41 +148,44 @@ def generate_env_example( ) if parent: inheritance.add_relation( - parent=parent, - child=class_fqn, + parent_class=parent, + child_class=class_fqn, ) - # fields_per_class: dict[str, list[str]] = {} - # children = inheritance.get_children(BASE_SETTINGS_FQN) - # while children: - # for child in children: - - # for fqn in sorted(settings_subclasses): - # class_def = module_hierarchy[fqn.parent].classes[fqn.leaf] - # fields_per_class[class_def.name] = extract_fields_from_settings( - # class_def - # ) + fields_per_settings: dict[str, set[str]] = defaultdict(set) + children = inheritance.get_children(BASE_SETTINGS_FQN) + for child in children: + gather_settings_for_subtree( + node=child, + inheritance_tree=inheritance, + module_hierarchy=module_hierarchy, + fields_per_settings=fields_per_settings, + ) - # env_example_txt = build_env_example(fields_per_class) - # if env_example_txt: - # write_to_file(env_example_txt, project_root / OUTPUT_FILE) + env_example_txt = build_env_example(fields_per_settings) + if env_example_txt: + write_to_file(env_example_txt, project_root / OUTPUT_FILE) -def extract_setting_fields( +def gather_settings_for_subtree( node: QualifiedName, - inheritance: InheritanceHierarchy, - modules: dict[QualifiedName, ParsedModule], + inheritance_tree: InheritanceTree, + module_hierarchy: dict[QualifiedName, ParsedModule], + fields_per_settings: dict[str, set[str]], ): - pass - - -def extract_all_setting_fields( - inheritance: InheritanceHierarchy, - modules: dict[QualifiedName, ParsedModule], -): - children = inheritance.get_children(BASE_SETTINGS_FQN) - for child in children: - extract_setting_fields(child, inheritance, modules) + class_def = module_hierarchy[node.parent].classes[node.leaf] + fields = extract_fields_from_settings(class_def) + fields_per_settings[class_def.name].add(*fields) + for child in inheritance_tree.get_children(node): + class_def = module_hierarchy[child.parent].classes[child.leaf] + fields = extract_fields_from_settings(class_def) + fields_per_settings[class_def.name].add(*fields) + gather_settings_for_subtree( + node=child, + inheritance_tree=inheritance_tree, + module_hierarchy=module_hierarchy, + fields_per_settings=fields_per_settings, + ) def write_to_file(text: str, file: Path) -> None: @@ -296,7 +284,7 @@ def find_source_or_external_import( return None -def build_env_example(fields_per_class: dict[str, list[str]]) -> str: +def build_env_example(fields_per_class: dict[str, set[str]]) -> str: if not fields_per_class: return "" sections = [ @@ -346,7 +334,7 @@ def extract_fields_from_settings(cd: ClassDef) -> list[str]: if not isinstance(elem.target, Name): continue name: str = elem.target.id - fields.append(f"{prefix}{name}") + fields.append(f"{prefix}{name}" if prefix else f"{name}") return fields @@ -394,5 +382,22 @@ def resolve_import_statements(module: ast.Module) -> list[ImportItem]: return imports +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--exclude-dir", + default=None, + type=Path, + action="append", + ) + namespace = parser.parse_args() + + cwd = Path.cwd() + generate_env_example( + project_root=cwd, + exclude_relative=namespace.exclude_dir, + ) + + if __name__ == "__main__": main() From a9aa890f67d2ccbc4e87121686b50580c5be9ec5 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:13:41 +0100 Subject: [PATCH 04/10] ordering --- tests/cases/multiple_settings/.env.example.expected | 6 +++--- tests/cases/qualified_module_import/.env.example.expected | 6 +++--- tests/cases/relative_import/.env.example.expected | 6 +++--- tests/cases/transitive_inheritance/.env.example.expected | 6 +++--- .../two_level_transitive_inheritance/.env.example.expected | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/cases/multiple_settings/.env.example.expected b/tests/cases/multiple_settings/.env.example.expected index f7828de..44c056b 100644 --- a/tests/cases/multiple_settings/.env.example.expected +++ b/tests/cases/multiple_settings/.env.example.expected @@ -1,5 +1,5 @@ -# OtherSettings -OTHER_FIELD= - # SomeSettings SOME_FIELD= + +# OtherSettings +OTHER_FIELD= diff --git a/tests/cases/qualified_module_import/.env.example.expected b/tests/cases/qualified_module_import/.env.example.expected index 80612ca..1bd040f 100644 --- a/tests/cases/qualified_module_import/.env.example.expected +++ b/tests/cases/qualified_module_import/.env.example.expected @@ -1,5 +1,5 @@ -# ChildSettings -CHILD_FIELD= - # ParentSettings PARENT_FIELD= + +# ChildSettings +CHILD_FIELD= diff --git a/tests/cases/relative_import/.env.example.expected b/tests/cases/relative_import/.env.example.expected index 4114dcb..347611b 100644 --- a/tests/cases/relative_import/.env.example.expected +++ b/tests/cases/relative_import/.env.example.expected @@ -1,11 +1,11 @@ # ParentSettings PARENT_FIELD= -# ChildSettings +# MiddleSettings PARENT_FIELD= MIDDLE_FIELD= -CHILD_FIELD= -# MiddleSettings +# ChildSettings PARENT_FIELD= MIDDLE_FIELD= +CHILD_FIELD= diff --git a/tests/cases/transitive_inheritance/.env.example.expected b/tests/cases/transitive_inheritance/.env.example.expected index 7b4e0db..a39e842 100644 --- a/tests/cases/transitive_inheritance/.env.example.expected +++ b/tests/cases/transitive_inheritance/.env.example.expected @@ -1,6 +1,6 @@ -# ChildSettings +# ParentSettings FIELD= -OTHER_FIELD= -# ParentSettings +# ChildSettings FIELD= +OTHER_FIELD= diff --git a/tests/cases/two_level_transitive_inheritance/.env.example.expected b/tests/cases/two_level_transitive_inheritance/.env.example.expected index 1127475..9cd06f2 100644 --- a/tests/cases/two_level_transitive_inheritance/.env.example.expected +++ b/tests/cases/two_level_transitive_inheritance/.env.example.expected @@ -1,8 +1,8 @@ -# ChildSettings -CHILD_FIELD= - # GrandparentSettings GRANDPARENT_FIELD= # ParentSettings PARENT_FIELD= + +# ChildSettings +CHILD_FIELD= From 49631f0926618e8100ee8144213ee7bea649d0c9 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:41:05 +0100 Subject: [PATCH 05/10] ordering --- src/env_example/main.py | 16 +++++++++------- .../cases/default_exclude/.env.example.expected | 2 ++ .../default_exclude/project/package/__init__.py | 0 .../project/package/included/__init__.py | 0 .../project/package/included/module.py | 5 +++++ .../project/package/site-packages/__init__.py | 0 .../project/package/site-packages/module.py | 5 +++++ .../.env.example.expected | 1 + .../reexport_inheritance/.env.example.expected | 2 +- .../cases/relative_import/.env.example.expected | 6 +++--- .../.env.example.expected | 3 +++ 11 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 tests/cases/default_exclude/.env.example.expected create mode 100644 tests/cases/default_exclude/project/package/__init__.py create mode 100644 tests/cases/default_exclude/project/package/included/__init__.py create mode 100644 tests/cases/default_exclude/project/package/included/module.py create mode 100644 tests/cases/default_exclude/project/package/site-packages/__init__.py create mode 100644 tests/cases/default_exclude/project/package/site-packages/module.py diff --git a/src/env_example/main.py b/src/env_example/main.py index 77480f9..308a1c0 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -173,13 +173,15 @@ def gather_settings_for_subtree( module_hierarchy: dict[QualifiedName, ParsedModule], fields_per_settings: dict[str, set[str]], ): - class_def = module_hierarchy[node.parent].classes[node.leaf] - fields = extract_fields_from_settings(class_def) - fields_per_settings[class_def.name].add(*fields) + parent_class_def = module_hierarchy[node.parent].classes[node.leaf] + parent_fields = extract_fields_from_settings(parent_class_def) + fields_per_settings[parent_class_def.name].add(*parent_fields) for child in inheritance_tree.get_children(node): - class_def = module_hierarchy[child.parent].classes[child.leaf] - fields = extract_fields_from_settings(class_def) - fields_per_settings[class_def.name].add(*fields) + child_class_def = module_hierarchy[child.parent].classes[child.leaf] + all_parent_fields = fields_per_settings[parent_class_def.name] + fields_per_settings[child_class_def.name].update(all_parent_fields) + child_fields = extract_fields_from_settings(child_class_def) + fields_per_settings[child_class_def.name].add(*child_fields) gather_settings_for_subtree( node=child, inheritance_tree=inheritance_tree, @@ -289,7 +291,7 @@ def build_env_example(fields_per_class: dict[str, set[str]]) -> str: return "" sections = [ f"# {class_name}\n" - + "\n".join(f"{field}=".upper() for field in fields) + + "\n".join(f"{field}=".upper() for field in sorted(fields)) for class_name, fields in fields_per_class.items() ] return "\n\n".join(sections) + "\n" diff --git a/tests/cases/default_exclude/.env.example.expected b/tests/cases/default_exclude/.env.example.expected new file mode 100644 index 0000000..6e3a486 --- /dev/null +++ b/tests/cases/default_exclude/.env.example.expected @@ -0,0 +1,2 @@ +# Settings +FIELD= diff --git a/tests/cases/default_exclude/project/package/__init__.py b/tests/cases/default_exclude/project/package/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/default_exclude/project/package/included/__init__.py b/tests/cases/default_exclude/project/package/included/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/default_exclude/project/package/included/module.py b/tests/cases/default_exclude/project/package/included/module.py new file mode 100644 index 0000000..8af5378 --- /dev/null +++ b/tests/cases/default_exclude/project/package/included/module.py @@ -0,0 +1,5 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + field: int diff --git a/tests/cases/default_exclude/project/package/site-packages/__init__.py b/tests/cases/default_exclude/project/package/site-packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/default_exclude/project/package/site-packages/module.py b/tests/cases/default_exclude/project/package/site-packages/module.py new file mode 100644 index 0000000..291afa9 --- /dev/null +++ b/tests/cases/default_exclude/project/package/site-packages/module.py @@ -0,0 +1,5 @@ +from pydantic_settings import BaseSettings + + +class ExcludedSettings(BaseSettings): + field: int diff --git a/tests/cases/qualified_module_import/.env.example.expected b/tests/cases/qualified_module_import/.env.example.expected index 1bd040f..1f544eb 100644 --- a/tests/cases/qualified_module_import/.env.example.expected +++ b/tests/cases/qualified_module_import/.env.example.expected @@ -3,3 +3,4 @@ PARENT_FIELD= # ChildSettings CHILD_FIELD= +PARENT_FIELD= diff --git a/tests/cases/reexport_inheritance/.env.example.expected b/tests/cases/reexport_inheritance/.env.example.expected index 6257586..1f544eb 100644 --- a/tests/cases/reexport_inheritance/.env.example.expected +++ b/tests/cases/reexport_inheritance/.env.example.expected @@ -2,5 +2,5 @@ PARENT_FIELD= # ChildSettings -PARENT_FIELD= CHILD_FIELD= +PARENT_FIELD= diff --git a/tests/cases/relative_import/.env.example.expected b/tests/cases/relative_import/.env.example.expected index 347611b..c9d86ba 100644 --- a/tests/cases/relative_import/.env.example.expected +++ b/tests/cases/relative_import/.env.example.expected @@ -2,10 +2,10 @@ PARENT_FIELD= # MiddleSettings -PARENT_FIELD= MIDDLE_FIELD= +PARENT_FIELD= # ChildSettings -PARENT_FIELD= -MIDDLE_FIELD= CHILD_FIELD= +MIDDLE_FIELD= +PARENT_FIELD= diff --git a/tests/cases/two_level_transitive_inheritance/.env.example.expected b/tests/cases/two_level_transitive_inheritance/.env.example.expected index 9cd06f2..21b3c6f 100644 --- a/tests/cases/two_level_transitive_inheritance/.env.example.expected +++ b/tests/cases/two_level_transitive_inheritance/.env.example.expected @@ -2,7 +2,10 @@ GRANDPARENT_FIELD= # ParentSettings +GRANDPARENT_FIELD= PARENT_FIELD= # ChildSettings CHILD_FIELD= +GRANDPARENT_FIELD= +PARENT_FIELD= From 5ea0887f8161107b3d92c94df47f8a24c10e93e1 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:36:34 +0100 Subject: [PATCH 06/10] reorder test cases --- src/env_example/main.py | 77 ++++++++++++------- tests/cases/main_file/.env.example.expected | 9 +++ tests/cases/main_file/project/main.py | 10 +++ .../main_file/project/package/__init__.py | 0 .../cases/main_file/project/package/module.py | 5 ++ .../multiple_settings/.env.example.expected | 6 +- .../.env.example.expected | 6 +- .../.env.example.expected | 6 +- .../relative_import/.env.example.expected | 8 +- .../.env.example.expected | 6 +- .../.env.example.expected | 10 +-- tests/test_run.py | 1 + 12 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 tests/cases/main_file/.env.example.expected create mode 100644 tests/cases/main_file/project/main.py create mode 100644 tests/cases/main_file/project/package/__init__.py create mode 100644 tests/cases/main_file/project/package/module.py diff --git a/src/env_example/main.py b/src/env_example/main.py index 308a1c0..d351331 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -10,7 +10,7 @@ Name, ) from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Iterator, Self @@ -152,48 +152,56 @@ def generate_env_example( child_class=class_fqn, ) - fields_per_settings: dict[str, set[str]] = defaultdict(set) + parsed_settings = defaultdict(ParsedSettings) children = inheritance.get_children(BASE_SETTINGS_FQN) for child in children: gather_settings_for_subtree( node=child, inheritance_tree=inheritance, module_hierarchy=module_hierarchy, - fields_per_settings=fields_per_settings, + parsed_settings=parsed_settings, ) - env_example_txt = build_env_example(fields_per_settings) + env_example_txt = build_env_example(parsed_settings) if env_example_txt: - write_to_file(env_example_txt, project_root / OUTPUT_FILE) + (project_root / OUTPUT_FILE).write_text(env_example_txt) + + +@dataclass +class ParsedSettings: + prefix: str | None = None + fields: set[str] = field(default_factory=set) def gather_settings_for_subtree( node: QualifiedName, inheritance_tree: InheritanceTree, module_hierarchy: dict[QualifiedName, ParsedModule], - fields_per_settings: dict[str, set[str]], -): - parent_class_def = module_hierarchy[node.parent].classes[node.leaf] - parent_fields = extract_fields_from_settings(parent_class_def) - fields_per_settings[parent_class_def.name].add(*parent_fields) + parsed_settings: dict[QualifiedName, ParsedSettings], +) -> None: + """ + Recursively parses fieldsfrom settings classes and adds them to + an aggregator for both the currently considered class and its children. + """ + class_def = module_hierarchy[node.parent].classes[node.leaf] + fields = parse_fields_from_settings(class_def) + prefix = parse_settings_prefix(class_def) + + parsed_settings[node].prefix = prefix + parsed_settings[node].fields.update(fields) + for child in inheritance_tree.get_children(node): - child_class_def = module_hierarchy[child.parent].classes[child.leaf] - all_parent_fields = fields_per_settings[parent_class_def.name] - fields_per_settings[child_class_def.name].update(all_parent_fields) - child_fields = extract_fields_from_settings(child_class_def) - fields_per_settings[child_class_def.name].add(*child_fields) + # add parent fields for the child settings class + parsed_settings[child].fields.update(parsed_settings[node].fields) + gather_settings_for_subtree( node=child, inheritance_tree=inheritance_tree, module_hierarchy=module_hierarchy, - fields_per_settings=fields_per_settings, + parsed_settings=parsed_settings, ) -def write_to_file(text: str, file: Path) -> None: - file.write_text(text) - - def walk_project( root: Path, exclude_paths: set[Path], @@ -218,7 +226,7 @@ def walk_dir( ) for item in sorted(dir.iterdir()): - if is_package and item.is_file() and item.suffix == ".py": + if item.is_file() and item.suffix == ".py": module = ast.parse(item.read_text()) module_fqn = ( new_parent @@ -286,18 +294,27 @@ def find_source_or_external_import( return None -def build_env_example(fields_per_class: dict[str, set[str]]) -> str: - if not fields_per_class: +def build_env_example( + parsed_settings: dict[QualifiedName, ParsedSettings], +) -> str: + if not parsed_settings: return "" sections = [ - f"# {class_name}\n" - + "\n".join(f"{field}=".upper() for field in sorted(fields)) - for class_name, fields in fields_per_class.items() + f"# {qn.leaf}\n" + + "\n".join( + f"{parsed.prefix}{field}=".upper() + if parsed.prefix + else f"{field}=".upper() + for field in sorted(parsed.fields) + ) + for qn, parsed in sorted( + parsed_settings.items(), key=lambda x: x[0].leaf + ) ] return "\n\n".join(sections) + "\n" -def extract_fields_from_settings(cd: ClassDef) -> list[str]: +def parse_settings_prefix(cd: ClassDef) -> str | None: prefixes: list[str] = [] for item in cd.body: @@ -328,6 +345,10 @@ def extract_fields_from_settings(cd: ClassDef) -> list[str]: ) prefix = prefixes[0] if prefixes else None + return prefix + + +def parse_fields_from_settings(cd: ClassDef) -> list[str]: fields: list[str] = [] for elem in cd.body: @@ -336,7 +357,7 @@ def extract_fields_from_settings(cd: ClassDef) -> list[str]: if not isinstance(elem.target, Name): continue name: str = elem.target.id - fields.append(f"{prefix}{name}" if prefix else f"{name}") + fields.append(name) return fields diff --git a/tests/cases/main_file/.env.example.expected b/tests/cases/main_file/.env.example.expected new file mode 100644 index 0000000..264e77c --- /dev/null +++ b/tests/cases/main_file/.env.example.expected @@ -0,0 +1,9 @@ +# InheritedSettings +CHILD_FIELD= +PACKAGE_FIELD= + +# MainSettings +MAIN_FIELD= + +# Settings +PACKAGE_FIELD= diff --git a/tests/cases/main_file/project/main.py b/tests/cases/main_file/project/main.py new file mode 100644 index 0000000..7a394af --- /dev/null +++ b/tests/cases/main_file/project/main.py @@ -0,0 +1,10 @@ +from package.module import Settings +from pydantic_settings import BaseSettings + + +class MainSettings(BaseSettings): + main_field: int + + +class InheritedSettings(Settings): + child_field: int diff --git a/tests/cases/main_file/project/package/__init__.py b/tests/cases/main_file/project/package/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cases/main_file/project/package/module.py b/tests/cases/main_file/project/package/module.py new file mode 100644 index 0000000..08c9e5a --- /dev/null +++ b/tests/cases/main_file/project/package/module.py @@ -0,0 +1,5 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + package_field: int diff --git a/tests/cases/multiple_settings/.env.example.expected b/tests/cases/multiple_settings/.env.example.expected index 44c056b..f7828de 100644 --- a/tests/cases/multiple_settings/.env.example.expected +++ b/tests/cases/multiple_settings/.env.example.expected @@ -1,5 +1,5 @@ -# SomeSettings -SOME_FIELD= - # OtherSettings OTHER_FIELD= + +# SomeSettings +SOME_FIELD= diff --git a/tests/cases/qualified_module_import/.env.example.expected b/tests/cases/qualified_module_import/.env.example.expected index 1f544eb..f22f355 100644 --- a/tests/cases/qualified_module_import/.env.example.expected +++ b/tests/cases/qualified_module_import/.env.example.expected @@ -1,6 +1,6 @@ -# ParentSettings -PARENT_FIELD= - # ChildSettings CHILD_FIELD= PARENT_FIELD= + +# ParentSettings +PARENT_FIELD= diff --git a/tests/cases/reexport_inheritance/.env.example.expected b/tests/cases/reexport_inheritance/.env.example.expected index 1f544eb..f22f355 100644 --- a/tests/cases/reexport_inheritance/.env.example.expected +++ b/tests/cases/reexport_inheritance/.env.example.expected @@ -1,6 +1,6 @@ -# ParentSettings -PARENT_FIELD= - # ChildSettings CHILD_FIELD= PARENT_FIELD= + +# ParentSettings +PARENT_FIELD= diff --git a/tests/cases/relative_import/.env.example.expected b/tests/cases/relative_import/.env.example.expected index c9d86ba..0fb8839 100644 --- a/tests/cases/relative_import/.env.example.expected +++ b/tests/cases/relative_import/.env.example.expected @@ -1,11 +1,11 @@ -# ParentSettings +# ChildSettings +CHILD_FIELD= +MIDDLE_FIELD= PARENT_FIELD= # MiddleSettings MIDDLE_FIELD= PARENT_FIELD= -# ChildSettings -CHILD_FIELD= -MIDDLE_FIELD= +# ParentSettings PARENT_FIELD= diff --git a/tests/cases/transitive_inheritance/.env.example.expected b/tests/cases/transitive_inheritance/.env.example.expected index a39e842..7b4e0db 100644 --- a/tests/cases/transitive_inheritance/.env.example.expected +++ b/tests/cases/transitive_inheritance/.env.example.expected @@ -1,6 +1,6 @@ -# ParentSettings -FIELD= - # ChildSettings FIELD= OTHER_FIELD= + +# ParentSettings +FIELD= diff --git a/tests/cases/two_level_transitive_inheritance/.env.example.expected b/tests/cases/two_level_transitive_inheritance/.env.example.expected index 21b3c6f..7f0c731 100644 --- a/tests/cases/two_level_transitive_inheritance/.env.example.expected +++ b/tests/cases/two_level_transitive_inheritance/.env.example.expected @@ -1,11 +1,11 @@ -# GrandparentSettings +# ChildSettings +CHILD_FIELD= GRANDPARENT_FIELD= +PARENT_FIELD= -# ParentSettings +# GrandparentSettings GRANDPARENT_FIELD= -PARENT_FIELD= -# ChildSettings -CHILD_FIELD= +# ParentSettings GRANDPARENT_FIELD= PARENT_FIELD= diff --git a/tests/test_run.py b/tests/test_run.py index 5553a3c..05e43dd 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -28,6 +28,7 @@ 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), ] From 166825295d89de2e3c05d18ba7e48bda67affb15 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:51:33 +0100 Subject: [PATCH 07/10] comments --- src/env_example/main.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/env_example/main.py b/src/env_example/main.py index d351331..4f8000d 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -116,10 +116,9 @@ def generate_env_example( """ Orchestrator function 1. Parse the modules and map the package structure of the project - 2. Build the inheritance hierarchy - 3. Calculate all transitive subclasses of BaseSettings - 4. Extract fields for all settings classes - 5. Write them to an .env.example file + 2. Build the inheritance tree + 3. Parse settings for the subclasses of BaseSettings + 4. Write them to an .env.example file """ exclude_absolute: set[Path] = ( {p.resolve() for p in exclude_relative} if exclude_relative else set() @@ -302,9 +301,7 @@ def build_env_example( sections = [ f"# {qn.leaf}\n" + "\n".join( - f"{parsed.prefix}{field}=".upper() - if parsed.prefix - else f"{field}=".upper() + f"{parsed.prefix or ''}{field}=".upper() for field in sorted(parsed.fields) ) for qn, parsed in sorted( From 1db80c474b8ef195aae3c4bd206a028c1c1211b6 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:55:22 +0100 Subject: [PATCH 08/10] renames --- src/env_example/main.py | 116 ++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/src/env_example/main.py b/src/env_example/main.py index 4f8000d..2c12665 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -109,12 +109,35 @@ def get_children(self, class_node: QualifiedName) -> list[QualifiedName]: return self._children[class_node] +@dataclass +class ParsedSettings: + prefix: str | None = None + fields: set[str] = field(default_factory=set) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--exclude-dir", + default=None, + type=Path, + action="append", + ) + namespace = parser.parse_args() + + cwd = Path.cwd() + generate_env_example( + project_root=cwd, + exclude_relative=namespace.exclude_dir, + ) + + def generate_env_example( project_root: Path, exclude_relative: list[Path] | None, ) -> None: """ - Orchestrator function + Orchestrator function. 1. Parse the modules and map the package structure of the project 2. Build the inheritance tree 3. Parse settings for the subclasses of BaseSettings @@ -124,26 +147,26 @@ def generate_env_example( {p.resolve() for p in exclude_relative} if exclude_relative else set() ) - module_hierarchy: dict[QualifiedName, ParsedModule] = {} + module_lookup: dict[QualifiedName, ParsedModule] = {} for fqn, ast_module in walk_project( root=project_root, exclude_paths=exclude_absolute, ): classes = filter_module_by_type(ast_module, ast.ClassDef) - module_hierarchy[fqn] = ParsedModule( + module_lookup[fqn] = ParsedModule( ast_module=ast_module, classes={cd.name: cd for cd in classes}, ) inheritance = InheritanceTree() - for fqn, parsed_module in module_hierarchy.items(): + for fqn, parsed_module in module_lookup.items(): for class_def in parsed_module.classes.values(): class_fqn = fqn.child(class_def.name) for base in get_bases_from_class(class_def): parent = find_source_or_external_import( searched_symbol=base, search_module=fqn, - module_lookup=module_hierarchy, + module_lookup=module_lookup, ) if parent: inheritance.add_relation( @@ -157,7 +180,7 @@ def generate_env_example( gather_settings_for_subtree( node=child, inheritance_tree=inheritance, - module_hierarchy=module_hierarchy, + module_lookup=module_lookup, parsed_settings=parsed_settings, ) @@ -166,41 +189,6 @@ def generate_env_example( (project_root / OUTPUT_FILE).write_text(env_example_txt) -@dataclass -class ParsedSettings: - prefix: str | None = None - fields: set[str] = field(default_factory=set) - - -def gather_settings_for_subtree( - node: QualifiedName, - inheritance_tree: InheritanceTree, - module_hierarchy: dict[QualifiedName, ParsedModule], - parsed_settings: dict[QualifiedName, ParsedSettings], -) -> None: - """ - Recursively parses fieldsfrom settings classes and adds them to - an aggregator for both the currently considered class and its children. - """ - class_def = module_hierarchy[node.parent].classes[node.leaf] - fields = parse_fields_from_settings(class_def) - prefix = parse_settings_prefix(class_def) - - parsed_settings[node].prefix = prefix - parsed_settings[node].fields.update(fields) - - for child in inheritance_tree.get_children(node): - # add parent fields for the child settings class - parsed_settings[child].fields.update(parsed_settings[node].fields) - - gather_settings_for_subtree( - node=child, - inheritance_tree=inheritance_tree, - module_hierarchy=module_hierarchy, - parsed_settings=parsed_settings, - ) - - def walk_project( root: Path, exclude_paths: set[Path], @@ -244,6 +232,35 @@ def walk_dir( yield from walk_dir(root, parent_package=QualifiedName(())) +def gather_settings_for_subtree( + node: QualifiedName, + inheritance_tree: InheritanceTree, + module_lookup: dict[QualifiedName, ParsedModule], + parsed_settings: dict[QualifiedName, ParsedSettings], +) -> None: + """ + Recursively parses fieldsfrom settings classes and adds them to + 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) + prefix = parse_settings_prefix(class_def) + + parsed_settings[node].prefix = prefix + parsed_settings[node].fields.update(fields) + + for child in inheritance_tree.get_children(node): + # add parent fields for the child settings class + parsed_settings[child].fields.update(parsed_settings[node].fields) + + gather_settings_for_subtree( + node=child, + inheritance_tree=inheritance_tree, + module_lookup=module_lookup, + parsed_settings=parsed_settings, + ) + + def find_source_or_external_import( searched_symbol: QualifiedName, search_module: QualifiedName, @@ -402,22 +419,5 @@ def resolve_import_statements(module: ast.Module) -> list[ImportItem]: return imports -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument( - "--exclude-dir", - default=None, - type=Path, - action="append", - ) - namespace = parser.parse_args() - - cwd = Path.cwd() - generate_env_example( - project_root=cwd, - exclude_relative=namespace.exclude_dir, - ) - - if __name__ == "__main__": main() From bfbcfb8de8de73e7671b54c05aa4205d94a9e233 Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:33:19 +0100 Subject: [PATCH 09/10] dict model config --- README.md | 35 ++++++++++++ src/env_example/main.py | 55 +++++++++++++------ .../project/package/module.py | 2 +- tests/cases/prefix/.env.example.expected | 3 + tests/cases/prefix/project/package/module.py | 5 ++ 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5d28fda..e03a32f 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,38 @@ uvx env-example # Exclude specific directories relative to the project root uvx env-example --exclude-dir other/scripts ``` + +# Example +```python +from pydantic import BaseSettings + + +class AppSettings(BaseSettings): + model_config = { + "env_prefix": "APP__" + } + debug: bool + log_level: str + +class DatabaseSettings(BaseSettings): + model_config = { + "env_prefix": "DB__" + } + host: str + port: int + username: str + password: str +``` + +env-example will generate the following `.env.example` file: +```shell +# AppSettings +APP__DEBUG= +APP__LOG_LEVEL= + +# DatabaseSettings +DB__HOST= +DB__PORT= +DB__USERNAME= +DB__PASSWORD= +``` diff --git a/src/env_example/main.py b/src/env_example/main.py index 2c12665..f4cab13 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -7,6 +7,7 @@ Call, ClassDef, Constant, + Dict, Name, ) from collections import defaultdict @@ -329,33 +330,55 @@ def build_env_example( def parse_settings_prefix(cd: ClassDef) -> str | None: + """ + Parses the model_config configuration to find the configured + prefix. model_config can be given as a SettingConfigDict and + as a plain dict. we cover both cases. + """ prefixes: list[str] = [] for item in cd.body: - if not isinstance(item, (Assign, AnnAssign)): + if isinstance(item, AnnAssign): + target = item.target + value = item.value + elif isinstance(item, Assign) and len(item.targets) == 1: + target = item.targets[0] + value = item.value + else: continue - value = item.value - if not isinstance(value, Call): + if not (isinstance(target, Name) and target.id == "model_config"): continue - if not ( - isinstance(value.func, Name) - and value.func.id == SETTINGS_CONFIG_CLASS - ): - continue - - for kw in value.keywords: - if ( - kw.arg == ENV_PREFIX_ARG - and isinstance(kw.value, Constant) - and isinstance(kw.value.value, str) + if isinstance(value, Call): + # SettingsConfigDict case + if not ( + isinstance(value.func, Name) + and value.func.id == SETTINGS_CONFIG_CLASS ): - prefixes.append(kw.value.value) + continue + for kw in value.keywords: + if ( + kw.arg == ENV_PREFIX_ARG + and isinstance(kw.value, Constant) + and isinstance(kw.value.value, str) + ): + prefixes.append(kw.value.value) + + elif isinstance(value, Dict): + # plain dict case + for key, val in zip(value.keys, value.values): + if ( + isinstance(key, Constant) + and key.value == ENV_PREFIX_ARG + and isinstance(val, Constant) + and isinstance(val.value, str) + ): + prefixes.append(val.value) if len(prefixes) > 1: raise ValueError( - f"Multiple prefixes found for class {cd.name}: {(prefixes,)}" + f"Multiple prefixes found for class {cd.name}: {prefixes}" ) prefix = prefixes[0] if prefixes else None diff --git a/tests/cases/multiple_prefixes/project/package/module.py b/tests/cases/multiple_prefixes/project/package/module.py index ad2933e..c0fb58d 100644 --- a/tests/cases/multiple_prefixes/project/package/module.py +++ b/tests/cases/multiple_prefixes/project/package/module.py @@ -3,5 +3,5 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="first_prefix_") - other_config = SettingsConfigDict(env_prefix="second_prefix_") + model_config = SettingsConfigDict(env_prefix="second_prefix_") field: int diff --git a/tests/cases/prefix/.env.example.expected b/tests/cases/prefix/.env.example.expected index acaf632..1b20255 100644 --- a/tests/cases/prefix/.env.example.expected +++ b/tests/cases/prefix/.env.example.expected @@ -1,2 +1,5 @@ +# DictSettings +DICT_PREFIX__OTHER_FIELD= + # Settings MY_PREFIX__FIELD= diff --git a/tests/cases/prefix/project/package/module.py b/tests/cases/prefix/project/package/module.py index f7b7f5d..b957a4f 100644 --- a/tests/cases/prefix/project/package/module.py +++ b/tests/cases/prefix/project/package/module.py @@ -4,3 +4,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="my_prefix__") field: int + + +class DictSettings(BaseSettings): + model_config = {"env_prefix": "dict_prefix__"} + other_field: str From e5b995b1c21c86f4e1656e3c230b26f981f66f6e Mon Sep 17 00:00:00 2001 From: Jochem Loedeman <60843521+jochemloedeman@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:12:45 +0100 Subject: [PATCH 10/10] remove inheritance --- src/env_example/main.py | 38 +++++++++++--------------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/src/env_example/main.py b/src/env_example/main.py index f4cab13..d95e706 100644 --- a/src/env_example/main.py +++ b/src/env_example/main.py @@ -95,21 +95,6 @@ class ParsedModule: classes: dict[str, ast.ClassDef] -class InheritanceTree: - def __init__(self) -> None: - self._children: defaultdict[QualifiedName, list[QualifiedName]] = ( - defaultdict(list) - ) - - def add_relation( - self, parent_class: QualifiedName, child_class: QualifiedName - ): - self._children[parent_class].append(child_class) - - def get_children(self, class_node: QualifiedName) -> list[QualifiedName]: - return self._children[class_node] - - @dataclass class ParsedSettings: prefix: str | None = None @@ -140,7 +125,7 @@ def generate_env_example( """ Orchestrator function. 1. Parse the modules and map the package structure of the project - 2. Build the inheritance tree + 2. Build a class inheritance lookup 3. Parse settings for the subclasses of BaseSettings 4. Write them to an .env.example file """ @@ -159,7 +144,9 @@ def generate_env_example( classes={cd.name: cd for cd in classes}, ) - inheritance = InheritanceTree() + child_lookup: defaultdict[QualifiedName, list[QualifiedName]] = ( + defaultdict(list) + ) for fqn, parsed_module in module_lookup.items(): for class_def in parsed_module.classes.values(): class_fqn = fqn.child(class_def.name) @@ -170,17 +157,14 @@ def generate_env_example( module_lookup=module_lookup, ) if parent: - inheritance.add_relation( - parent_class=parent, - child_class=class_fqn, - ) + child_lookup[parent].append(class_fqn) parsed_settings = defaultdict(ParsedSettings) - children = inheritance.get_children(BASE_SETTINGS_FQN) + children = child_lookup[BASE_SETTINGS_FQN] for child in children: gather_settings_for_subtree( node=child, - inheritance_tree=inheritance, + child_lookup=child_lookup, module_lookup=module_lookup, parsed_settings=parsed_settings, ) @@ -235,9 +219,9 @@ def walk_dir( def gather_settings_for_subtree( node: QualifiedName, - inheritance_tree: InheritanceTree, + child_lookup: defaultdict[QualifiedName, list[QualifiedName]], module_lookup: dict[QualifiedName, ParsedModule], - parsed_settings: dict[QualifiedName, ParsedSettings], + parsed_settings: defaultdict[QualifiedName, ParsedSettings], ) -> None: """ Recursively parses fieldsfrom settings classes and adds them to @@ -250,13 +234,13 @@ def gather_settings_for_subtree( parsed_settings[node].prefix = prefix parsed_settings[node].fields.update(fields) - for child in inheritance_tree.get_children(node): + for child in child_lookup[node]: # add parent fields for the child settings class parsed_settings[child].fields.update(parsed_settings[node].fields) gather_settings_for_subtree( node=child, - inheritance_tree=inheritance_tree, + child_lookup=child_lookup, module_lookup=module_lookup, parsed_settings=parsed_settings, )