Format: text/x-python
⬇ Download File (12.1 KB)
   1  import io
   2  import logging
   3  import os
   4  import pathlib
   5  import shutil
   6  import sys
   7  import tempfile
   8  from collections import OrderedDict
   9  from contextlib import contextmanager
  10  from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union
  11  
  12  from .parser import Binding, parse_stream
  13  from .variables import parse_variables
  14  
  15  # A type alias for a string path to be used for the paths in this file.
  16  # These paths may flow to `open()` and `shutil.move()`; `shutil.move()`
  17  # only accepts string paths, not byte paths or file descriptors. See
  18  # https://github.com/python/typeshed/pull/6832.
  19  StrPath = Union[str, "os.PathLike[str]"]
  20  
  21  logger = logging.getLogger(__name__)
  22  
  23  
  24  def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]:
  25      for mapping in mappings:
  26          if mapping.error:
  27              logger.warning(
  28                  "python-dotenv could not parse statement starting at line %s",
  29                  mapping.original.line,
  30              )
  31          yield mapping
  32  
  33  
  34  class DotEnv:
  35      def __init__(
  36          self,
  37          dotenv_path: Optional[StrPath],
  38          stream: Optional[IO[str]] = None,
  39          verbose: bool = False,
  40          encoding: Optional[str] = None,
  41          interpolate: bool = True,
  42          override: bool = True,
  43      ) -> None:
  44          self.dotenv_path: Optional[StrPath] = dotenv_path
  45          self.stream: Optional[IO[str]] = stream
  46          self._dict: Optional[Dict[str, Optional[str]]] = None
  47          self.verbose: bool = verbose
  48          self.encoding: Optional[str] = encoding
  49          self.interpolate: bool = interpolate
  50          self.override: bool = override
  51  
  52      @contextmanager
  53      def _get_stream(self) -> Iterator[IO[str]]:
  54          if self.dotenv_path and os.path.isfile(self.dotenv_path):
  55              with open(self.dotenv_path, encoding=self.encoding) as stream:
  56                  yield stream
  57          elif self.stream is not None:
  58              yield self.stream
  59          else:
  60              if self.verbose:
  61                  logger.info(
  62                      "python-dotenv could not find configuration file %s.",
  63                      self.dotenv_path or ".env",
  64                  )
  65              yield io.StringIO("")
  66  
  67      def dict(self) -> Dict[str, Optional[str]]:
  68          """Return dotenv as dict"""
  69          if self._dict:
  70              return self._dict
  71  
  72          raw_values = self.parse()
  73  
  74          if self.interpolate:
  75              self._dict = OrderedDict(
  76                  resolve_variables(raw_values, override=self.override)
  77              )
  78          else:
  79              self._dict = OrderedDict(raw_values)
  80  
  81          return self._dict
  82  
  83      def parse(self) -> Iterator[Tuple[str, Optional[str]]]:
  84          with self._get_stream() as stream:
  85              for mapping in with_warn_for_invalid_lines(parse_stream(stream)):
  86                  if mapping.key is not None:
  87                      yield mapping.key, mapping.value
  88  
  89      def set_as_environment_variables(self) -> bool:
  90          """
  91          Load the current dotenv as system environment variable.
  92          """
  93          if not self.dict():
  94              return False
  95  
  96          for k, v in self.dict().items():
  97              if k in os.environ and not self.override:
  98                  continue
  99              if v is not None:
 100                  os.environ[k] = v
 101  
 102          return True
 103  
 104      def get(self, key: str) -> Optional[str]:
 105          """ """
 106          data = self.dict()
 107  
 108          if key in data:
 109              return data[key]
 110  
 111          if self.verbose:
 112              logger.warning("Key %s not found in %s.", key, self.dotenv_path)
 113  
 114          return None
 115  
 116  
 117  def get_key(
 118      dotenv_path: StrPath,
 119      key_to_get: str,
 120      encoding: Optional[str] = "utf-8",
 121  ) -> Optional[str]:
 122      """
 123      Get the value of a given key from the given .env.
 124  
 125      Returns `None` if the key isn't found or doesn't have a value.
 126      """
 127      return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get)
 128  
 129  
 130  @contextmanager
 131  def rewrite(
 132      path: StrPath,
 133      encoding: Optional[str],
 134  ) -> Iterator[Tuple[IO[str], IO[str]]]:
 135      pathlib.Path(path).touch()
 136  
 137      with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest:
 138          error = None
 139          try:
 140              with open(path, encoding=encoding) as source:
 141                  yield (source, dest)
 142          except BaseException as err:
 143              error = err
 144  
 145      if error is None:
 146          shutil.move(dest.name, path)
 147      else:
 148          os.unlink(dest.name)
 149          raise error from None
 150  
 151  
 152  def set_key(
 153      dotenv_path: StrPath,
 154      key_to_set: str,
 155      value_to_set: str,
 156      quote_mode: str = "always",
 157      export: bool = False,
 158      encoding: Optional[str] = "utf-8",
 159  ) -> Tuple[Optional[bool], str, str]:
 160      """
 161      Adds or Updates a key/value to the given .env
 162  
 163      If the .env path given doesn't exist, fails instead of risking creating
 164      an orphan .env somewhere in the filesystem
 165      """
 166      if quote_mode not in ("always", "auto", "never"):
 167          raise ValueError(f"Unknown quote_mode: {quote_mode}")
 168  
 169      quote = quote_mode == "always" or (
 170          quote_mode == "auto" and not value_to_set.isalnum()
 171      )
 172  
 173      if quote:
 174          value_out = "'{}'".format(value_to_set.replace("'", "\\'"))
 175      else:
 176          value_out = value_to_set
 177      if export:
 178          line_out = f"export {key_to_set}={value_out}\n"
 179      else:
 180          line_out = f"{key_to_set}={value_out}\n"
 181  
 182      with rewrite(dotenv_path, encoding=encoding) as (source, dest):
 183          replaced = False
 184          missing_newline = False
 185          for mapping in with_warn_for_invalid_lines(parse_stream(source)):
 186              if mapping.key == key_to_set:
 187                  dest.write(line_out)
 188                  replaced = True
 189              else:
 190                  dest.write(mapping.original.string)
 191                  missing_newline = not mapping.original.string.endswith("\n")
 192          if not replaced:
 193              if missing_newline:
 194                  dest.write("\n")
 195              dest.write(line_out)
 196  
 197      return True, key_to_set, value_to_set
 198  
 199  
 200  def unset_key(
 201      dotenv_path: StrPath,
 202      key_to_unset: str,
 203      quote_mode: str = "always",
 204      encoding: Optional[str] = "utf-8",
 205  ) -> Tuple[Optional[bool], str]:
 206      """
 207      Removes a given key from the given `.env` file.
 208  
 209      If the .env path given doesn't exist, fails.
 210      If the given key doesn't exist in the .env, fails.
 211      """
 212      if not os.path.exists(dotenv_path):
 213          logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path)
 214          return None, key_to_unset
 215  
 216      removed = False
 217      with rewrite(dotenv_path, encoding=encoding) as (source, dest):
 218          for mapping in with_warn_for_invalid_lines(parse_stream(source)):
 219              if mapping.key == key_to_unset:
 220                  removed = True
 221              else:
 222                  dest.write(mapping.original.string)
 223  
 224      if not removed:
 225          logger.warning(
 226              "Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path
 227          )
 228          return None, key_to_unset
 229  
 230      return removed, key_to_unset
 231  
 232  
 233  def resolve_variables(
 234      values: Iterable[Tuple[str, Optional[str]]],
 235      override: bool,
 236  ) -> Mapping[str, Optional[str]]:
 237      new_values: Dict[str, Optional[str]] = {}
 238  
 239      for name, value in values:
 240          if value is None:
 241              result = None
 242          else:
 243              atoms = parse_variables(value)
 244              env: Dict[str, Optional[str]] = {}
 245              if override:
 246                  env.update(os.environ)  # type: ignore
 247                  env.update(new_values)
 248              else:
 249                  env.update(new_values)
 250                  env.update(os.environ)  # type: ignore
 251              result = "".join(atom.resolve(env) for atom in atoms)
 252  
 253          new_values[name] = result
 254  
 255      return new_values
 256  
 257  
 258  def _walk_to_root(path: str) -> Iterator[str]:
 259      """
 260      Yield directories starting from the given directory up to the root
 261      """
 262      if not os.path.exists(path):
 263          raise IOError("Starting path not found")
 264  
 265      if os.path.isfile(path):
 266          path = os.path.dirname(path)
 267  
 268      last_dir = None
 269      current_dir = os.path.abspath(path)
 270      while last_dir != current_dir:
 271          yield current_dir
 272          parent_dir = os.path.abspath(os.path.join(current_dir, os.path.pardir))
 273          last_dir, current_dir = current_dir, parent_dir
 274  
 275  
 276  def find_dotenv(
 277      filename: str = ".env",
 278      raise_error_if_not_found: bool = False,
 279      usecwd: bool = False,
 280  ) -> str:
 281      """
 282      Search in increasingly higher folders for the given file
 283  
 284      Returns path to the file if found, or an empty string otherwise
 285      """
 286  
 287      def _is_interactive():
 288          """Decide whether this is running in a REPL or IPython notebook"""
 289          try:
 290              main = __import__("__main__", None, None, fromlist=["__file__"])
 291          except ModuleNotFoundError:
 292              return False
 293          return not hasattr(main, "__file__")
 294  
 295      def _is_debugger():
 296          return sys.gettrace() is not None
 297  
 298      if usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False):
 299          # Should work without __file__, e.g. in REPL or IPython notebook.
 300          path = os.getcwd()
 301      else:
 302          # will work for .py files
 303          frame = sys._getframe()
 304          current_file = __file__
 305  
 306          while frame.f_code.co_filename == current_file or not os.path.exists(
 307              frame.f_code.co_filename
 308          ):
 309              assert frame.f_back is not None
 310              frame = frame.f_back
 311          frame_filename = frame.f_code.co_filename
 312          path = os.path.dirname(os.path.abspath(frame_filename))
 313  
 314      for dirname in _walk_to_root(path):
 315          check_path = os.path.join(dirname, filename)
 316          if os.path.isfile(check_path):
 317              return check_path
 318  
 319      if raise_error_if_not_found:
 320          raise IOError("File not found")
 321  
 322      return ""
 323  
 324  
 325  def load_dotenv(
 326      dotenv_path: Optional[StrPath] = None,
 327      stream: Optional[IO[str]] = None,
 328      verbose: bool = False,
 329      override: bool = False,
 330      interpolate: bool = True,
 331      encoding: Optional[str] = "utf-8",
 332  ) -> bool:
 333      """Parse a .env file and then load all the variables found as environment variables.
 334  
 335      Parameters:
 336          dotenv_path: Absolute or relative path to .env file.
 337          stream: Text stream (such as `io.StringIO`) with .env content, used if
 338              `dotenv_path` is `None`.
 339          verbose: Whether to output a warning the .env file is missing.
 340          override: Whether to override the system environment variables with the variables
 341              from the `.env` file.
 342          encoding: Encoding to be used to read the file.
 343      Returns:
 344          Bool: True if at least one environment variable is set else False
 345  
 346      If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the
 347      .env file with it's default parameters. If you need to change the default parameters
 348      of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result
 349      to this function as `dotenv_path`.
 350      """
 351      if dotenv_path is None and stream is None:
 352          dotenv_path = find_dotenv()
 353  
 354      dotenv = DotEnv(
 355          dotenv_path=dotenv_path,
 356          stream=stream,
 357          verbose=verbose,
 358          interpolate=interpolate,
 359          override=override,
 360          encoding=encoding,
 361      )
 362      return dotenv.set_as_environment_variables()
 363  
 364  
 365  def dotenv_values(
 366      dotenv_path: Optional[StrPath] = None,
 367      stream: Optional[IO[str]] = None,
 368      verbose: bool = False,
 369      interpolate: bool = True,
 370      encoding: Optional[str] = "utf-8",
 371  ) -> Dict[str, Optional[str]]:
 372      """
 373      Parse a .env file and return its content as a dict.
 374  
 375      The returned dict will have `None` values for keys without values in the .env file.
 376      For example, `foo=bar` results in `{"foo": "bar"}` whereas `foo` alone results in
 377      `{"foo": None}`
 378  
 379      Parameters:
 380          dotenv_path: Absolute or relative path to the .env file.
 381          stream: `StringIO` object with .env content, used if `dotenv_path` is `None`.
 382          verbose: Whether to output a warning if the .env file is missing.
 383          encoding: Encoding to be used to read the file.
 384  
 385      If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the
 386      .env file.
 387      """
 388      if dotenv_path is None and stream is None:
 389          dotenv_path = find_dotenv()
 390  
 391      return DotEnv(
 392          dotenv_path=dotenv_path,
 393          stream=stream,
 394          verbose=verbose,
 395          interpolate=interpolate,
 396          override=True,
 397          encoding=encoding,
 398      ).dict()
 399