1# Copyright 2020 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""The envparse module defines an environment variable parser.""" 15 16import argparse 17from dataclasses import dataclass 18import os 19from typing import Callable, Dict, Generic, IO, List, Mapping, Optional, TypeVar 20from typing import Union 21 22 23class EnvNamespace(argparse.Namespace): # pylint: disable=too-few-public-methods 24 """Base class for parsed environment variable namespaces.""" 25 26 27T = TypeVar('T') 28TypeConversion = Callable[[str], T] 29 30 31@dataclass 32class VariableDescriptor(Generic[T]): 33 name: str 34 type: TypeConversion[T] 35 default: Optional[T] 36 37 38class EnvironmentValueError(Exception): 39 """Exception indicating a bad type conversion on an environment variable. 40 41 Stores a reference to the lower-level exception from the type conversion 42 function through the __cause__ attribute for more detailed information on 43 the error. 44 """ 45 def __init__(self, variable: str, value: str): 46 self.variable: str = variable 47 self.value: str = value 48 super().__init__( 49 f'Bad value for environment variable {variable}: {value}') 50 51 52class EnvironmentParser: 53 """Parser for environment variables. 54 55 Args: 56 prefix: If provided, checks that all registered environment variables 57 start with the specified string. 58 error_on_unrecognized: If True and prefix is provided, will raise an 59 exception if the environment contains a variable with the specified 60 prefix that is not registered on the EnvironmentParser. If None, 61 checks existence of PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED (but not 62 value). 63 64 Example: 65 66 parser = envparse.EnvironmentParser(prefix='PW_') 67 parser.add_var('PW_LOG_LEVEL') 68 parser.add_var('PW_LOG_FILE', type=envparse.FileType('w')) 69 parser.add_var('PW_USE_COLOR', type=envparse.strict_bool, default=False) 70 env = parser.parse_env() 71 72 configure_logging(env.PW_LOG_LEVEL, env.PW_LOG_FILE) 73 """ 74 def __init__(self, 75 prefix: Optional[str] = None, 76 error_on_unrecognized: Union[bool, None] = None) -> None: 77 self._prefix: Optional[str] = prefix 78 if error_on_unrecognized is None: 79 varname = 'PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED' 80 error_on_unrecognized = varname not in os.environ 81 self._error_on_unrecognized: bool = error_on_unrecognized 82 83 self._variables: Dict[str, VariableDescriptor] = {} 84 self._allowed_suffixes: List[str] = [] 85 86 def add_var( 87 self, 88 name: str, 89 # pylint: disable=redefined-builtin 90 type: TypeConversion[T] = str, # type: ignore[assignment] 91 # pylint: enable=redefined-builtin 92 default: Optional[T] = None, 93 ) -> None: 94 """Registers an environment variable. 95 96 Args: 97 name: The environment variable's name. 98 type: Type conversion for the variable's value. 99 default: Default value for the variable. 100 101 Raises: 102 ValueError: If prefix was provided to the constructor and name does 103 not start with the prefix. 104 """ 105 if self._prefix is not None and not name.startswith(self._prefix): 106 raise ValueError( 107 f'Variable {name} does not have prefix {self._prefix}') 108 109 self._variables[name] = VariableDescriptor(name, type, default) 110 111 def add_allowed_suffix(self, suffix: str) -> None: 112 """Registers an environment variable name suffix to be allowed.""" 113 114 self._allowed_suffixes.append(suffix) 115 116 def parse_env(self, 117 env: Optional[Mapping[str, str]] = None) -> EnvNamespace: 118 """Parses known environment variables into a namespace. 119 120 Args: 121 env: Dictionary of environment variables. Defaults to os.environ. 122 123 Raises: 124 EnvironmentValueError: If the type conversion fails. 125 """ 126 if env is None: 127 env = os.environ 128 129 namespace = EnvNamespace() 130 131 for var, desc in self._variables.items(): 132 if var not in env: 133 val = desc.default 134 else: 135 try: 136 val = desc.type(env[var]) # type: ignore 137 except Exception as err: 138 raise EnvironmentValueError(var, env[var]) from err 139 140 setattr(namespace, var, val) 141 142 allowed_suffixes = tuple(self._allowed_suffixes) 143 for var in env: 144 if (not hasattr(namespace, var) 145 and (self._prefix is None or var.startswith(self._prefix)) 146 and var.endswith(allowed_suffixes)): 147 setattr(namespace, var, env[var]) 148 149 if self._prefix is not None and self._error_on_unrecognized: 150 for var in env: 151 if (var.startswith(self._prefix) and var not in self._variables 152 and not var.endswith(allowed_suffixes)): 153 raise ValueError( 154 f'Unrecognized environment variable {var}') 155 156 return namespace 157 158 def __repr__(self) -> str: 159 return f'{type(self).__name__}(prefix={self._prefix})' 160 161 162# List of emoji which are considered to represent "True". 163_BOOLEAN_TRUE_EMOJI = set([ 164 '✔️', 165 '', 166 '', 167 '', 168 '', 169 '', 170 '', 171 '', 172]) 173 174 175def strict_bool(value: str) -> bool: 176 return (value == '1' or value.lower() == 'true' 177 or value in _BOOLEAN_TRUE_EMOJI) 178 179 180# TODO(mohrr) Switch to Literal when no longer supporting Python 3.7. 181# OpenMode = Literal['r', 'rb', 'w', 'wb'] 182OpenMode = str 183 184 185class FileType: 186 def __init__(self, mode: OpenMode) -> None: 187 self._mode: OpenMode = mode 188 189 def __call__(self, value: str) -> IO: 190 return open(value, self._mode) 191