Skip to content

Commit cef4774

Browse files
committed
Refactor config management
1 parent 30f1cf4 commit cef4774

3 files changed

Lines changed: 131 additions & 103 deletions

File tree

config/toml_config_manager.py

Lines changed: 104 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
import rtoml
1111

12+
ConfigDict = dict[str, Any]
13+
ExportEnv = dict[str, str]
14+
1215
log = 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+
5864
class 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]
8286
CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config"
8387

8488
ENV_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

103107
def 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+
111130
def 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

244272
def 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

253282
if __name__ == "__main__":

src/app/setup/config/loader.py

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88

99
import rtoml
1010

11+
ConfigDict = dict[str, Any]
12+
1113
log = logging.getLogger(__name__)
1214

15+
ENV_VAR_NAME: Final[str] = "APP_ENV"
16+
1317

1418
class ValidEnvs(StrEnum):
1519
"""
@@ -32,8 +36,6 @@ class DirContents(StrEnum):
3236
DOTENV_NAME = ".env"
3337

3438

35-
ENV_VAR_NAME: Final[str] = "APP_ENV"
36-
3739
BASE_DIR_PATH = Path(__file__).resolve().parents[4]
3840
CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config"
3941

@@ -44,7 +46,7 @@ class DirContents(StrEnum):
4446
})
4547

4648

47-
def validate_env(*, env: str | None) -> ValidEnvs:
49+
def validate_env(env: str | None) -> ValidEnvs:
4850
if env is None:
4951
raise ValueError(f"{ENV_VAR_NAME} is not set.")
5052
try:
@@ -57,16 +59,30 @@ def validate_env(*, env: str | None) -> ValidEnvs:
5759

5860

5961
def get_current_env() -> ValidEnvs:
60-
env_value = os.getenv(ENV_VAR_NAME)
61-
return validate_env(env=env_value)
62+
return validate_env(os.getenv(ENV_VAR_NAME))
63+
64+
65+
def load_full_config(
66+
env: ValidEnvs,
67+
dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS,
68+
main_config: DirContents = DirContents.CONFIG_NAME,
69+
secrets_config: DirContents = DirContents.SECRETS_NAME,
70+
) -> ConfigDict:
71+
log.info("Reading config for environment: '%s'", env)
72+
config = read_config(env=env, config=main_config, dir_paths=dir_paths)
73+
try:
74+
secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths)
75+
except FileNotFoundError:
76+
log.warning("Secrets file not found. Full config will not contain secrets.")
77+
return config
78+
return merge_dicts(dict1=config, dict2=secrets)
6279

6380

6481
def read_config(
65-
*,
6682
env: ValidEnvs,
67-
config: DirContents,
6883
dir_paths: Mapping[ValidEnvs, Path],
69-
) -> dict[str, Any]:
84+
config: DirContents,
85+
) -> ConfigDict:
7086
dir_path = dir_paths.get(env)
7187
if dir_path is None:
7288
raise FileNotFoundError(f"No directory path configured for environment: {env}")
@@ -79,28 +95,11 @@ def read_config(
7995
return rtoml.load(file)
8096

8197

82-
def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]:
98+
def merge_dicts(*, dict1: ConfigDict, dict2: ConfigDict) -> ConfigDict:
8399
result = dict1.copy()
84100
for key, value in dict2.items():
85101
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
86102
result[key] = merge_dicts(dict1=result[key], dict2=value)
87103
else:
88104
result[key] = value
89105
return result
90-
91-
92-
def load_full_config(
93-
*,
94-
env: ValidEnvs,
95-
main_config: DirContents = DirContents.CONFIG_NAME,
96-
secrets_config: DirContents = DirContents.SECRETS_NAME,
97-
dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS,
98-
) -> dict[str, Any]:
99-
log.info("Reading config for environment: '%s'", env)
100-
config = read_config(env=env, config=main_config, dir_paths=dir_paths)
101-
try:
102-
secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths)
103-
except FileNotFoundError:
104-
log.warning("Secrets file not found. Full config will not contain secrets.")
105-
return config
106-
return merge_dicts(dict1=config, dict2=secrets)

tests/app/unit/setup/test_cfg_loader.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
@pytest.mark.parametrize("env", list(ValidEnvs))
2020
def test_returns_enum_for_correct_env_string(env: ValidEnvs) -> None:
21-
assert validate_env(env=env) == env
21+
assert validate_env(env) == env
2222

2323

2424
@pytest.mark.parametrize(
@@ -27,7 +27,7 @@ def test_returns_enum_for_correct_env_string(env: ValidEnvs) -> None:
2727
)
2828
def test_raises_for_incorrect_env_string_or_none(env: str | None) -> None:
2929
with pytest.raises(ValueError):
30-
validate_env(env=env)
30+
validate_env(env)
3131

3232

3333
@pytest.mark.parametrize("env_str", list(ValidEnvs))

0 commit comments

Comments
 (0)