main.py 12 KB

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