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.
Files changed (26) hide show
  1. policyengine_household_common-0.29.6/.gitignore +16 -0
  2. policyengine_household_common-0.29.6/PKG-INFO +26 -0
  3. policyengine_household_common-0.29.6/README.md +14 -0
  4. policyengine_household_common-0.29.6/policyengine_household_common/__init__.py +0 -0
  5. policyengine_household_common-0.29.6/policyengine_household_common/analytics_migration.py +1 -0
  6. policyengine_household_common-0.29.6/policyengine_household_common/config_loader.py +484 -0
  7. policyengine_household_common-0.29.6/policyengine_household_common/constants.py +27 -0
  8. policyengine_household_common-0.29.6/policyengine_household_common/deprecated_inputs.py +160 -0
  9. policyengine_household_common-0.29.6/policyengine_household_common/dispatch_codec.py +76 -0
  10. policyengine_household_common-0.29.6/policyengine_household_common/gateway.py +325 -0
  11. policyengine_household_common-0.29.6/policyengine_household_common/household.py +98 -0
  12. policyengine_household_common-0.29.6/policyengine_household_common/models/__init__.py +0 -0
  13. policyengine_household_common-0.29.6/policyengine_household_common/models/analytics.py +81 -0
  14. policyengine_household_common-0.29.6/policyengine_household_common/models/household.py +69 -0
  15. policyengine_household_common-0.29.6/policyengine_household_common/observability/__init__.py +1 -0
  16. policyengine_household_common-0.29.6/policyengine_household_common/observability/flask.py +159 -0
  17. policyengine_household_common-0.29.6/policyengine_household_common/observability/segments.py +44 -0
  18. policyengine_household_common-0.29.6/policyengine_household_common/release_config.py +278 -0
  19. policyengine_household_common-0.29.6/policyengine_household_common/release_manifest.py +561 -0
  20. policyengine_household_common-0.29.6/policyengine_household_common/request_limits.py +26 -0
  21. policyengine_household_common-0.29.6/policyengine_household_common/routing_metadata.py +41 -0
  22. policyengine_household_common-0.29.6/policyengine_household_common/variable_usage_analytics.py +359 -0
  23. policyengine_household_common-0.29.6/policyengine_household_common/version_config.py +4 -0
  24. policyengine_household_common-0.29.6/policyengine_household_common/version_routing.py +82 -0
  25. policyengine_household_common-0.29.6/policyengine_household_common/worker_dispatch.py +118 -0
  26. 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.
@@ -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
+ }