• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 (
20    Callable,
21    Dict,
22    Generic,
23    IO,
24    List,
25    Mapping,
26    Optional,
27    TypeVar,
28    Union,
29)
30
31
32class EnvNamespace(
33    argparse.Namespace
34):  # pylint: disable=too-few-public-methods
35    """Base class for parsed environment variable namespaces."""
36
37
38T = TypeVar('T')
39TypeConversion = Callable[[str], T]
40
41
42@dataclass
43class VariableDescriptor(Generic[T]):
44    name: str
45    type: TypeConversion[T]
46    default: Optional[T]
47
48
49class EnvironmentValueError(Exception):
50    """Exception indicating a bad type conversion on an environment variable.
51
52    Stores a reference to the lower-level exception from the type conversion
53    function through the __cause__ attribute for more detailed information on
54    the error.
55    """
56
57    def __init__(self, variable: str, value: str):
58        self.variable: str = variable
59        self.value: str = value
60        super().__init__(
61            f'Bad value for environment variable {variable}: {value}'
62        )
63
64
65class EnvironmentParser:
66    """Parser for environment variables.
67
68    Args:
69        prefix: If provided, checks that all registered environment variables
70          start with the specified string.
71        error_on_unrecognized: If True and prefix is provided, will raise an
72          exception if the environment contains a variable with the specified
73          prefix that is not registered on the EnvironmentParser. If None,
74          checks existence of PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED (but not
75          value).
76
77    Example:
78
79        parser = envparse.EnvironmentParser(prefix='PW_')
80        parser.add_var('PW_LOG_LEVEL')
81        parser.add_var('PW_LOG_FILE', type=envparse.FileType('w'))
82        parser.add_var('PW_USE_COLOR', type=envparse.strict_bool, default=False)
83        env = parser.parse_env()
84
85        configure_logging(env.PW_LOG_LEVEL, env.PW_LOG_FILE)
86    """
87
88    def __init__(
89        self,
90        prefix: Optional[str] = None,
91        error_on_unrecognized: Union[bool, None] = None,
92    ) -> None:
93        self._prefix: Optional[str] = prefix
94        if error_on_unrecognized is None:
95            varname = 'PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED'
96            error_on_unrecognized = varname not in os.environ
97        self._error_on_unrecognized: bool = error_on_unrecognized
98
99        self._variables: Dict[str, VariableDescriptor] = {}
100        self._allowed_suffixes: List[str] = []
101
102    def add_var(
103        self,
104        name: str,
105        # pylint: disable=redefined-builtin
106        type: TypeConversion[T] = str,  # type: ignore[assignment]
107        # pylint: enable=redefined-builtin
108        default: Optional[T] = None,
109    ) -> None:
110        """Registers an environment variable.
111
112        Args:
113            name: The environment variable's name.
114            type: Type conversion for the variable's value.
115            default: Default value for the variable.
116
117        Raises:
118            ValueError: If prefix was provided to the constructor and name does
119              not start with the prefix.
120        """
121        if self._prefix is not None and not name.startswith(self._prefix):
122            raise ValueError(
123                f'Variable {name} does not have prefix {self._prefix}'
124            )
125
126        self._variables[name] = VariableDescriptor(name, type, default)
127
128    def add_allowed_suffix(self, suffix: str) -> None:
129        """Registers an environment variable name suffix to be allowed."""
130
131        self._allowed_suffixes.append(suffix)
132
133    def parse_env(
134        self, env: Optional[Mapping[str, str]] = None
135    ) -> EnvNamespace:
136        """Parses known environment variables into a namespace.
137
138        Args:
139            env: Dictionary of environment variables. Defaults to os.environ.
140
141        Raises:
142            EnvironmentValueError: If the type conversion fails.
143        """
144        if env is None:
145            env = os.environ
146
147        namespace = EnvNamespace()
148
149        for var, desc in self._variables.items():
150            if var not in env:
151                val = desc.default
152            else:
153                try:
154                    val = desc.type(env[var])  # type: ignore
155                except Exception as err:
156                    raise EnvironmentValueError(var, env[var]) from err
157
158            setattr(namespace, var, val)
159
160        allowed_suffixes = tuple(self._allowed_suffixes)
161        for var in env:
162            if (
163                not hasattr(namespace, var)
164                and (self._prefix is None or var.startswith(self._prefix))
165                and var.endswith(allowed_suffixes)
166            ):
167                setattr(namespace, var, env[var])
168
169        if self._prefix is not None and self._error_on_unrecognized:
170            for var in env:
171                if (
172                    var.startswith(self._prefix)
173                    and var not in self._variables
174                    and not var.endswith(allowed_suffixes)
175                ):
176                    raise ValueError(f'Unrecognized environment variable {var}')
177
178        return namespace
179
180    def __repr__(self) -> str:
181        return f'{type(self).__name__}(prefix={self._prefix})'
182
183
184# List of emoji which are considered to represent "True".
185_BOOLEAN_TRUE_EMOJI = set(
186    [
187        '✔️',
188        '��',
189        '����',
190        '����',
191        '����',
192        '����',
193        '����',
194        '��',
195    ]
196)
197
198
199def strict_bool(value: str) -> bool:
200    return (
201        value == '1' or value.lower() == 'true' or value in _BOOLEAN_TRUE_EMOJI
202    )
203
204
205# TODO(mohrr) Switch to Literal when no longer supporting Python 3.7.
206# OpenMode = Literal['r', 'rb', 'w', 'wb']
207OpenMode = str
208
209
210class FileType:
211    def __init__(self, mode: OpenMode) -> None:
212        self._mode: OpenMode = mode
213
214    def __call__(self, value: str) -> IO:
215        return open(value, self._mode)
216