99
1010import rtoml
1111
12+ ConfigDict = dict [str , Any ]
13+ ExportEnv = dict [str , str ]
14+
1215log = logging .getLogger (__name__ )
1316
1417
@@ -55,6 +58,9 @@ def configure_logging(*, level: LoggingLevel = DEFAULT_LOG_LEVEL) -> None:
5558# ENVIRONMENT & PATHS
5659
5760
61+ ENV_VAR_NAME : Final [str ] = "APP_ENV"
62+
63+
5864class ValidEnvs (StrEnum ):
5965 """
6066 Values should reflect actual directory names.
@@ -76,9 +82,7 @@ class DirContents(StrEnum):
7682 DOTENV_NAME = ".env"
7783
7884
79- ENV_VAR_NAME : Final [str ] = "APP_ENV"
80-
81- BASE_DIR_PATH : Final [Path ] = Path (__file__ ).resolve ().parent .parent
85+ BASE_DIR_PATH : Final [Path ] = Path (__file__ ).resolve ().parents [1 ]
8286CONFIG_PATH : Final [Path ] = BASE_DIR_PATH / "config"
8387
8488ENV_TO_DIR_PATHS : Final [Mapping [ValidEnvs , Path ]] = MappingProxyType ({
@@ -88,7 +92,7 @@ class DirContents(StrEnum):
8892})
8993
9094
91- def validate_env (* , env : str | None ) -> ValidEnvs :
95+ def validate_env (env : str | None ) -> ValidEnvs :
9296 if env is None :
9397 raise ValueError (f"{ ENV_VAR_NAME } is not set." )
9498 try :
@@ -101,19 +105,33 @@ def validate_env(*, env: str | None) -> ValidEnvs:
101105
102106
103107def get_current_env () -> ValidEnvs :
104- env_value = os .getenv (ENV_VAR_NAME )
105- return validate_env (env = env_value )
108+ return validate_env (os .getenv (ENV_VAR_NAME ))
106109
107110
108111# CONFIG READING
109112
110113
114+ def load_full_config (
115+ env : ValidEnvs ,
116+ dir_paths : Mapping [ValidEnvs , Path ] = ENV_TO_DIR_PATHS ,
117+ main_config : DirContents = DirContents .CONFIG_NAME ,
118+ secrets_config : DirContents = DirContents .SECRETS_NAME ,
119+ ) -> ConfigDict :
120+ log .info ("Reading config for environment: '%s'" , env )
121+ config = read_config (env = env , config = main_config , dir_paths = dir_paths )
122+ try :
123+ secrets = read_config (env = env , config = secrets_config , dir_paths = dir_paths )
124+ except FileNotFoundError :
125+ log .warning ("Secrets file not found. Full config will not contain secrets." )
126+ return config
127+ return merge_dicts (dict1 = config , dict2 = secrets )
128+
129+
111130def read_config (
112- * ,
113131 env : ValidEnvs ,
114- config : DirContents ,
115132 dir_paths : Mapping [ValidEnvs , Path ],
116- ) -> dict [str , Any ]:
133+ config : DirContents ,
134+ ) -> ConfigDict :
117135 dir_path = dir_paths .get (env )
118136 if dir_path is None :
119137 raise FileNotFoundError (f"No directory path configured for environment: { env } " )
@@ -126,7 +144,7 @@ def read_config(
126144 return rtoml .load (file )
127145
128146
129- def merge_dicts (* , dict1 : dict [ str , Any ], dict2 : dict [ str , Any ] ) -> dict [ str , Any ] :
147+ def merge_dicts (* , dict1 : ConfigDict , dict2 : ConfigDict ) -> ConfigDict :
130148 result = dict1 .copy ()
131149 for key , value in dict2 .items ():
132150 if key in result and isinstance (result [key ], dict ) and isinstance (value , dict ):
@@ -136,31 +154,68 @@ def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, An
136154 return result
137155
138156
139- def load_full_config (
140- * ,
157+ # EXPORT PROCESSING
158+
159+
160+ EXPORT_SECTION : Final [str ] = "export"
161+ EXPORT_FIELDS_KEY : Final [str ] = "fields"
162+
163+
164+ def get_exported_env_variables (
141165 env : ValidEnvs ,
142- main_config : DirContents = DirContents .CONFIG_NAME ,
143- secrets_config : DirContents = DirContents .SECRETS_NAME ,
144166 dir_paths : Mapping [ValidEnvs , Path ] = ENV_TO_DIR_PATHS ,
145- ) -> dict [str , Any ]:
146- log .info ("Reading config for environment: '%s'" , env )
147- config = read_config (env = env , config = main_config , dir_paths = dir_paths )
148- try :
149- secrets = read_config (env = env , config = secrets_config , dir_paths = dir_paths )
150- except FileNotFoundError :
151- log .warning ("Secrets file not found. Full config will not contain secrets." )
152- return config
153- return merge_dicts (dict1 = config , dict2 = secrets )
167+ ) -> ExportEnv :
168+ config = load_full_config (env = env , dir_paths = dir_paths )
169+ export_fields = load_export_fields (env = env , dir_paths = dir_paths )
170+ return extract_export_fields_from_config (config = config , export_fields = export_fields )
154171
155172
156- # EXPORT PROCESSING
173+ def load_export_fields (
174+ env : ValidEnvs ,
175+ dir_paths : Mapping [ValidEnvs , Path ],
176+ ) -> list [str ]:
177+ export_data = read_config (
178+ env = env ,
179+ config = DirContents .EXPORT_NAME ,
180+ dir_paths = dir_paths ,
181+ )
182+
183+ export_section = export_data .get (EXPORT_SECTION )
184+ if not isinstance (export_section , dict ):
185+ raise ValueError (
186+ f"Invalid { DirContents .EXPORT_NAME } : missing [{ EXPORT_SECTION } ] section"
187+ )
188+
189+ fields = export_section .get (EXPORT_FIELDS_KEY )
190+ if not isinstance (fields , list ) or not all (isinstance (f , str ) for f in fields ):
191+ raise ValueError (
192+ f"Invalid { DirContents .EXPORT_NAME } : "
193+ f"'{ EXPORT_FIELDS_KEY } ' must be a list of strings"
194+ )
195+ if not fields :
196+ raise ValueError (
197+ f"Invalid { DirContents .EXPORT_NAME } : '{ EXPORT_FIELDS_KEY } ' cannot be empty"
198+ )
157199
200+ return fields
158201
159- def get_env_value_by_export_field (* , config : dict [str , Any ], field : str ) -> Any :
160- parts = field .split ("." )
202+
203+ def extract_export_fields_from_config (
204+ config : ConfigDict ,
205+ export_fields : list [str ],
206+ ) -> ExportEnv :
207+ result : ExportEnv = {}
208+ for field in export_fields :
209+ str_value = get_env_value_by_export_field (config = config , field = field )
210+ env_key = "_" .join (part .upper () for part in field .split ("." ))
211+ result [env_key ] = str_value
212+ return result
213+
214+
215+ def get_env_value_by_export_field (* , config : ConfigDict , field : str ) -> str :
161216 current = config
162- for part in parts :
163- if part not in current :
217+ for part in field . split ( "." ) :
218+ if not isinstance ( current , dict ) or part not in current :
164219 raise KeyError (f"Field '{ field } ' not found in config" )
165220 current = current [part ]
166221
@@ -169,85 +224,59 @@ def get_env_value_by_export_field(*, config: dict[str, Any], field: str) -> Any:
169224 f"Field '{ field } ' cannot be converted to string: "
170225 f"got { type (current ).__name__ } " ,
171226 )
227+
172228 try :
173229 return str (current )
174230 except (TypeError , ValueError ) as e :
175231 raise ValueError (f"Field '{ field } ' cannot be converted to string: { e !s} " ) from e
176232
177233
178- def extract_exported (
179- * ,
180- config : dict [str , Any ],
181- export_fields : list [str ],
182- ) -> dict [str , str ]:
183- result : dict [str , str ] = {}
184- for field in export_fields :
185- str_value = get_env_value_by_export_field (config = config , field = field )
186- env_key = "_" .join (part .upper () for part in field .split ("." ))
187- result [env_key ] = str_value
188- return result
189-
190-
191- def load_export_fields (* , env : ValidEnvs ) -> tuple [dict [str , Any ], list [str ]]:
192- config = load_full_config (env = env )
193- export_data = read_config (
194- env = env ,
195- config = DirContents .EXPORT_NAME ,
196- dir_paths = ENV_TO_DIR_PATHS ,
197- )
198- if "export" not in export_data or "fields" not in export_data ["export" ]:
199- raise ValueError ("Invalid export.toml: missing [export] section or 'fields'" )
200- export_fields = export_data ["export" ]["fields" ]
201- return config , export_fields
234+ # DOTENV GENERATION
202235
203236
204- # DOTENV GENERATION
237+ def write_dotenv_file (
238+ * ,
239+ env : ValidEnvs ,
240+ exported_fields : ExportEnv ,
241+ generated_at : datetime | None = None ,
242+ ) -> None :
243+ if generated_at is None :
244+ generated_at = datetime .now (UTC )
205245
246+ dotenv_filename = f"{ DirContents .DOTENV_NAME } .{ env .value } "
247+ dotenv_path = ENV_TO_DIR_PATHS [env ] / dotenv_filename
206248
207- def write_dotenv_file (* , env : ValidEnvs , exported_fields : dict [str , str ]) -> None :
208- env_filename = f"{ DirContents .DOTENV_NAME } .{ env .value } "
209- env_path = ENV_TO_DIR_PATHS [env ] / env_filename
210249 header = [
211250 "# This .env file was automatically generated by toml_config_manager." ,
212251 "# Do not edit directly. Make changes in config.toml or .secrets.toml instead." ,
213252 "# Ensure values here match those in config files." ,
214253 f"# Environment: { env } " ,
215- f"# Generated: { datetime . now ( UTC ) .isoformat ()} " ,
254+ f"# Generated: { generated_at .isoformat ()} " ,
216255 ]
217256 body = [f"{ key } ={ value } " for key , value in exported_fields .items ()]
218257 body .append ("" )
219258
220- with open (env_path , "w" , encoding = "utf-8" ) as f :
259+ with open (dotenv_path , "w" , encoding = "utf-8" ) as f :
221260 f .write ("\n " .join (header + body ))
222261
223- try :
224- relative_path = env_path .relative_to (BASE_DIR_PATH )
225- except ValueError :
226- relative_path = env_path
227-
228262 log .info (
229263 "Dotenv for environment '%s' was successfully generated at '%s'! ✨" ,
230264 env .value ,
231- relative_path ,
265+ str ( dotenv_path . resolve ()) ,
232266 )
233267
234268
235- def generate_dotenv (* , env : ValidEnvs ) -> None :
236- config , export_fields = load_export_fields (env = env )
237- exported_fields = extract_exported (config = config , export_fields = export_fields )
238- write_dotenv_file (env = env , exported_fields = exported_fields )
239-
240-
241269# ENTRY POINT
242270
243271
244272def main () -> None :
245- log_lvl : str = os .getenv (LOG_LEVEL_VAR_NAME , DEFAULT_LOG_LEVEL )
246- validated_log_lvl : LoggingLevel = validate_logging_level (level = log_lvl )
247- configure_logging (level = validated_log_lvl )
273+ log_lvl_str = os .getenv (LOG_LEVEL_VAR_NAME , DEFAULT_LOG_LEVEL )
274+ log_lvl = validate_logging_level (level = log_lvl_str )
275+ configure_logging (level = log_lvl )
248276
249- current_env = get_current_env ()
250- generate_dotenv (env = current_env )
277+ env = get_current_env ()
278+ exported_fields = get_exported_env_variables (env )
279+ write_dotenv_file (env = env , exported_fields = exported_fields )
251280
252281
253282if __name__ == "__main__" :
0 commit comments