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()
def apply_environment_patches(env: Optional[Dict[str, Any]] = None) -> None:
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.

def apply_environment_config(env_var: str, env: Optional[Dict[str, Any]] = None) -> None:
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.

def apply_environment_uris(env: Optional[Dict[str, Any]] = None) -> None:
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_.

def get_connector_env_regex() -> str:
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.

def get_connector_env_vars(env: Optional[Dict[str, Any]] = None) -> List[str]:
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']
def apply_connector_uri(env_var: str, env: Optional[Dict[str, Any]] = None) -> None:
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.

def get_env_vars(env: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
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_'.

def get_daemon_env_vars(env: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
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.

@contextlib.contextmanager
def replace_env(env: Optional[Dict[str, Any]]):
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.