policyengine-household-common 0.29.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- policyengine_household_common-0.29.6/.gitignore +16 -0
- policyengine_household_common-0.29.6/PKG-INFO +26 -0
- policyengine_household_common-0.29.6/README.md +14 -0
- policyengine_household_common-0.29.6/policyengine_household_common/__init__.py +0 -0
- policyengine_household_common-0.29.6/policyengine_household_common/analytics_migration.py +1 -0
- policyengine_household_common-0.29.6/policyengine_household_common/config_loader.py +484 -0
- policyengine_household_common-0.29.6/policyengine_household_common/constants.py +27 -0
- policyengine_household_common-0.29.6/policyengine_household_common/deprecated_inputs.py +160 -0
- policyengine_household_common-0.29.6/policyengine_household_common/dispatch_codec.py +76 -0
- policyengine_household_common-0.29.6/policyengine_household_common/gateway.py +325 -0
- policyengine_household_common-0.29.6/policyengine_household_common/household.py +98 -0
- policyengine_household_common-0.29.6/policyengine_household_common/models/__init__.py +0 -0
- policyengine_household_common-0.29.6/policyengine_household_common/models/analytics.py +81 -0
- policyengine_household_common-0.29.6/policyengine_household_common/models/household.py +69 -0
- policyengine_household_common-0.29.6/policyengine_household_common/observability/__init__.py +1 -0
- policyengine_household_common-0.29.6/policyengine_household_common/observability/flask.py +159 -0
- policyengine_household_common-0.29.6/policyengine_household_common/observability/segments.py +44 -0
- policyengine_household_common-0.29.6/policyengine_household_common/release_config.py +278 -0
- policyengine_household_common-0.29.6/policyengine_household_common/release_manifest.py +561 -0
- policyengine_household_common-0.29.6/policyengine_household_common/request_limits.py +26 -0
- policyengine_household_common-0.29.6/policyengine_household_common/routing_metadata.py +41 -0
- policyengine_household_common-0.29.6/policyengine_household_common/variable_usage_analytics.py +359 -0
- policyengine_household_common-0.29.6/policyengine_household_common/version_config.py +4 -0
- policyengine_household_common-0.29.6/policyengine_household_common/version_routing.py +82 -0
- policyengine_household_common-0.29.6/policyengine_household_common/worker_dispatch.py +118 -0
- policyengine_household_common-0.29.6/pyproject.toml +27 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
**/__pycache__
|
|
2
|
+
*.egg-info
|
|
3
|
+
.pytest_cache
|
|
4
|
+
.mypy_cache
|
|
5
|
+
.vscode
|
|
6
|
+
**/*.db
|
|
7
|
+
**/*.db-journal
|
|
8
|
+
dist/*
|
|
9
|
+
**/*.rdb
|
|
10
|
+
**/*.h5
|
|
11
|
+
**/*.csv.gz
|
|
12
|
+
.env
|
|
13
|
+
.ds_store
|
|
14
|
+
|
|
15
|
+
# Generated by modal-deploy-release.sh for Modal image builds
|
|
16
|
+
requirements-modal-*.txt
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: policyengine-household-common
|
|
3
|
+
Version: 0.29.6
|
|
4
|
+
Summary: Shared kernel for the PolicyEngine Household API services
|
|
5
|
+
Author-email: PolicyEngine <hello@policyengine.org>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: flask>=2.2
|
|
8
|
+
Requires-Dist: policyengine-observability[flask,google]<2,>=1.4.0
|
|
9
|
+
Requires-Dist: pydantic
|
|
10
|
+
Requires-Dist: pyyaml>=6
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# policyengine-household-common
|
|
14
|
+
|
|
15
|
+
Shared kernel for the PolicyEngine Household API services: constants, config
|
|
16
|
+
loading, pydantic models, observability integration, version routing, release
|
|
17
|
+
manifest handling, and dispatch codecs.
|
|
18
|
+
|
|
19
|
+
This package deliberately keeps a light dependency closure — no numpy, no
|
|
20
|
+
SQLAlchemy, no country model packages, no modal — because the slim analytics
|
|
21
|
+
writer image installs it. Do not add heavy imports at module level; see the
|
|
22
|
+
repository's `docs/engineering/` guidance before extending it.
|
|
23
|
+
|
|
24
|
+
Published to PyPI because `policyengine-household-api` depends on it. It is
|
|
25
|
+
not a standalone product; its API follows the needs of the Household API
|
|
26
|
+
services.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# policyengine-household-common
|
|
2
|
+
|
|
3
|
+
Shared kernel for the PolicyEngine Household API services: constants, config
|
|
4
|
+
loading, pydantic models, observability integration, version routing, release
|
|
5
|
+
manifest handling, and dispatch codecs.
|
|
6
|
+
|
|
7
|
+
This package deliberately keeps a light dependency closure — no numpy, no
|
|
8
|
+
SQLAlchemy, no country model packages, no modal — because the slim analytics
|
|
9
|
+
writer image installs it. Do not add heavy imports at module level; see the
|
|
10
|
+
repository's `docs/engineering/` guidance before extending it.
|
|
11
|
+
|
|
12
|
+
Published to PyPI because `policyengine-household-api` depends on it. It is
|
|
13
|
+
not a standalone product; its API follows the needs of the Household API
|
|
14
|
+
services.
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ANALYTICS_ALEMBIC_MINIMUM_REVISION = "20260519_0004"
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loader for PolicyEngine Household API.
|
|
3
|
+
|
|
4
|
+
Loads configuration with the following priority (highest to lowest):
|
|
5
|
+
1. Environment variables (override everything)
|
|
6
|
+
2. Mounted config file (if provided via CONFIG_FILE env var)
|
|
7
|
+
3. Default config baked into image
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import yaml
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigLoader:
|
|
21
|
+
"""
|
|
22
|
+
Loads and merges configuration from multiple sources.
|
|
23
|
+
|
|
24
|
+
Priority order (highest wins):
|
|
25
|
+
1. Environment variables
|
|
26
|
+
2. External config file (if CONFIG_FILE is set)
|
|
27
|
+
3. Default config file baked into image
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Default location for baked-in config
|
|
31
|
+
DEFAULT_CONFIG_PATH = "/app/config/default.yaml"
|
|
32
|
+
|
|
33
|
+
# Environment variable to specify external config
|
|
34
|
+
CONFIG_FILE_ENV_VAR = "CONFIG_FILE"
|
|
35
|
+
|
|
36
|
+
# Environment variable to specify config values file
|
|
37
|
+
CONFIG_VALUE_SETTINGS_ENV_VAR = "CONFIG_VALUE_SETTINGS"
|
|
38
|
+
|
|
39
|
+
# Mapping of environment variables to config paths
|
|
40
|
+
# Format: "ENV_VAR_NAME": "config.path.to.value"
|
|
41
|
+
ENV_VAR_MAPPING = {
|
|
42
|
+
# Flask/App settings
|
|
43
|
+
"FLASK_DEBUG": "app.debug",
|
|
44
|
+
# Analytics database settings (for user analytics)
|
|
45
|
+
"USER_ANALYTICS_DB_CONNECTION_NAME": "analytics.database.connection_name",
|
|
46
|
+
"USER_ANALYTICS_DB_USERNAME": "analytics.database.username",
|
|
47
|
+
"USER_ANALYTICS_DB_PASSWORD": "analytics.database.password",
|
|
48
|
+
"ANALYTICS_DATABASE_URL": "analytics.database.url",
|
|
49
|
+
# Auth0 settings
|
|
50
|
+
"AUTH0_ADDRESS_NO_DOMAIN": "auth.auth0.address",
|
|
51
|
+
"AUTH0_AUDIENCE_NO_DOMAIN": "auth.auth0.audience",
|
|
52
|
+
"AUTH0_TEST_TOKEN_NO_DOMAIN": "auth.auth0.test_token",
|
|
53
|
+
"AUTH0_TEST_TOKEN_SCOPES": "auth.auth0.test_token_scopes",
|
|
54
|
+
# Server settings
|
|
55
|
+
"PORT": "server.port",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def __init__(self, default_config_path: Optional[str] = None):
|
|
59
|
+
"""
|
|
60
|
+
Initialize the config loader.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
default_config_path: Override the default config file location
|
|
64
|
+
"""
|
|
65
|
+
self.default_config_path = (
|
|
66
|
+
default_config_path or self.DEFAULT_CONFIG_PATH
|
|
67
|
+
)
|
|
68
|
+
self._config: Optional[Dict[str, Any]] = None
|
|
69
|
+
self._config_values: Optional[Dict[str, str]] = None
|
|
70
|
+
|
|
71
|
+
def load(self) -> Dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Load and merge configuration from all sources.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Merged configuration dictionary
|
|
77
|
+
"""
|
|
78
|
+
if self._config is not None:
|
|
79
|
+
return self._config
|
|
80
|
+
|
|
81
|
+
# Start with empty config
|
|
82
|
+
config = {}
|
|
83
|
+
|
|
84
|
+
# 1. Load default config (lowest priority)
|
|
85
|
+
default_config = self._load_default_config()
|
|
86
|
+
if default_config:
|
|
87
|
+
# Substitute environment variables in default config
|
|
88
|
+
default_config = self._substitute_env_vars(default_config)
|
|
89
|
+
config = self._deep_merge(config, default_config)
|
|
90
|
+
logger.info(
|
|
91
|
+
f"Loaded default config from {self.default_config_path}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# 2. Load external config file if specified
|
|
95
|
+
external_config = self._load_external_config()
|
|
96
|
+
if external_config:
|
|
97
|
+
# Substitute environment variables in external config
|
|
98
|
+
external_config = self._substitute_env_vars(external_config)
|
|
99
|
+
config = self._deep_merge(config, external_config)
|
|
100
|
+
logger.info(
|
|
101
|
+
f"Loaded external config from {os.getenv(self.CONFIG_FILE_ENV_VAR)}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# 3. Override with environment variables (highest priority)
|
|
105
|
+
env_overrides = self._load_env_overrides()
|
|
106
|
+
if env_overrides:
|
|
107
|
+
config = self._deep_merge(config, env_overrides)
|
|
108
|
+
logger.info("Applied environment variable overrides")
|
|
109
|
+
|
|
110
|
+
self._config = config
|
|
111
|
+
return config
|
|
112
|
+
|
|
113
|
+
def _load_default_config(self) -> Optional[Dict[str, Any]]:
|
|
114
|
+
"""Load the default config file baked into the image."""
|
|
115
|
+
path = Path(self.default_config_path)
|
|
116
|
+
if not path.exists():
|
|
117
|
+
# It's acceptable for default config not to exist - just use empty dict
|
|
118
|
+
logger.debug(
|
|
119
|
+
f"Default config not found at {self.default_config_path}, using empty configuration"
|
|
120
|
+
)
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
with open(path, "r") as f:
|
|
125
|
+
content = yaml.safe_load(f)
|
|
126
|
+
return content if content is not None else {}
|
|
127
|
+
except yaml.YAMLError as e:
|
|
128
|
+
logger.error(
|
|
129
|
+
f"Error parsing YAML in default config at {self.default_config_path}: {e}"
|
|
130
|
+
)
|
|
131
|
+
# For YAML errors, return empty dict but log the error
|
|
132
|
+
return {}
|
|
133
|
+
except PermissionError as e:
|
|
134
|
+
logger.error(
|
|
135
|
+
f"Permission denied reading default config at {self.default_config_path}: {e}"
|
|
136
|
+
)
|
|
137
|
+
# For permission errors, return empty dict but log the error
|
|
138
|
+
return {}
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.error(
|
|
141
|
+
f"Unexpected error loading default config at {self.default_config_path}: {e}"
|
|
142
|
+
)
|
|
143
|
+
# For unexpected errors, return empty dict but log the error
|
|
144
|
+
return {}
|
|
145
|
+
|
|
146
|
+
def _load_external_config(self) -> Optional[Dict[str, Any]]:
|
|
147
|
+
"""Load external config file if CONFIG_FILE env var is set."""
|
|
148
|
+
config_file = os.getenv(self.CONFIG_FILE_ENV_VAR)
|
|
149
|
+
if not config_file:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
path = Path(config_file)
|
|
153
|
+
if not path.exists():
|
|
154
|
+
logger.warning(
|
|
155
|
+
f"External config file specified but not found: {config_file}"
|
|
156
|
+
)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
with open(path, "r") as f:
|
|
161
|
+
content = yaml.safe_load(f)
|
|
162
|
+
if content is None:
|
|
163
|
+
logger.debug(
|
|
164
|
+
f"External config file {config_file} is empty"
|
|
165
|
+
)
|
|
166
|
+
return {}
|
|
167
|
+
return content
|
|
168
|
+
except yaml.YAMLError as e:
|
|
169
|
+
logger.error(
|
|
170
|
+
f"Error parsing YAML in external config at {config_file}: {e}"
|
|
171
|
+
)
|
|
172
|
+
return None
|
|
173
|
+
except PermissionError as e:
|
|
174
|
+
logger.error(
|
|
175
|
+
f"Permission denied reading external config at {config_file}: {e}"
|
|
176
|
+
)
|
|
177
|
+
return None
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(
|
|
180
|
+
f"Unexpected error loading external config from {config_file}: {e}"
|
|
181
|
+
)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def _load_config_values_file(self) -> Dict[str, str]:
|
|
185
|
+
"""
|
|
186
|
+
Load configuration values from a file specified by CONFIG_VALUE_SETTINGS.
|
|
187
|
+
|
|
188
|
+
The file should be in .env format:
|
|
189
|
+
KEY=value
|
|
190
|
+
# Comments are allowed
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Dictionary of key-value pairs for substitution
|
|
194
|
+
"""
|
|
195
|
+
if self._config_values is not None:
|
|
196
|
+
return self._config_values
|
|
197
|
+
|
|
198
|
+
config_values = {}
|
|
199
|
+
config_values_file = os.getenv(self.CONFIG_VALUE_SETTINGS_ENV_VAR)
|
|
200
|
+
|
|
201
|
+
if not config_values_file:
|
|
202
|
+
logger.debug("No CONFIG_VALUE_SETTINGS file specified")
|
|
203
|
+
self._config_values = config_values
|
|
204
|
+
return config_values
|
|
205
|
+
|
|
206
|
+
path = Path(config_values_file)
|
|
207
|
+
if not path.exists():
|
|
208
|
+
logger.error(
|
|
209
|
+
f"CONFIG_VALUE_SETTINGS file specified but not found: {config_values_file}"
|
|
210
|
+
)
|
|
211
|
+
raise FileNotFoundError(
|
|
212
|
+
f"Configuration values file not found: {config_values_file}. "
|
|
213
|
+
f"Please ensure the file exists or unset CONFIG_VALUE_SETTINGS."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
with open(path, "r") as f:
|
|
218
|
+
line_number = 0
|
|
219
|
+
for line in f:
|
|
220
|
+
line_number += 1
|
|
221
|
+
# Strip whitespace
|
|
222
|
+
line = line.strip()
|
|
223
|
+
|
|
224
|
+
# Skip empty lines and comments
|
|
225
|
+
if not line or line.startswith("#"):
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
# Parse KEY=value format
|
|
229
|
+
# Use regex to properly handle values with '=' in them
|
|
230
|
+
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", line)
|
|
231
|
+
if not match:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"Invalid format in {config_values_file} at line {line_number}: '{line}'. "
|
|
234
|
+
f"Expected format: KEY=value (KEY must start with letter or underscore, "
|
|
235
|
+
f"followed by letters, numbers, or underscores)"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
key = match.group(1)
|
|
239
|
+
value = match.group(2)
|
|
240
|
+
|
|
241
|
+
# Check for duplicate keys
|
|
242
|
+
if key in config_values:
|
|
243
|
+
logger.warning(
|
|
244
|
+
f"Duplicate key '{key}' in {config_values_file} at line {line_number}. "
|
|
245
|
+
f"Using the latest value."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
config_values[key] = value
|
|
249
|
+
logger.debug(f"Loaded config value: {key}")
|
|
250
|
+
|
|
251
|
+
logger.info(
|
|
252
|
+
f"Loaded {len(config_values)} config values from {config_values_file}"
|
|
253
|
+
)
|
|
254
|
+
self._config_values = config_values
|
|
255
|
+
return config_values
|
|
256
|
+
|
|
257
|
+
except PermissionError as e:
|
|
258
|
+
logger.error(
|
|
259
|
+
f"Permission denied reading config values file at {config_values_file}: {e}"
|
|
260
|
+
)
|
|
261
|
+
raise PermissionError(
|
|
262
|
+
f"Cannot read configuration values file: {config_values_file}. "
|
|
263
|
+
f"Please check file permissions."
|
|
264
|
+
)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.error(
|
|
267
|
+
f"Error loading config values from {config_values_file}: {e}"
|
|
268
|
+
)
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
def _substitute_env_vars(self, config: Any) -> Any:
|
|
272
|
+
"""
|
|
273
|
+
Recursively substitute ${VAR} and $VAR with values from CONFIG_VALUE_SETTINGS file.
|
|
274
|
+
Falls back to environment variables if CONFIG_VALUE_SETTINGS is not set.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
config: Configuration data (dict, list, string, or other)
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Configuration with variables substituted
|
|
281
|
+
"""
|
|
282
|
+
# Load config values from file if specified
|
|
283
|
+
config_values = self._load_config_values_file()
|
|
284
|
+
|
|
285
|
+
if isinstance(config, dict):
|
|
286
|
+
return {k: self._substitute_env_vars(v) for k, v in config.items()}
|
|
287
|
+
elif isinstance(config, list):
|
|
288
|
+
return [self._substitute_env_vars(item) for item in config]
|
|
289
|
+
elif isinstance(config, str):
|
|
290
|
+
# If CONFIG_VALUE_SETTINGS is set, use those values
|
|
291
|
+
if config_values:
|
|
292
|
+
# Custom substitution using config values
|
|
293
|
+
result = config
|
|
294
|
+
# Handle ${VAR} syntax
|
|
295
|
+
for match in re.finditer(
|
|
296
|
+
r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", config
|
|
297
|
+
):
|
|
298
|
+
var_name = match.group(1)
|
|
299
|
+
if var_name in config_values:
|
|
300
|
+
result = result.replace(
|
|
301
|
+
match.group(0), config_values[var_name]
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
logger.warning(
|
|
305
|
+
f"Variable ${{{var_name}}} not found in config values file. "
|
|
306
|
+
f"Leaving as-is."
|
|
307
|
+
)
|
|
308
|
+
# Handle $VAR syntax (but only if followed by non-alphanumeric or at end)
|
|
309
|
+
for match in re.finditer(
|
|
310
|
+
r"\$([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])", config
|
|
311
|
+
):
|
|
312
|
+
var_name = match.group(1)
|
|
313
|
+
if var_name in config_values:
|
|
314
|
+
result = result.replace(
|
|
315
|
+
match.group(0), config_values[var_name]
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
logger.warning(
|
|
319
|
+
f"Variable ${var_name} not found in config values file. "
|
|
320
|
+
f"Leaving as-is."
|
|
321
|
+
)
|
|
322
|
+
return result
|
|
323
|
+
else:
|
|
324
|
+
# Fall back to environment variables
|
|
325
|
+
return os.path.expandvars(config)
|
|
326
|
+
else:
|
|
327
|
+
return config
|
|
328
|
+
|
|
329
|
+
def _load_env_overrides(self) -> Dict[str, Any]:
|
|
330
|
+
"""
|
|
331
|
+
Load configuration overrides from environment variables.
|
|
332
|
+
|
|
333
|
+
Supports two methods:
|
|
334
|
+
1. Explicit mapping (ENV_VAR_MAPPING)
|
|
335
|
+
2. Double underscore notation (DATABASE__HOST -> database.host)
|
|
336
|
+
"""
|
|
337
|
+
overrides = {}
|
|
338
|
+
|
|
339
|
+
# Process explicitly mapped environment variables
|
|
340
|
+
for env_var, config_path in self.ENV_VAR_MAPPING.items():
|
|
341
|
+
value = os.getenv(env_var)
|
|
342
|
+
if value is not None:
|
|
343
|
+
self._set_nested_value(overrides, config_path, value)
|
|
344
|
+
|
|
345
|
+
# Process double-underscore notation env vars
|
|
346
|
+
# Format: SECTION__KEY__SUBKEY -> section.key.subkey
|
|
347
|
+
for key, value in os.environ.items():
|
|
348
|
+
if "__" in key and key not in self.ENV_VAR_MAPPING:
|
|
349
|
+
# Skip system environment variables (those starting with underscores)
|
|
350
|
+
if key.startswith("_"):
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# Convert to lowercase and split
|
|
354
|
+
path_parts = key.lower().split("__")
|
|
355
|
+
|
|
356
|
+
# Skip if any part is empty (e.g., from vars starting/ending with __)
|
|
357
|
+
if any(not part for part in path_parts):
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
config_path = ".".join(path_parts)
|
|
361
|
+
self._set_nested_value(overrides, config_path, value)
|
|
362
|
+
|
|
363
|
+
return overrides
|
|
364
|
+
|
|
365
|
+
def _set_nested_value(
|
|
366
|
+
self, d: Dict[str, Any], path: str, value: Any
|
|
367
|
+
) -> None:
|
|
368
|
+
"""
|
|
369
|
+
Set a nested value in a dictionary using dot notation.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
d: Dictionary to modify
|
|
373
|
+
path: Dot-separated path (e.g., "database.host")
|
|
374
|
+
value: Value to set
|
|
375
|
+
"""
|
|
376
|
+
keys = path.split(".")
|
|
377
|
+
current = d
|
|
378
|
+
|
|
379
|
+
for key in keys[:-1]:
|
|
380
|
+
if key not in current:
|
|
381
|
+
current[key] = {}
|
|
382
|
+
current = current[key]
|
|
383
|
+
|
|
384
|
+
# Convert string values to appropriate types
|
|
385
|
+
current[keys[-1]] = self._convert_value(value)
|
|
386
|
+
|
|
387
|
+
def _convert_value(self, value: str) -> Any:
|
|
388
|
+
"""
|
|
389
|
+
Convert string values to appropriate Python types.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
value: String value from environment variable
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Converted value (int, float, bool, or original string)
|
|
396
|
+
"""
|
|
397
|
+
# Try integer conversion first so that "0" and "1" are kept as
|
|
398
|
+
# their numeric values. Using bool words (true/false/yes/no)
|
|
399
|
+
# exclusively for booleans avoids collapsing numeric 0/1 into
|
|
400
|
+
# False/True, which silently broke ports, counts, and any other
|
|
401
|
+
# int-valued config set via env var.
|
|
402
|
+
try:
|
|
403
|
+
return int(value)
|
|
404
|
+
except ValueError:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
return float(value)
|
|
409
|
+
except ValueError:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
lowered = value.lower()
|
|
413
|
+
if lowered in ("true", "yes"):
|
|
414
|
+
return True
|
|
415
|
+
if lowered in ("false", "no"):
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
return value
|
|
419
|
+
|
|
420
|
+
def _deep_merge(
|
|
421
|
+
self, base: Dict[str, Any], override: Dict[str, Any]
|
|
422
|
+
) -> Dict[str, Any]:
|
|
423
|
+
"""
|
|
424
|
+
Deep merge two dictionaries, with override taking precedence.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
base: Base dictionary
|
|
428
|
+
override: Dictionary with values to override
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Merged dictionary
|
|
432
|
+
"""
|
|
433
|
+
result = base.copy()
|
|
434
|
+
|
|
435
|
+
for key, value in override.items():
|
|
436
|
+
if (
|
|
437
|
+
key in result
|
|
438
|
+
and isinstance(result[key], dict)
|
|
439
|
+
and isinstance(value, dict)
|
|
440
|
+
):
|
|
441
|
+
result[key] = self._deep_merge(result[key], value)
|
|
442
|
+
else:
|
|
443
|
+
result[key] = value
|
|
444
|
+
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
def get(self, path: str, default: Any = None) -> Any:
|
|
448
|
+
"""
|
|
449
|
+
Get a configuration value using dot notation.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
path: Dot-separated path (e.g., "database.host")
|
|
453
|
+
default: Default value if path not found
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Configuration value or default
|
|
457
|
+
"""
|
|
458
|
+
if self._config is None:
|
|
459
|
+
self.load()
|
|
460
|
+
|
|
461
|
+
keys = path.split(".")
|
|
462
|
+
current = self._config
|
|
463
|
+
|
|
464
|
+
for key in keys:
|
|
465
|
+
if isinstance(current, dict) and key in current:
|
|
466
|
+
current = current[key]
|
|
467
|
+
else:
|
|
468
|
+
return default
|
|
469
|
+
|
|
470
|
+
return current
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# Global instance for convenience
|
|
474
|
+
_config_loader = ConfigLoader()
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def get_config() -> Dict[str, Any]:
|
|
478
|
+
"""Get the loaded configuration."""
|
|
479
|
+
return _config_loader.load()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def get_config_value(path: str, default: Any = None) -> Any:
|
|
483
|
+
"""Get a specific configuration value."""
|
|
484
|
+
return _config_loader.get(path, default)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
GET = "GET"
|
|
4
|
+
POST = "POST"
|
|
5
|
+
UPDATE = "UPDATE"
|
|
6
|
+
LIST = "LIST"
|
|
7
|
+
COUNTRIES = ("uk", "us", "ca", "ng", "il")
|
|
8
|
+
COUNTRY_PACKAGE_NAMES = (
|
|
9
|
+
"policyengine_uk",
|
|
10
|
+
"policyengine_us",
|
|
11
|
+
"policyengine_canada",
|
|
12
|
+
"policyengine_ng",
|
|
13
|
+
"policyengine_il",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_package_version(package_name: str) -> str:
|
|
18
|
+
try:
|
|
19
|
+
return version(package_name)
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
return "0.0.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
COUNTRY_PACKAGE_VERSIONS = {
|
|
25
|
+
country: get_package_version(package_name)
|
|
26
|
+
for country, package_name in zip(COUNTRIES, COUNTRY_PACKAGE_NAMES)
|
|
27
|
+
}
|