meerschaum.config.environment
Patch the runtime configuration from environment variables.
1#! /usr/bin/env python3 2# -*- coding: utf-8 -*- 3# vim:fenc=utf-8 4 5""" 6Patch the runtime configuration from environment variables. 7""" 8 9import os 10import re 11import json 12import contextlib 13import copy 14import pathlib 15import threading 16 17from meerschaum.utils.typing import List, Union, Dict, Any, Optional 18from meerschaum._internal.static import STATIC_CONFIG 19 20### Serialize the process-global swap of `os.environ` and `meerschaum.config.paths` 21### globals performed by `replace_env`. A daemon process runs several threads 22### concurrently (the job target plus the `check-jobs` `RepeatTimer`), so two 23### overlapping `replace_env` calls could otherwise leave these globals in a torn 24### state. 25_replace_env_lock = threading.RLock() 26 27 28def apply_environment_patches(env: Optional[Dict[str, Any]] = None) -> None: 29 """ 30 Apply patches defined in `MRSM_CONFIG` and `MRSM_PATCH`. 31 """ 32 config_var = STATIC_CONFIG['environment']['config'] 33 patch_var = STATIC_CONFIG['environment']['patch'] 34 apply_environment_config(config_var, env=env) 35 apply_environment_config(patch_var, env=env) 36 37 38def apply_environment_config(env_var: str, env: Optional[Dict[str, Any]] = None) -> None: 39 """ 40 Parse a dictionary (simple or JSON) from an environment variable 41 and apply it to the current configuration. 42 """ 43 from meerschaum.config import get_config, set_config, _config 44 from meerschaum.config._patch import apply_patch_to_config 45 46 env = env if env is not None else os.environ 47 48 if env_var not in env: 49 return 50 51 from meerschaum.utils.misc import string_to_dict 52 try: 53 _patch = string_to_dict(str(os.environ[env_var]).lstrip()) 54 except Exception: 55 _patch = None 56 57 error_msg = ( 58 f"Environment variable {env_var} is set but cannot be parsed.\n" 59 f"Unset {env_var} or change to JSON or simplified dictionary format " 60 "(see --help, under params for formatting)\n" 61 f"{env_var} is set to:\n{os.environ[env_var]}\n" 62 f"Skipping patching os environment into config..." 63 ) 64 65 if not isinstance(_patch, dict): 66 print(error_msg) 67 return 68 69 valids = [] 70 71 def load_key(key: str) -> Union[Dict[str, Any], None]: 72 try: 73 c = get_config(key, warn=False) 74 except Exception: 75 c = None 76 return c 77 78 ### This was multi-threaded, but I ran into all sorts of locking issues. 79 keys = list(_patch.keys()) 80 for key in keys: 81 _ = load_key(key) 82 83 ### Load and patch config files. 84 set_config( 85 apply_patch_to_config( 86 _config(), 87 _patch, 88 ) 89 ) 90 91 92def apply_environment_uris(env: Optional[Dict[str, Any]] = None) -> None: 93 """ 94 Patch temporary connectors defined in environment variables which start with 95 `MRSM_SQL_` or `MRSM_API_`. 96 """ 97 for env_var in get_connector_env_vars(env=env): 98 apply_connector_uri(env_var, env=env) 99 100 101def get_connector_env_regex() -> str: 102 """ 103 Return the regex pattern for valid environment variable names for instance connectors. 104 """ 105 return STATIC_CONFIG['environment']['uri_regex'] 106 107 108def get_connector_env_vars(env: Optional[Dict[str, Any]] = None) -> List[str]: 109 """ 110 Get the names of the environment variables which match the Meerschaum connector regex. 111 112 Examples 113 -------- 114 >>> get_connector_environment_vars() 115 ['MRSM_SQL_FOO'] 116 """ 117 uri_regex = get_connector_env_regex() 118 env_vars = [] 119 120 env = env if env is not None else os.environ 121 122 for env_var in env: 123 matched = re.match(uri_regex, env_var) 124 if matched is None: 125 continue 126 if env_var in STATIC_CONFIG['environment'].values(): 127 continue 128 env_vars.append(env_var) 129 130 return env_vars 131 132 133def apply_connector_uri(env_var: str, env: Optional[Dict[str, Any]] = None) -> None: 134 """ 135 Parse and validate a URI obtained from an environment variable. 136 """ 137 from meerschaum.config import get_config, set_config, _config 138 from meerschaum.config._patch import apply_patch_to_config 139 from meerschaum.config._read_config import search_and_substitute_config 140 from meerschaum.utils.warnings import warn 141 142 env = env if env is not None else os.environ 143 144 if env_var not in env: 145 return 146 147 uri_regex = get_connector_env_regex() 148 matched = re.match(uri_regex, env_var) 149 groups = matched.groups() 150 typ, label = groups[0].lower(), groups[1].lower() 151 if not typ or not label: 152 return 153 154 uri = env[env_var] 155 156 if uri.lstrip().startswith('{') and uri.rstrip().endswith('}'): 157 try: 158 conn_attrs = json.loads(uri) 159 except Exception: 160 warn(f"Unable to parse JSON for environment connector '{typ}:{label}'.") 161 conn_attrs = {'uri': uri} 162 else: 163 conn_attrs = {'uri': uri} 164 165 set_config( 166 apply_patch_to_config( 167 {'meerschaum': get_config('meerschaum')}, 168 {'meerschaum': {'connectors': {typ: {label: conn_attrs}}}}, 169 ) 170 ) 171 172 173def get_env_vars(env: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 174 """ 175 Return all environment variables which begin with `'MRSM_'`. 176 """ 177 prefix = STATIC_CONFIG['environment']['prefix'] 178 env = env if env is not None else os.environ 179 return { 180 env_var: env_val 181 for env_var, env_val in env.items() 182 if env_var.startswith(prefix) 183 } 184 185 186def get_daemon_env_vars(env: Optional[Dict[str, Any]] = None) -> Dict[str, str]: 187 """ 188 Return the daemon-specific environment vars in the current environment. 189 """ 190 env = env if env is not None else os.environ 191 192 daemon_env_var_names = ( 193 STATIC_CONFIG['environment']['systemd_log_path'], 194 STATIC_CONFIG['environment']['systemd_result_path'], 195 STATIC_CONFIG['environment']['systemd_delete_job'], 196 STATIC_CONFIG['environment']['systemd_stdin_path'], 197 STATIC_CONFIG['environment']['daemon_id'], 198 ) 199 return { 200 env_var: env.get(env_var, '') 201 for env_var in daemon_env_var_names 202 if env_var in env 203 } 204 205 206@contextlib.contextmanager 207def replace_env(env: Union[Dict[str, Any], None]): 208 """ 209 Temporarily replace environment variables and current configuration. 210 211 Parameters 212 ---------- 213 env: Dict[str, Any] 214 The new environment dictionary to be patched on `os.environ`. 215 """ 216 if env is None: 217 try: 218 yield 219 except Exception: 220 pass 221 return 222 223 from meerschaum.config import _config, set_config 224 import meerschaum.config.paths as paths 225 226 old_environ = dict(os.environ) 227 old_config = copy.deepcopy(_config()) 228 old_root_dir_path = paths.ROOT_DIR_PATH 229 old_plugins_dir_paths = paths.PLUGINS_DIR_PATHS 230 old_venvs_dir_path = paths.VIRTENV_RESOURCES_PATH 231 old_config_dir_path = paths.CONFIG_DIR_PATH 232 233 root_dir_env_var = STATIC_CONFIG['environment']['root'] 234 plugins_dir_env_var = STATIC_CONFIG['environment']['plugins'] 235 config_dir_env_var = STATIC_CONFIG['environment']['config_dir'] 236 venvs_dir_env_var = STATIC_CONFIG['environment']['venvs'] 237 238 replaced_root = False 239 replaced_plugins = False 240 replaced_venvs = False 241 replaced_config_dir = False 242 243 ### Hold the lock only while swapping the process-global state, not across the 244 ### `yield` (which runs arbitrary user code and could deadlock or serialize the 245 ### whole daemon). 246 with _replace_env_lock: 247 os.environ.update(env) 248 249 if root_dir_env_var in env: 250 root_dir_path = pathlib.Path(env[root_dir_env_var]) 251 paths.set_root(root_dir_path) 252 replaced_root = True 253 254 if plugins_dir_env_var in env: 255 plugins_dir_paths = env[plugins_dir_env_var] 256 paths.set_plugins_dir_paths(plugins_dir_paths) 257 replaced_plugins = True 258 259 if venvs_dir_env_var in env: 260 venv_dir_path = pathlib.Path(env[venvs_dir_env_var]) 261 paths.set_venvs_dir_path(venv_dir_path) 262 replaced_venvs = True 263 264 if config_dir_env_var in env: 265 config_dir_path = pathlib.Path(env[config_dir_env_var]) 266 paths.set_config_dir_path(config_dir_path) 267 replaced_config_dir = True 268 269 apply_environment_patches(env) 270 apply_environment_uris(env) 271 272 ### The `plugins` package in `sys.modules` caches a `__path__` resolved 273 ### against the previous scope's `PLUGINS_RESOURCES_PATH`. If the root or 274 ### plugins-dir scope actually changed, drop that cache so the next plugin 275 ### import re-discovers plugins under the new scope (otherwise module-level 276 ### `from_plugin_import` of a sibling plugin fails with 277 ### "Unable to import plugin '<name>'" and connector-providing plugins 278 ### don't register their connectors). 279 plugins_scope_changed = ( 280 (replaced_root and paths.ROOT_DIR_PATH != old_root_dir_path) 281 or (replaced_plugins and paths.PLUGINS_DIR_PATHS != old_plugins_dir_paths) 282 ) 283 if plugins_scope_changed: 284 from meerschaum.plugins import invalidate_plugins_cache 285 invalidate_plugins_cache() 286 287 try: 288 yield 289 finally: 290 with _replace_env_lock: 291 ### Restore `os.environ` by diffing, NOT `clear()` + `update()`. 292 ### `clear()` blanks the entire environment for a window during which a 293 ### concurrent thread (e.g. a daemon's `sync_plugins_symlinks`) sees 294 ### `MRSM_PLUGINS_DIR` as absent and falls back to the host plugins dir, 295 ### so it never creates a project's plugin symlinks (the `plugin:<name>` 296 ### job then fails to import). Keys present in `old_environ` are never 297 ### removed, so vars like `MRSM_PLUGINS_DIR` never momentarily vanish. 298 for key in [k for k in os.environ if k not in old_environ]: 299 del os.environ[key] 300 for key, val in old_environ.items(): 301 if os.environ.get(key) != val: 302 os.environ[key] = val 303 304 if replaced_root: 305 paths.set_root(old_root_dir_path) 306 307 if replaced_plugins: 308 paths.set_plugins_dir_paths(old_plugins_dir_paths) 309 310 if replaced_venvs: 311 paths.set_venvs_dir_path(old_venvs_dir_path) 312 313 if replaced_config_dir: 314 paths.set_config_dir_path(old_config_dir_path) 315 316 _config().clear() 317 set_config(old_config) 318 319 ### Mirror the invalidation on exit: the scope is being restored, 320 ### so the `plugins` package cached during the temporary scope is 321 ### now the stale one. 322 if plugins_scope_changed: 323 from meerschaum.plugins import invalidate_plugins_cache 324 invalidate_plugins_cache()
29def apply_environment_patches(env: Optional[Dict[str, Any]] = None) -> None: 30 """ 31 Apply patches defined in `MRSM_CONFIG` and `MRSM_PATCH`. 32 """ 33 config_var = STATIC_CONFIG['environment']['config'] 34 patch_var = STATIC_CONFIG['environment']['patch'] 35 apply_environment_config(config_var, env=env) 36 apply_environment_config(patch_var, env=env)
Apply patches defined in MRSM_CONFIG and MRSM_PATCH.
39def apply_environment_config(env_var: str, env: Optional[Dict[str, Any]] = None) -> None: 40 """ 41 Parse a dictionary (simple or JSON) from an environment variable 42 and apply it to the current configuration. 43 """ 44 from meerschaum.config import get_config, set_config, _config 45 from meerschaum.config._patch import apply_patch_to_config 46 47 env = env if env is not None else os.environ 48 49 if env_var not in env: 50 return 51 52 from meerschaum.utils.misc import string_to_dict 53 try: 54 _patch = string_to_dict(str(os.environ[env_var]).lstrip()) 55 except Exception: 56 _patch = None 57 58 error_msg = ( 59 f"Environment variable {env_var} is set but cannot be parsed.\n" 60 f"Unset {env_var} or change to JSON or simplified dictionary format " 61 "(see --help, under params for formatting)\n" 62 f"{env_var} is set to:\n{os.environ[env_var]}\n" 63 f"Skipping patching os environment into config..." 64 ) 65 66 if not isinstance(_patch, dict): 67 print(error_msg) 68 return 69 70 valids = [] 71 72 def load_key(key: str) -> Union[Dict[str, Any], None]: 73 try: 74 c = get_config(key, warn=False) 75 except Exception: 76 c = None 77 return c 78 79 ### This was multi-threaded, but I ran into all sorts of locking issues. 80 keys = list(_patch.keys()) 81 for key in keys: 82 _ = load_key(key) 83 84 ### Load and patch config files. 85 set_config( 86 apply_patch_to_config( 87 _config(), 88 _patch, 89 ) 90 )
Parse a dictionary (simple or JSON) from an environment variable and apply it to the current configuration.
93def apply_environment_uris(env: Optional[Dict[str, Any]] = None) -> None: 94 """ 95 Patch temporary connectors defined in environment variables which start with 96 `MRSM_SQL_` or `MRSM_API_`. 97 """ 98 for env_var in get_connector_env_vars(env=env): 99 apply_connector_uri(env_var, env=env)
Patch temporary connectors defined in environment variables which start with
MRSM_SQL_ or MRSM_API_.
102def get_connector_env_regex() -> str: 103 """ 104 Return the regex pattern for valid environment variable names for instance connectors. 105 """ 106 return STATIC_CONFIG['environment']['uri_regex']
Return the regex pattern for valid environment variable names for instance connectors.
109def get_connector_env_vars(env: Optional[Dict[str, Any]] = None) -> List[str]: 110 """ 111 Get the names of the environment variables which match the Meerschaum connector regex. 112 113 Examples 114 -------- 115 >>> get_connector_environment_vars() 116 ['MRSM_SQL_FOO'] 117 """ 118 uri_regex = get_connector_env_regex() 119 env_vars = [] 120 121 env = env if env is not None else os.environ 122 123 for env_var in env: 124 matched = re.match(uri_regex, env_var) 125 if matched is None: 126 continue 127 if env_var in STATIC_CONFIG['environment'].values(): 128 continue 129 env_vars.append(env_var) 130 131 return env_vars
Get the names of the environment variables which match the Meerschaum connector regex.
Examples
>>> get_connector_environment_vars()
['MRSM_SQL_FOO']
134def apply_connector_uri(env_var: str, env: Optional[Dict[str, Any]] = None) -> None: 135 """ 136 Parse and validate a URI obtained from an environment variable. 137 """ 138 from meerschaum.config import get_config, set_config, _config 139 from meerschaum.config._patch import apply_patch_to_config 140 from meerschaum.config._read_config import search_and_substitute_config 141 from meerschaum.utils.warnings import warn 142 143 env = env if env is not None else os.environ 144 145 if env_var not in env: 146 return 147 148 uri_regex = get_connector_env_regex() 149 matched = re.match(uri_regex, env_var) 150 groups = matched.groups() 151 typ, label = groups[0].lower(), groups[1].lower() 152 if not typ or not label: 153 return 154 155 uri = env[env_var] 156 157 if uri.lstrip().startswith('{') and uri.rstrip().endswith('}'): 158 try: 159 conn_attrs = json.loads(uri) 160 except Exception: 161 warn(f"Unable to parse JSON for environment connector '{typ}:{label}'.") 162 conn_attrs = {'uri': uri} 163 else: 164 conn_attrs = {'uri': uri} 165 166 set_config( 167 apply_patch_to_config( 168 {'meerschaum': get_config('meerschaum')}, 169 {'meerschaum': {'connectors': {typ: {label: conn_attrs}}}}, 170 ) 171 )
Parse and validate a URI obtained from an environment variable.
174def get_env_vars(env: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 175 """ 176 Return all environment variables which begin with `'MRSM_'`. 177 """ 178 prefix = STATIC_CONFIG['environment']['prefix'] 179 env = env if env is not None else os.environ 180 return { 181 env_var: env_val 182 for env_var, env_val in env.items() 183 if env_var.startswith(prefix) 184 }
Return all environment variables which begin with 'MRSM_'.
187def get_daemon_env_vars(env: Optional[Dict[str, Any]] = None) -> Dict[str, str]: 188 """ 189 Return the daemon-specific environment vars in the current environment. 190 """ 191 env = env if env is not None else os.environ 192 193 daemon_env_var_names = ( 194 STATIC_CONFIG['environment']['systemd_log_path'], 195 STATIC_CONFIG['environment']['systemd_result_path'], 196 STATIC_CONFIG['environment']['systemd_delete_job'], 197 STATIC_CONFIG['environment']['systemd_stdin_path'], 198 STATIC_CONFIG['environment']['daemon_id'], 199 ) 200 return { 201 env_var: env.get(env_var, '') 202 for env_var in daemon_env_var_names 203 if env_var in env 204 }
Return the daemon-specific environment vars in the current environment.
207@contextlib.contextmanager 208def replace_env(env: Union[Dict[str, Any], None]): 209 """ 210 Temporarily replace environment variables and current configuration. 211 212 Parameters 213 ---------- 214 env: Dict[str, Any] 215 The new environment dictionary to be patched on `os.environ`. 216 """ 217 if env is None: 218 try: 219 yield 220 except Exception: 221 pass 222 return 223 224 from meerschaum.config import _config, set_config 225 import meerschaum.config.paths as paths 226 227 old_environ = dict(os.environ) 228 old_config = copy.deepcopy(_config()) 229 old_root_dir_path = paths.ROOT_DIR_PATH 230 old_plugins_dir_paths = paths.PLUGINS_DIR_PATHS 231 old_venvs_dir_path = paths.VIRTENV_RESOURCES_PATH 232 old_config_dir_path = paths.CONFIG_DIR_PATH 233 234 root_dir_env_var = STATIC_CONFIG['environment']['root'] 235 plugins_dir_env_var = STATIC_CONFIG['environment']['plugins'] 236 config_dir_env_var = STATIC_CONFIG['environment']['config_dir'] 237 venvs_dir_env_var = STATIC_CONFIG['environment']['venvs'] 238 239 replaced_root = False 240 replaced_plugins = False 241 replaced_venvs = False 242 replaced_config_dir = False 243 244 ### Hold the lock only while swapping the process-global state, not across the 245 ### `yield` (which runs arbitrary user code and could deadlock or serialize the 246 ### whole daemon). 247 with _replace_env_lock: 248 os.environ.update(env) 249 250 if root_dir_env_var in env: 251 root_dir_path = pathlib.Path(env[root_dir_env_var]) 252 paths.set_root(root_dir_path) 253 replaced_root = True 254 255 if plugins_dir_env_var in env: 256 plugins_dir_paths = env[plugins_dir_env_var] 257 paths.set_plugins_dir_paths(plugins_dir_paths) 258 replaced_plugins = True 259 260 if venvs_dir_env_var in env: 261 venv_dir_path = pathlib.Path(env[venvs_dir_env_var]) 262 paths.set_venvs_dir_path(venv_dir_path) 263 replaced_venvs = True 264 265 if config_dir_env_var in env: 266 config_dir_path = pathlib.Path(env[config_dir_env_var]) 267 paths.set_config_dir_path(config_dir_path) 268 replaced_config_dir = True 269 270 apply_environment_patches(env) 271 apply_environment_uris(env) 272 273 ### The `plugins` package in `sys.modules` caches a `__path__` resolved 274 ### against the previous scope's `PLUGINS_RESOURCES_PATH`. If the root or 275 ### plugins-dir scope actually changed, drop that cache so the next plugin 276 ### import re-discovers plugins under the new scope (otherwise module-level 277 ### `from_plugin_import` of a sibling plugin fails with 278 ### "Unable to import plugin '<name>'" and connector-providing plugins 279 ### don't register their connectors). 280 plugins_scope_changed = ( 281 (replaced_root and paths.ROOT_DIR_PATH != old_root_dir_path) 282 or (replaced_plugins and paths.PLUGINS_DIR_PATHS != old_plugins_dir_paths) 283 ) 284 if plugins_scope_changed: 285 from meerschaum.plugins import invalidate_plugins_cache 286 invalidate_plugins_cache() 287 288 try: 289 yield 290 finally: 291 with _replace_env_lock: 292 ### Restore `os.environ` by diffing, NOT `clear()` + `update()`. 293 ### `clear()` blanks the entire environment for a window during which a 294 ### concurrent thread (e.g. a daemon's `sync_plugins_symlinks`) sees 295 ### `MRSM_PLUGINS_DIR` as absent and falls back to the host plugins dir, 296 ### so it never creates a project's plugin symlinks (the `plugin:<name>` 297 ### job then fails to import). Keys present in `old_environ` are never 298 ### removed, so vars like `MRSM_PLUGINS_DIR` never momentarily vanish. 299 for key in [k for k in os.environ if k not in old_environ]: 300 del os.environ[key] 301 for key, val in old_environ.items(): 302 if os.environ.get(key) != val: 303 os.environ[key] = val 304 305 if replaced_root: 306 paths.set_root(old_root_dir_path) 307 308 if replaced_plugins: 309 paths.set_plugins_dir_paths(old_plugins_dir_paths) 310 311 if replaced_venvs: 312 paths.set_venvs_dir_path(old_venvs_dir_path) 313 314 if replaced_config_dir: 315 paths.set_config_dir_path(old_config_dir_path) 316 317 _config().clear() 318 set_config(old_config) 319 320 ### Mirror the invalidation on exit: the scope is being restored, 321 ### so the `plugins` package cached during the temporary scope is 322 ### now the stale one. 323 if plugins_scope_changed: 324 from meerschaum.plugins import invalidate_plugins_cache 325 invalidate_plugins_cache()
Temporarily replace environment variables and current configuration.
Parameters
- env (Dict[str, Any]):
The new environment dictionary to be patched on
os.environ.