From 335dedc7d94bd69236c496ebb09e2de2b2da2dd7 Mon Sep 17 00:00:00 2001 From: Ewoud Kohl van Wijngaarden Date: Wed, 29 Apr 2020 12:22:46 +0200 Subject: [PATCH 01/49] Use https in setup.py's URL metadata (#251) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index db69a9ce..f5c2a507 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def read_files(files): version=meta['__version__'], author="Saurabh Kumar", author_email="me+github@saurabh-kumar.com", - url="http://github.com/theskumar/python-dotenv", + url="https://github.com/theskumar/python-dotenv", keywords=['environment variables', 'deployments', 'settings', 'env', 'dotenv', 'configurations', 'python'], packages=['dotenv'], From e92ab1f18087c2ca058203f36535c43a0f824d8a Mon Sep 17 00:00:00 2001 From: Adrian Calinescu Date: Wed, 29 Apr 2020 22:00:57 +0300 Subject: [PATCH 02/49] Fix file not found message to have some context (#245) 'File doesn't exist' doesn't really tell the user much, let's add some context. --- src/dotenv/main.py | 2 +- tests/test_main.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 7fbd24f8..c821ef73 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -74,7 +74,7 @@ def _get_stream(self): yield stream else: if self.verbose: - logger.warning("File doesn't exist %s", self.dotenv_path) + logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') yield StringIO('') def dict(self): diff --git a/tests/test_main.py b/tests/test_main.py index f877d21a..04d86509 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -74,14 +74,19 @@ def test_get_key_no_file(tmp_path): nx_file = str(tmp_path / "nx") logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "warning") as mock_warning: + with mock.patch.object(logger, "info") as mock_info, \ + mock.patch.object(logger, "warning") as mock_warning: result = dotenv.get_key(nx_file, "foo") assert result is None + mock_info.assert_has_calls( + calls=[ + mock.call("Python-dotenv could not find configuration file %s.", nx_file) + ], + ) mock_warning.assert_has_calls( calls=[ - mock.call("File doesn't exist %s", nx_file), - mock.call("Key %s not found in %s.", "foo", nx_file), + mock.call("Key %s not found in %s.", "foo", nx_file) ], ) @@ -228,10 +233,10 @@ def test_load_dotenv_existing_file(dotenv_file): def test_load_dotenv_no_file_verbose(): logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "warning") as mock_warning: + with mock.patch.object(logger, "info") as mock_info: dotenv.load_dotenv('.does_not_exist', verbose=True) - mock_warning.assert_called_once_with("File doesn't exist %s", ".does_not_exist") + mock_info.assert_called_once_with("Python-dotenv could not find configuration file %s.", ".does_not_exist") @mock.patch.dict(os.environ, {"a": "c"}, clear=True) From d83c1b65ceab5cf1223c41649a25a142755fe703 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 9 May 2020 12:56:13 +0200 Subject: [PATCH 03/49] Refine Python version constraint in readme example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7374d05c..98aac03b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ load_dotenv() load_dotenv(verbose=True) # OR, explicitly providing path to '.env' -from pathlib import Path # python3 only +from pathlib import Path # Python 3.6+ only env_path = Path('.') / '.env' load_dotenv(dotenv_path=env_path) ``` From 6712bc8a12db1e8d3f09b7bf7d3c25338992e422 Mon Sep 17 00:00:00 2001 From: Abdelrahman Elbehery Date: Sat, 27 Jun 2020 20:31:32 +0200 Subject: [PATCH 04/49] fix interpolation order add interpolation order test --- CHANGELOG.md | 6 +++++- src/dotenv/main.py | 7 +------ tests/test_main.py | 3 +++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2f7c9b..1c0eceb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -*No unreleased change at this time.* +### Fixed + +- Privilege definition in file over the environment in variable expansion (#256 by + [@elbehery95]). ## [0.13.0] - 2020-04-16 @@ -197,6 +200,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur [@yannham]: https://github.com/yannham +[@elbehery95]: https://github.com/elbehery95 [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...HEAD [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index c821ef73..8f77e831 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -214,13 +214,8 @@ def resolve_nested_variables(values): # type: (Dict[Text, Optional[Text]]) -> Dict[Text, Optional[Text]] def _replacement(name, default): # type: (Text, Optional[Text]) -> Text - """ - get appropriate value for a variable name. - first search in environ, if not found, - then look into the dotenv variables - """ default = default if default is not None else "" - ret = os.getenv(name, new_values.get(name, default)) + ret = new_values.get(name, os.getenv(name, default)) return ret # type: ignore def _re_sub_callback(match): diff --git a/tests/test_main.py b/tests/test_main.py index 04d86509..3a3d059b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -334,6 +334,9 @@ def test_dotenv_values_file(dotenv_file): # Reused ({"b": "c"}, "a=${b}${b}", True, {"a": "cc"}), + + # Re-defined and used in file + ({"b": "c"}, "b=d\na=${b}", True, {"a": "d", "b": "d"}), ], ) def test_dotenv_values_stream(env, string, interpolate, expected): From e4bbb8a2aa881409af6fb92933c18e2af6609da8 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 3 Jul 2020 11:09:24 +0200 Subject: [PATCH 05/49] Release v0.14.0 --- CHANGELOG.md | 18 +++++++++++++++--- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c0eceb2..116d97fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,20 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Fixed +*No unreleased change at this time.* + +## [0.14.0] - 2020-07-03 + +### Changed - Privilege definition in file over the environment in variable expansion (#256 by [@elbehery95]). +### Fixed + +- Improve error message for when file isn't found (#245 by [@snobu]). +- Use HTTPS URL in package meta data (#251 by [@ekohl]). + ## [0.13.0] - 2020-04-16 ### Added @@ -192,17 +201,20 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@bbc2]: https://github.com/bbc2 [@cjauvin]: https://github.com/cjauvin [@earlbread]: https://github.com/earlbread +[@ekohl]: https://github.com/ekohl +[@elbehery95]: https://github.com/elbehery95 [@gergelyk]: https://github.com/gergelyk [@greyli]: https://github.com/greyli [@qnighy]: https://github.com/qnighy +[@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur [@yannham]: https://github.com/yannham -[@elbehery95]: https://github.com/elbehery95 -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...HEAD +[0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0 diff --git a/setup.cfg b/setup.cfg index c19d6bb7..0f168618 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.13.0 +current_version = 0.14.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index f23a6b39..9e78220f 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.13.0" +__version__ = "0.14.0" From 4b434362e1832771fd08c14b8af67a8fd562b854 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 3 Jul 2020 18:06:39 +0200 Subject: [PATCH 06/49] Fix empty expanded value for duplicate key Example problematic file: ```bash hello=hi greetings=${hello} goodbye=bye greetings=${goodbye} ``` It would result in `greetings` being associated with the empty string instead of `"bye"`. The problem came from the fact that bindings were converted to a dict, and so deduplicated by key, before being interpolated. The dict would be `{"hello": "hi", "greetings": "${goodbye}", "goodbye": "bye"}` in the earlier example, which shows why interpolation wouldn't work: `goodbye` would not be defined when `greetings` was interpolated. This commit fixes that by passing all values in order, even if there are duplicated keys. --- CHANGELOG.md | 4 +++- src/dotenv/main.py | 16 ++++++++++------ tests/test_main.py | 2 ++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 116d97fa..a01d3dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -*No unreleased change at this time.* +### Fixed + +- Fix potentially empty expanded value for duplicate key (#260 by [@bbc]). ## [0.14.0] - 2020-07-03 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 8f77e831..607299ae 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -18,7 +18,7 @@ if IS_TYPE_CHECKING: from typing import ( - Dict, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple + Dict, Iterable, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple ) if sys.version_info >= (3, 6): _PathLike = os.PathLike @@ -83,9 +83,13 @@ def dict(self): if self._dict: return self._dict - values = OrderedDict(self.parse()) - self._dict = resolve_nested_variables(values) if self.interpolate else values - return self._dict + if self.interpolate: + values = resolve_nested_variables(self.parse()) + else: + values = OrderedDict(self.parse()) + + self._dict = values + return values def parse(self): # type: () -> Iterator[Tuple[Text, Optional[Text]]] @@ -211,7 +215,7 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): def resolve_nested_variables(values): - # type: (Dict[Text, Optional[Text]]) -> Dict[Text, Optional[Text]] + # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Dict[Text, Optional[Text]] def _replacement(name, default): # type: (Text, Optional[Text]) -> Text default = default if default is not None else "" @@ -229,7 +233,7 @@ def _re_sub_callback(match): new_values = {} - for k, v in values.items(): + for (k, v) in values: new_values[k] = __posix_variable.sub(_re_sub_callback, v) if v is not None else None return new_values diff --git a/tests/test_main.py b/tests/test_main.py index 3a3d059b..339d00bb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -337,6 +337,8 @@ def test_dotenv_values_file(dotenv_file): # Re-defined and used in file ({"b": "c"}, "b=d\na=${b}", True, {"a": "d", "b": "d"}), + ({}, "a=b\na=c\nd=${a}", True, {"a": "c", "d": "c"}), + ({}, "a=b\nc=${a}\nd=e\nc=${d}", True, {"a": "b", "c": "e", "d": "e"}), ], ) def test_dotenv_values_stream(env, string, interpolate, expected): From 78be0f865d34c34120456ff0b6b9d34a95ac6879 Mon Sep 17 00:00:00 2001 From: gongqingkui Date: Sat, 4 Jul 2020 22:15:35 +0800 Subject: [PATCH 07/49] Fix import error on Python 3.5.0 and 3.5.1 While `typing` was added in 3.5.0, `typing.Text` was only added in 3.5.2, and so causes an `AttributeError`, not an `ImportError` exception. --- CHANGELOG.md | 2 ++ src/dotenv/parser.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a01d3dc5..7d502f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Fix potentially empty expanded value for duplicate key (#260 by [@bbc]). +- Fix import error on Python 3.5.0 and 3.5.1 (#267 by [@gongqingkui]). ## [0.14.0] - 2020-07-03 @@ -206,6 +207,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 [@gergelyk]: https://github.com/gergelyk +[@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli [@qnighy]: https://github.com/qnighy [@snobu]: https://github.com/snobu diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 2c93cbd0..4eba0ac4 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -55,7 +55,7 @@ def make_regex(string, extra_flags=0): ("error", bool), ], ) -except ImportError: +except (ImportError, AttributeError): from collections import namedtuple Original = namedtuple( # type: ignore "Original", From 92ec3b280db6e9f946994a3bf5815d6b6447892c Mon Sep 17 00:00:00 2001 From: Leon Chen Date: Mon, 13 Jul 2020 14:11:27 -0400 Subject: [PATCH 08/49] Update README.md https://github.com/theskumar/python-dotenv/issues/256 https://github.com/theskumar/python-dotenv/pull/258 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 98aac03b..d3992847 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Python-dotenv can interpolate variables using POSIX variable expansion. The value of a variable is the first of the values defined in the following list: -- Value of that variable in the environment. - Value of that variable in the `.env` file. +- Value of that variable in the environment. - Default value, if provided. - Empty string. From 6ca2e2ab399c7e41be276bc830f21af3092f5d28 Mon Sep 17 00:00:00 2001 From: jadutter <4691511+jadutter@users.noreply.github.com> Date: Tue, 4 Aug 2020 20:43:52 -0400 Subject: [PATCH 09/49] Add --export to `set` and make it create env file - Add `--export` option to `set` to make it prepend the binding with `export`. - Make `set` command create the `.env` file in the current directory if no `.env` file was found. --- CHANGELOG.md | 11 +++++++++++ README.md | 11 +++++++++-- src/dotenv/cli.py | 30 +++++++++++++++++++++++++----- src/dotenv/main.py | 15 +++++++++------ tests/test_cli.py | 27 ++++++++++++++++++++++----- tests/test_main.py | 10 +++------- 6 files changed, 79 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d502f19..34cdb324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Add `--export` option to `set` to make it prepend the binding with `export` (#270 by + [@jadutter]). + +### Changed + +- Make `set` command create the `.env` file in the current directory if no `.env` file was + found (#270 by [@jadutter]). + ### Fixed - Fix potentially empty expanded value for duplicate key (#260 by [@bbc]). @@ -209,6 +219,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli +[@jadutter]: https://github.com/jadutter [@qnighy]: https://github.com/qnighy [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy diff --git a/README.md b/README.md index d3992847..462a11d3 100644 --- a/README.md +++ b/README.md @@ -186,16 +186,23 @@ Usage: dotenv [OPTIONS] COMMAND [ARGS]... Options: -f, --file PATH Location of the .env file, defaults to .env file in current working directory. + -q, --quote [always|never|auto] Whether to quote or not the variable values. Default mode is always. This does not affect parsing. + + -e, --export BOOLEAN + Whether to write the dot file as an + executable bash script. + + --version Show the version and exit. --help Show this message and exit. Commands: - get Retrive the value for the given key. + get Retrieve the value for the given key. list Display all the stored key/value. - run Run command with environment variables from .env file present + run Run command with environment variables present. set Store the given key/value. unset Removes the given key. ``` diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 91a8e3d3..e17d248f 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -19,19 +19,23 @@ @click.group() @click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), - type=click.Path(exists=True), + type=click.Path(file_okay=True), help="Location of the .env file, defaults to .env file in current working directory.") @click.option('-q', '--quote', default='always', type=click.Choice(['always', 'never', 'auto']), help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.") +@click.option('-e', '--export', default=False, + type=click.BOOL, + help="Whether to write the dot file as an executable bash script.") @click.version_option(version=__version__) @click.pass_context -def cli(ctx, file, quote): - # type: (click.Context, Any, Any) -> None +def cli(ctx, file, quote, export): + # type: (click.Context, Any, Any, Any) -> None '''This script is used to set, get or unset values from a .env file.''' ctx.obj = {} - ctx.obj['FILE'] = file ctx.obj['QUOTE'] = quote + ctx.obj['EXPORT'] = export + ctx.obj['FILE'] = file @cli.command() @@ -40,6 +44,11 @@ def list(ctx): # type: (click.Context) -> None '''Display all the stored key/value.''' file = ctx.obj['FILE'] + if not os.path.isfile(file): + raise click.BadParameter( + 'Path "%s" does not exist.' % (file), + ctx=ctx + ) dotenv_as_dict = dotenv_values(file) for k, v in dotenv_as_dict.items(): click.echo('%s=%s' % (k, v)) @@ -54,7 +63,8 @@ def set(ctx, key, value): '''Store the given key/value.''' file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] - success, key, value = set_key(file, key, value, quote) + export = ctx.obj['EXPORT'] + success, key, value = set_key(file, key, value, quote, export) if success: click.echo('%s=%s' % (key, value)) else: @@ -68,6 +78,11 @@ def get(ctx, key): # type: (click.Context, Any) -> None '''Retrieve the value for the given key.''' file = ctx.obj['FILE'] + if not os.path.isfile(file): + raise click.BadParameter( + 'Path "%s" does not exist.' % (file), + ctx=ctx + ) stored_value = get_key(file, key) if stored_value: click.echo('%s=%s' % (key, stored_value)) @@ -97,6 +112,11 @@ def run(ctx, commandline): # type: (click.Context, List[str]) -> None """Run command with environment variables present.""" file = ctx.obj['FILE'] + if not os.path.isfile(file): + raise click.BadParameter( + 'Invalid value for \'-f\' "%s" does not exist.' % (file), + ctx=ctx + ) dotenv_as_dict = {to_env(k): to_env(v) for (k, v) in dotenv_values(file).items() if v is not None} if not commandline: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 607299ae..58a23f3d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -140,6 +140,9 @@ def get_key(dotenv_path, key_to_get): def rewrite(path): # type: (_PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]] try: + if not os.path.isfile(path): + with io.open(path, "w+") as source: + source.write("") with tempfile.NamedTemporaryFile(mode="w+", delete=False) as dest: with io.open(path) as source: yield (source, dest) # type: ignore @@ -151,8 +154,8 @@ def rewrite(path): shutil.move(dest.name, path) -def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): - # type: (_PathLike, Text, Text, Text) -> Tuple[Optional[bool], Text, Text] +def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=False): + # type: (_PathLike, Text, Text, Text, bool) -> Tuple[Optional[bool], Text, Text] """ Adds or Updates a key/value to the given .env @@ -160,9 +163,6 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): an orphan .env somewhere in the filesystem """ value_to_set = value_to_set.strip("'").strip('"') - if not os.path.exists(dotenv_path): - logger.warning("Can't write to %s - it doesn't exist.", dotenv_path) - return None, key_to_set, value_to_set if " " in value_to_set: quote_mode = "always" @@ -171,7 +171,10 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): value_out = '"{}"'.format(value_to_set.replace('"', '\\"')) else: value_out = value_to_set - line_out = "{}={}\n".format(key_to_set, value_out) + if export: + line_out = 'export {}={}\n'.format(key_to_set, value_out) + else: + line_out = "{}={}\n".format(key_to_set, value_out) with rewrite(dotenv_path) as (source, dest): replaced = False diff --git a/tests/test_cli.py b/tests/test_cli.py index edc62fff..23404e70 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,7 +20,7 @@ def test_list_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) assert result.exit_code == 2, result.output - assert "Invalid value for '-f'" in result.output + assert "does not exist" in result.output def test_list_no_file(cli): @@ -48,7 +48,7 @@ def test_get_no_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + assert "does not exist" in result.output def test_unset_existing_value(cli, dotenv_file): @@ -77,10 +77,27 @@ def test_unset_non_existent_value(cli, dotenv_file): ("auto", "HELLO", "HELLO WORLD", 'HELLO="HELLO WORLD"\n'), ) ) -def test_set_options(cli, dotenv_file, quote_mode, variable, value, expected): +def test_set_quote_options(cli, dotenv_file, quote_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_file, "--quote", quote_mode, "set", variable, value] + ["--file", dotenv_file, "--export", "false", "--quote", quote_mode, "set", variable, value] + ) + + assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) + assert open(dotenv_file, "r").read() == expected + + +@pytest.mark.parametrize( + "dotenv_file,export_mode,variable,value,expected", + ( + (".nx_file", "true", "HELLO", "WORLD", "export HELLO=\"WORLD\"\n"), + (".nx_file", "false", "HELLO", "WORLD", "HELLO=\"WORLD\"\n"), + ) +) +def test_set_export(cli, dotenv_file, export_mode, variable, value, expected): + result = cli.invoke( + dotenv_cli, + ["--file", dotenv_file, "--quote", "always", "--export", export_mode, "set", variable, value] ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) @@ -97,7 +114,7 @@ def test_set_no_file(cli): result = cli.invoke(dotenv_cli, ["--file", "nx_file", "set"]) assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + assert "Missing argument" in result.output def test_get_default_path(tmp_path): diff --git a/tests/test_main.py b/tests/test_main.py index 339d00bb..6b9458d2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -18,15 +18,11 @@ def test_set_key_no_file(tmp_path): nx_file = str(tmp_path / "nx") logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "warning") as mock_warning: + with mock.patch.object(logger, "warning"): result = dotenv.set_key(nx_file, "foo", "bar") - assert result == (None, "foo", "bar") - assert not os.path.exists(nx_file) - mock_warning.assert_called_once_with( - "Can't write to %s - it doesn't exist.", - nx_file, - ) + assert result == (True, "foo", "bar") + assert os.path.exists(nx_file) @pytest.mark.parametrize( From 7b172fe24830bb8fd8cd943b73570e8cd6515ba1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 9 Sep 2020 11:00:13 +0200 Subject: [PATCH 10/49] Fix parsing of unquoted values with two spaces If a value is unquoted and has two or more adjacent spaces (like in `a=b c`), the parser would detect an error. This commit fixes that. Tabs and other whitespace characters are now also considered like space characters in this case and I added relevant test cases. --- CHANGELOG.md | 5 ++++- src/dotenv/parser.py | 12 +++--------- tests/test_parser.py | 36 ++++++++++++++++++++++++++++-------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cdb324..b5305f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- Fix potentially empty expanded value for duplicate key (#260 by [@bbc]). +- Fix potentially empty expanded value for duplicate key (#260 by [@bbc2]). - Fix import error on Python 3.5.0 and 3.5.1 (#267 by [@gongqingkui]). +- Fix parsing of unquoted values containing several adjacent space or tab characters + (#277 by [@bbc2], review by [@x-yuri]). ## [0.14.0] - 2020-07-03 @@ -226,6 +228,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur +[@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...HEAD diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 4eba0ac4..5cb1cdfa 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -24,7 +24,7 @@ def make_regex(string, extra_flags=0): _equal_sign = make_regex(r"(=[^\S\r\n]*)") _single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'") _double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"') -_unquoted_value_part = make_regex(r"([^ \r\n]*)") +_unquoted_value = make_regex(r"([^\r\n]*)") _comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?") _end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r|$)") _rest_of_line = make_regex(r"[^\r\n]*(?:\r|\n|\r\n)?") @@ -167,14 +167,8 @@ def parse_key(reader): def parse_unquoted_value(reader): # type: (Reader) -> Text - value = u"" - while True: - (part,) = reader.read_regex(_unquoted_value_part) - value += part - after = reader.peek(2) - if len(after) < 2 or after[0] in u"\r\n" or after[1] in u" #\r\n": - return value - value += reader.read(2) + (part,) = reader.read_regex(_unquoted_value) + return re.sub(r"\s+#.*", "", part).rstrip() def parse_value(reader): diff --git a/tests/test_parser.py b/tests/test_parser.py index f8075138..48cecdce 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -19,20 +19,40 @@ (u"# a=b", [Binding(key=None, value=None, original=Original(string=u"# a=b", line=1), error=False)]), (u"a=b#c", [Binding(key=u"a", value=u"b#c", original=Original(string=u"a=b#c", line=1), error=False)]), ( - u'a=b # comment', - [Binding(key=u"a", value=u"b", original=Original(string=u"a=b # comment", line=1), error=False)], + u'a=b #c', + [Binding(key=u"a", value=u"b", original=Original(string=u"a=b #c", line=1), error=False)], ), ( - u"a=b space ", - [Binding(key=u"a", value=u"b space", original=Original(string=u"a=b space ", line=1), error=False)], + u'a=b\t#c', + [Binding(key=u"a", value=u"b", original=Original(string=u"a=b\t#c", line=1), error=False)], ), ( - u"a='b space '", - [Binding(key=u"a", value=u"b space ", original=Original(string=u"a='b space '", line=1), error=False)], + u"a=b c", + [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c", line=1), error=False)], ), ( - u'a="b space "', - [Binding(key=u"a", value=u"b space ", original=Original(string=u'a="b space "', line=1), error=False)], + u"a=b\tc", + [Binding(key=u"a", value=u"b\tc", original=Original(string=u"a=b\tc", line=1), error=False)], + ), + ( + u"a=b c", + [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c", line=1), error=False)], + ), + ( + u"a=b\u00a0 c", + [Binding(key=u"a", value=u"b\u00a0 c", original=Original(string=u"a=b\u00a0 c", line=1), error=False)], + ), + ( + u"a=b c ", + [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c ", line=1), error=False)], + ), + ( + u"a='b c '", + [Binding(key=u"a", value=u"b c ", original=Original(string=u"a='b c '", line=1), error=False)], + ), + ( + u'a="b c "', + [Binding(key=u"a", value=u"b c ", original=Original(string=u'a="b c "', line=1), error=False)], ), ( u"export export_a=1", From 6fc345833f148762bb8783896d1b1059b146886f Mon Sep 17 00:00:00 2001 From: Peyman Salehi Date: Fri, 16 Oct 2020 00:57:43 +0330 Subject: [PATCH 11/49] Add python 3.9 to CI --- .travis.yml | 2 ++ setup.py | 1 + tox.ini | 5 +++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 483f6d40..8f51de38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,8 @@ jobs: env: TOXENV=py37 - python: "3.8" env: TOXENV=py38 + - python: "3.9-dev" + env: TOXENV=py39 - python: "pypy" env: TOXENV=pypy - python: "pypy3" diff --git a/setup.py b/setup.py index f5c2a507..530ab129 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ def read_files(files): 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', diff --git a/tox.ini b/tox.ini index 2dd61864..0025c946 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{27,34,35,36,37,38,34-no-typing},pypy,pypy3,manifest,coverage-report +envlist = lint,py{27,34,35,36,37,38,39,34-no-typing},pypy,pypy3,manifest,coverage-report [testenv] deps = @@ -10,7 +10,7 @@ deps = click py{27,py}: ipython<6.0.0 py34{,-no-typing}: ipython<7.0.0 - py{35,36,37,38,py3}: ipython + py{35,36,37,38,39,py3}: ipython commands = coverage run --parallel -m pytest {posargs} [testenv:py34-no-typing] @@ -25,6 +25,7 @@ deps = mypy commands = flake8 src tests + mypy --python-version=3.9 src tests mypy --python-version=3.8 src tests mypy --python-version=3.7 src tests mypy --python-version=3.6 src tests From fa354ce5d4c4f515e340265bf7c0f7e32fb0e75d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 18 Oct 2020 16:49:26 +0530 Subject: [PATCH 12/49] Migrate from travis-ci.org to .com --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 462a11d3..5c9aeaf9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ __ | |____ | |\ | \ / (__)|_______||__| \__| \__/ ``` -python-dotenv | [![Build Status](https://travis-ci.org/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.org/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) +python-dotenv | [![Build Status](https://travis-ci.com/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.com/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) =============================================================================== Reads the key-value pair from `.env` file and adds them to environment @@ -48,7 +48,7 @@ The value of a variable is the first of the values defined in the following list - Default value, if provided. - Empty string. -Ensure that variables are surrounded with `{}` like `${HOME}` as bare +Ensure that variables are surrounded with `{}` like `${HOME}` as bare variables such as `$HOME` are not expanded. ```shell From e13d957bf48224453c5d9d9a7a83a13b999e0196 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 28 Oct 2020 17:57:28 +0100 Subject: [PATCH 13/49] Release v0.15.0 --- CHANGELOG.md | 7 ++++++- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5305f19..56a7a94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +_There are no unreleased changes at this time._ + +## [0.15.0] - 2020-10-28 + ### Added - Add `--export` option to `set` to make it prepend the binding with `export` (#270 by @@ -231,7 +235,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...HEAD +[0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...v0.12.0 diff --git a/setup.cfg b/setup.cfg index 0f168618..9d69a202 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.14.0 +current_version = 0.15.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 9e78220f..9da2f8fc 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.14.0" +__version__ = "0.15.0" From 8815885ee1b8ce9a33c3054df5bd7032bf297d33 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 15 Nov 2020 15:26:26 +0100 Subject: [PATCH 14/49] Fix pypy environment in Travis CI --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 8f51de38..8ccd2405 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,6 +33,7 @@ script: - tox before_install: + - pip install --upgrade pip - pip install coveralls after_success: From 17dba65244c1d4d10f591fe37c924bd2c6fd1cfc Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 15 Nov 2020 13:06:25 +0100 Subject: [PATCH 15/49] Decouple variable parsing and expansion This is now done in two steps: - Parse the value into a sequence of atoms (literal of variable). - Resolve that sequence into a string. --- src/dotenv/main.py | 59 ++++++++-------------- src/dotenv/variables.py | 106 ++++++++++++++++++++++++++++++++++++++++ tests/test_variables.py | 35 +++++++++++++ 3 files changed, 162 insertions(+), 38 deletions(-) create mode 100644 src/dotenv/variables.py create mode 100644 tests/test_variables.py diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 58a23f3d..ea523d48 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -4,7 +4,6 @@ import io import logging import os -import re import shutil import sys import tempfile @@ -13,13 +12,13 @@ from .compat import IS_TYPE_CHECKING, PY2, StringIO, to_env from .parser import Binding, parse_stream +from .variables import parse_variables logger = logging.getLogger(__name__) if IS_TYPE_CHECKING: - from typing import ( - Dict, Iterable, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple - ) + from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, + Tuple, Union) if sys.version_info >= (3, 6): _PathLike = os.PathLike else: @@ -30,18 +29,6 @@ else: _StringIO = StringIO[Text] -__posix_variable = re.compile( - r""" - \$\{ - (?P[^\}:]*) - (?::- - (?P[^\}]*) - )? - \} - """, - re.VERBOSE, -) # type: Pattern[Text] - def with_warn_for_invalid_lines(mappings): # type: (Iterator[Binding]) -> Iterator[Binding] @@ -83,13 +70,14 @@ def dict(self): if self._dict: return self._dict + raw_values = self.parse() + if self.interpolate: - values = resolve_nested_variables(self.parse()) + self._dict = OrderedDict(resolve_variables(raw_values)) else: - values = OrderedDict(self.parse()) + self._dict = OrderedDict(raw_values) - self._dict = values - return values + return self._dict def parse(self): # type: () -> Iterator[Tuple[Text, Optional[Text]]] @@ -217,27 +205,22 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): return removed, key_to_unset -def resolve_nested_variables(values): - # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Dict[Text, Optional[Text]] - def _replacement(name, default): - # type: (Text, Optional[Text]) -> Text - default = default if default is not None else "" - ret = new_values.get(name, os.getenv(name, default)) - return ret # type: ignore +def resolve_variables(values): + # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Mapping[Text, Optional[Text]] - def _re_sub_callback(match): - # type: (Match[Text]) -> Text - """ - From a match object gets the variable name and returns - the correct replacement - """ - matches = match.groupdict() - return _replacement(name=matches["name"], default=matches["default"]) # type: ignore + new_values = {} # type: Dict[Text, Optional[Text]] - new_values = {} + for (name, value) in values: + if value is None: + result = None + else: + atoms = parse_variables(value) + env = {} # type: Dict[Text, Optional[Text]] + env.update(os.environ) # type: ignore + env.update(new_values) + result = "".join(atom.resolve(env) for atom in atoms) - for (k, v) in values: - new_values[k] = __posix_variable.sub(_re_sub_callback, v) if v is not None else None + new_values[name] = result return new_values diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py new file mode 100644 index 00000000..4828dfc2 --- /dev/null +++ b/src/dotenv/variables.py @@ -0,0 +1,106 @@ +import re +from abc import ABCMeta + +from .compat import IS_TYPE_CHECKING + +if IS_TYPE_CHECKING: + from typing import Iterator, Mapping, Optional, Pattern, Text + + +_posix_variable = re.compile( + r""" + \$\{ + (?P[^\}:]*) + (?::- + (?P[^\}]*) + )? + \} + """, + re.VERBOSE, +) # type: Pattern[Text] + + +class Atom(): + __metaclass__ = ABCMeta + + def __ne__(self, other): + # type: (object) -> bool + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + return not result + + def resolve(self, env): + # type: (Mapping[Text, Optional[Text]]) -> Text + raise NotImplementedError + + +class Literal(Atom): + def __init__(self, value): + # type: (Text) -> None + self.value = value + + def __repr__(self): + # type: () -> str + return "Literal(value={})".format(self.value) + + def __eq__(self, other): + # type: (object) -> bool + if not isinstance(other, self.__class__): + return NotImplemented + return self.value == other.value + + def __hash__(self): + # type: () -> int + return hash((self.__class__, self.value)) + + def resolve(self, env): + # type: (Mapping[Text, Optional[Text]]) -> Text + return self.value + + +class Variable(Atom): + def __init__(self, name, default): + # type: (Text, Optional[Text]) -> None + self.name = name + self.default = default + + def __repr__(self): + # type: () -> str + return "Variable(name={}, default={})".format(self.name, self.default) + + def __eq__(self, other): + # type: (object) -> bool + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name, self.default) == (other.name, other.default) + + def __hash__(self): + # type: () -> int + return hash((self.__class__, self.name, self.default)) + + def resolve(self, env): + # type: (Mapping[Text, Optional[Text]]) -> Text + default = self.default if self.default is not None else "" + result = env.get(self.name, default) + return result if result is not None else "" + + +def parse_variables(value): + # type: (Text) -> Iterator[Atom] + cursor = 0 + + for match in _posix_variable.finditer(value): + (start, end) = match.span() + name = match.groupdict()["name"] + default = match.groupdict()["default"] + + if start > cursor: + yield Literal(value=value[cursor:start]) + + yield Variable(name=name, default=default) + cursor = end + + length = len(value) + if cursor < length: + yield Literal(value=value[cursor:length]) diff --git a/tests/test_variables.py b/tests/test_variables.py new file mode 100644 index 00000000..86b06466 --- /dev/null +++ b/tests/test_variables.py @@ -0,0 +1,35 @@ +import pytest + +from dotenv.variables import Literal, Variable, parse_variables + + +@pytest.mark.parametrize( + "value,expected", + [ + ("", []), + ("a", [Literal(value="a")]), + ("${a}", [Variable(name="a", default=None)]), + ("${a:-b}", [Variable(name="a", default="b")]), + ( + "${a}${b}", + [ + Variable(name="a", default=None), + Variable(name="b", default=None), + ], + ), + ( + "a${b}c${d}e", + [ + Literal(value="a"), + Variable(name="b", default=None), + Literal(value="c"), + Variable(name="d", default=None), + Literal(value="e"), + ], + ), + ] +) +def test_parse_variables(value, expected): + result = parse_variables(value) + + assert list(result) == expected From 26ff5b74c676dbed391eb535ad2a591ecf98d3c6 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 15 Nov 2020 12:25:27 +0100 Subject: [PATCH 16/49] Fix variable expansion order without override This fixes an issue when a variable is resolved differently in two bindings. For instance, take the following env file: ``` PORT=8000 URL=http://localhost:${PORT} ``` With `PORT` set to `1234` in the environment, the environment resulting from `dotenv_load(override=False)` would be: ``` PORT=1234 URL=http://localhost:8000 ``` This was inconsistent and is fixed by this commit. The environment would now be: ``` PORT=1234 URL=http://localhost:1234 ``` with override, and ``` PORT=8000 URL=http://localhost:8000 ``` without override. The behavior of `load_dotenv` is unchanged and always assumes `override=True`. --- CHANGELOG.md | 2 +- README.md | 11 ++++++++++- src/dotenv/main.py | 30 ++++++++++++++++++------------ tests/test_main.py | 22 ++++++++++++++++++++++ 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a7a94c..effa2510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -_There are no unreleased changes at this time._ +- Fix resolution order in variable expansion with `override=False` (#? by [@bbc2]). ## [0.15.0] - 2020-10-28 diff --git a/README.md b/README.md index 5c9aeaf9..36f3b2b0 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,22 @@ export SECRET_KEY=YOURSECRETKEYGOESHERE Python-dotenv can interpolate variables using POSIX variable expansion. -The value of a variable is the first of the values defined in the following list: +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the +first of the values defined in the following list: - Value of that variable in the `.env` file. - Value of that variable in the environment. - Default value, if provided. - Empty string. +With `load_dotenv(override=False)`, the value of a variable is the first of the values +defined in the following list: + +- Value of that variable in the environment. +- Value of that variable in the `.env` file. +- Default value, if provided. +- Empty string. + Ensure that variables are surrounded with `{}` like `${HOME}` as bare variables such as `$HOME` are not expanded. diff --git a/src/dotenv/main.py b/src/dotenv/main.py index ea523d48..b366b18e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -43,13 +43,14 @@ def with_warn_for_invalid_lines(mappings): class DotEnv(): - def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True): - # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text], bool) -> None + def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, override=True): + # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text], bool, bool) -> None self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, _StringIO] self._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, Text] self.interpolate = interpolate # type: bool + self.override = override # type: bool @contextmanager def _get_stream(self): @@ -73,7 +74,7 @@ def dict(self): raw_values = self.parse() if self.interpolate: - self._dict = OrderedDict(resolve_variables(raw_values)) + self._dict = OrderedDict(resolve_variables(raw_values, override=self.override)) else: self._dict = OrderedDict(raw_values) @@ -86,13 +87,13 @@ def parse(self): if mapping.key is not None: yield mapping.key, mapping.value - def set_as_environment_variables(self, override=False): - # type: (bool) -> bool + def set_as_environment_variables(self): + # type: () -> bool """ Load the current dotenv as system environemt variable. """ for k, v in self.dict().items(): - if k in os.environ and not override: + if k in os.environ and not self.override: continue if v is not None: os.environ[to_env(k)] = to_env(v) @@ -205,8 +206,8 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): return removed, key_to_unset -def resolve_variables(values): - # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Mapping[Text, Optional[Text]] +def resolve_variables(values, override): + # type: (Iterable[Tuple[Text, Optional[Text]]], bool) -> Mapping[Text, Optional[Text]] new_values = {} # type: Dict[Text, Optional[Text]] @@ -216,8 +217,12 @@ def resolve_variables(values): else: atoms = parse_variables(value) env = {} # type: Dict[Text, Optional[Text]] - env.update(os.environ) # type: ignore - env.update(new_values) + if override: + env.update(os.environ) # type: ignore + env.update(new_values) + else: + env.update(new_values) + env.update(os.environ) # type: ignore result = "".join(atom.resolve(env) for atom in atoms) new_values[name] = result @@ -299,10 +304,11 @@ def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, in Defaults to `False`. """ f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, interpolate=interpolate, **kwargs).set_as_environment_variables(override=override) + dotenv = DotEnv(f, verbose=verbose, interpolate=interpolate, override=override, **kwargs) + return dotenv.set_as_environment_variables() def dotenv_values(dotenv_path=None, stream=None, verbose=False, interpolate=True, **kwargs): # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Union[None, Text]) -> Dict[Text, Optional[Text]] # noqa: E501 f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, interpolate=interpolate, **kwargs).dict() + return DotEnv(f, verbose=verbose, interpolate=interpolate, override=True, **kwargs).dict() diff --git a/tests/test_main.py b/tests/test_main.py index 6b9458d2..b927d7f2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -257,6 +257,28 @@ def test_load_dotenv_existing_variable_override(dotenv_file): assert os.environ == {"a": "b"} +@mock.patch.dict(os.environ, {"a": "c"}, clear=True) +def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_file): + with open(dotenv_file, "w") as f: + f.write('a=b\nd="${a}"') + + result = dotenv.load_dotenv(dotenv_file) + + assert result is True + assert os.environ == {"a": "c", "d": "c"} + + +@mock.patch.dict(os.environ, {"a": "c"}, clear=True) +def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): + with open(dotenv_file, "w") as f: + f.write('a=b\nd="${a}"') + + result = dotenv.load_dotenv(dotenv_file, override=True) + + assert result is True + assert os.environ == {"a": "b", "d": "b"} + + @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_utf_8(): stream = StringIO("a=à") From 2e0ea4873ba39192db97fcafdb16ae03ffcaf951 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 27 Dec 2020 20:28:16 +0100 Subject: [PATCH 17/49] Rewrite readme (#294) This mainly reorganizes the readme based on questions and feedback from users on GitHub over the years: - Getting Started: Short section which covers the main use case and doesn't go into details. - Pip command at the very beginning so that users are less likely to mistakenly install another package. - Basic application code to load the .env file into the environment. - Introduction to the syntax of .env files with a short example. - Other common use cases: - Load configuration without altering the environment - Parse configuration as a stream - Load .env files in IPython - Command-line Interface - File format: Details about the syntax of .env files, previously scattered around. - Related Projects: I'm not sure we really need that one but I guess we can keep it for now. - Acknowledgements Minor changes: - I removed the "saythanks" link since it is dead. - I removed the banner made in ASCII art since it read ".env" and not "python-dotenv", which I found distracting. We could make another one but I don't have time right now. It also saves the user some scrolling. --- README.md | 339 +++++++++++++++++++++--------------------------------- 1 file changed, 132 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index 36f3b2b0..03638adc 100644 --- a/README.md +++ b/README.md @@ -1,276 +1,193 @@ -``` - _______ .__ __. ____ ____ - | ____|| \ | | \ \ / / - | |__ | \| | \ \/ / - | __| | . ` | \ / - __ | |____ | |\ | \ / - (__)|_______||__| \__| \__/ -``` -python-dotenv | [![Build Status](https://travis-ci.com/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.com/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) -=============================================================================== - -Reads the key-value pair from `.env` file and adds them to environment -variable. It is great for managing app settings during development and -in production using [12-factor](http://12factor.net/) principles. +# python-dotenv -> Do one thing, do it well! +[![Build Status][build_status_badge]][build_status_link] +[![Coverage Status][coverage_status_badge]][coverage_status_link] +[![PyPI version][pypi_badge]][pypi_link] -## Usages +Python-dotenv reads key-value pairs from a `.env` file and can set them as environment +variables. It helps in the development of applications following the +[12-factor](http://12factor.net/) principles. -The easiest and most common usage consists on calling `load_dotenv` when -the application starts, which will load environment variables from a -file named `.env` in the current directory or any of its parents or from -the path specificied; after that, you can just call the -environment-related method you need as provided by `os.getenv`. - -`.env` looks like this: +## Getting Started ```shell -# a comment that will be ignored. -REDIS_ADDRESS=localhost:6379 -MEANING_OF_LIFE=42 -MULTILINE_VAR="hello\nworld" +pip install python-dotenv ``` -You can optionally prefix each line with the word `export`, which is totally ignored by this library, but might allow you to [`source`](https://bash.cyberciti.biz/guide/Source_command) the file in bash. +If your application takes its configuration from environment variables, like a 12-factor +application, launching it in development is not very practical because you have to set +those environment variables yourself. -``` -export S3_BUCKET=YOURS3BUCKET -export SECRET_KEY=YOURSECRETKEYGOESHERE -``` +To help you with that, you can add Python-dotenv to your application to make it load the +configuration from a `.env` file when it is present (e.g. in development) while remaining +configurable via the environment: -Python-dotenv can interpolate variables using POSIX variable expansion. +```python +from dotenv import load_dotenv -With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the -first of the values defined in the following list: +load_dotenv() # take environment variables from .env. -- Value of that variable in the `.env` file. -- Value of that variable in the environment. -- Default value, if provided. -- Empty string. +# Code of your application, which uses environment variables (e.g. from `os.environ` or +# `os.getenv`) as if they came from the actual environment. +``` -With `load_dotenv(override=False)`, the value of a variable is the first of the values -defined in the following list: +By default, `load_dotenv` doesn't override existing environment variables. -- Value of that variable in the environment. -- Value of that variable in the `.env` file. -- Default value, if provided. -- Empty string. +To configure the development environment, add a `.env` in the root directory of your +project: -Ensure that variables are surrounded with `{}` like `${HOME}` as bare -variables such as `$HOME` are not expanded. +``` +. +├── .env +└── foo.py +``` -```shell -CONFIG_PATH=${HOME}/.config/foo +The syntax of `.env` files supported by python-dotenv is similar to that of Bash: + +```bash +# Development settings DOMAIN=example.org -EMAIL=admin@${DOMAIN} -DEBUG=${DEBUG:-false} +ADMIN_EMAIL=admin@${DOMAIN} +ROOT_URL=${DOMAIN}/app ``` -## Getting started +If you use variables in values, ensure they are surrounded with `{` and `}`, like +`${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. -Install the latest version with: +You will probably want to add `.env` to your `.gitignore`, especially if it contains +secrets like a password. -```shell -pip install -U python-dotenv -``` +See the section "File format" below for more information about what you can write in a +`.env` file. -Assuming you have created the `.env` file along-side your settings -module. +## Other Use Cases - . - ├── .env - └── settings.py +### Load configuration without altering the environment -Add the following code to your `settings.py`: +The function `dotenv_values` works more or less the same way as `load_dotenv`, except it +doesn't touch the environment, it just returns a `dict` with the values parsed from the +`.env` file. ```python -# settings.py -from dotenv import load_dotenv -load_dotenv() - -# OR, the same with increased verbosity -load_dotenv(verbose=True) +from dotenv import dotenv_values -# OR, explicitly providing path to '.env' -from pathlib import Path # Python 3.6+ only -env_path = Path('.') / '.env' -load_dotenv(dotenv_path=env_path) +config = dotenv_values(".env") # config = {"USER": "foo", "EMAIL": "foo@example.org"} ``` -At this point, parsed key/value from the `.env` file is now present as -system environment variable and they can be conveniently accessed via -`os.getenv()`: +This notably enables advanced configuration management: ```python -# settings.py import os -SECRET_KEY = os.getenv("EMAIL") -DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") -``` - -`load_dotenv` does not override existing System environment variables. To -override, pass `override=True` to `load_dotenv()`. - -`load_dotenv` also accepts `encoding` parameter to open the `.env` file. The default encoding is platform dependent (whatever `locale.getpreferredencoding()` returns), but any encoding supported by Python can be used. See the [codecs](https://docs.python.org/3/library/codecs.html#standard-encodings) module for the list of supported encodings. +from dotenv import dotenv_values -You can use `find_dotenv()` method that will try to find a `.env` file -by (a) guessing where to start using `__file__` or the working directory --- allowing this to work in non-file contexts such as IPython notebooks -and the REPL, and then (b) walking up the directory tree looking for the -specified file -- called `.env` by default. - -```python -from dotenv import load_dotenv, find_dotenv -load_dotenv(find_dotenv()) +config = { + **dotenv_values(".env.shared"), # load shared development variables + **dotenv_values(".env.secret"), # load sensitive variables + **os.environ, # override loaded values with environment variables +} ``` -### In-memory filelikes +### Parse configuration as a stream -It is possible to not rely on the filesystem to parse filelikes from -other sources (e.g. from a network storage). `load_dotenv` and -`dotenv_values` accepts a filelike `stream`. Just be sure to rewind it -before passing. +`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their `stream` +argument. It is thus possible to load the variables from sources other than the +filesystem (e.g. the network). ```python ->>> from io import StringIO # Python2: from StringIO import StringIO ->>> from dotenv import dotenv_values ->>> filelike = StringIO('SPAM=EGGS\n') ->>> filelike.seek(0) ->>> parsed = dotenv_values(stream=filelike) ->>> parsed['SPAM'] -'EGGS' -``` - -The returned value is dictionary with key-value pairs. - -`dotenv_values` could be useful if you need to *consume* the envfile but -not *apply* it directly into the system environment. - -### Django - -If you are using Django, you should add the above loader script at the -top of `wsgi.py` and `manage.py`. +from io import StringIO +from dotenv import load_dotenv -## IPython Support +config = StringIO("USER=foo\nEMAIL=foo@example.org") +load_dotenv(stream=stream) +``` -You can use dotenv with IPython. You can either let the dotenv search -for `.env` with `%dotenv` or provide the path to the `.env` file explicitly; see -below for usages. +### Load .env files in IPython - %load_ext dotenv +You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a +`.env` file: - # Use find_dotenv to locate the file - %dotenv +```python +%load_ext dotenv +%dotenv +``` - # Specify a particular file - %dotenv relative/or/absolute/path/to/.env +You can also specify a path: - # Use '-o' to indicate override of existing variables - %dotenv -o +```python +%dotenv relative/or/absolute/path/to/.env +``` - # Use '-v' to turn verbose mode on - %dotenv -v +Optional flags: +- `-o` to override existing variables. +- `-v` for increased verbosity. ## Command-line Interface -For command-line support, use the CLI option during installation: +A CLI interface `dotenv` is also included, which helps you manipulate the `.env` file +without manually opening it. ```shell -pip install -U "python-dotenv[cli]" +$ pip install "python-dotenv[cli]" +$ dotenv set USER=foo +$ dotenv set EMAIL=foo@example.org +$ dotenv list +USER=foo +EMAIL=foo@example.org +$ dotenv run -- python foo.py ``` -A CLI interface `dotenv` is also included, which helps you manipulate -the `.env` file without manually opening it. The same CLI installed on -remote machine combined with fabric (discussed later) will enable you to -update your settings on a remote server; handy, isn't it! +Run `dotenv --help` for more information about the options and subcommands. -``` -Usage: dotenv [OPTIONS] COMMAND [ARGS]... - - This script is used to set, get or unset values from a .env file. - -Options: - -f, --file PATH Location of the .env file, defaults to .env - file in current working directory. +## File format - -q, --quote [always|never|auto] - Whether to quote or not the variable values. - Default mode is always. This does not affect - parsing. +The format is not formally specified and still improves over time. That being said, +`.env` files should mostly look like Bash files. - -e, --export BOOLEAN - Whether to write the dot file as an - executable bash script. +Keys can be unquoted or single-quoted. Values can be unquoted, single- or double-quoted. +Spaces before and after keys, equal signs, and values are ignored. Values can be followed +by a comment. Lines can start with the `export` directive, which has no effect on their +interpretation. - --version Show the version and exit. - --help Show this message and exit. - -Commands: - get Retrieve the value for the given key. - list Display all the stored key/value. - run Run command with environment variables present. - set Store the given key/value. - unset Removes the given key. -``` +Allowed escape sequences: +- in single-quoted values: `\\`, `\'` +- in double-quoted values: `\\`, `\'`, `\"`, `\a`, `\b`, `\f`, `\n`, `\r`, `\t`, `\v` -### Setting config on Remote Servers +### Multiline values -We make use of excellent [Fabric](http://www.fabfile.org/) to accomplish -this. Add a config task to your local fabfile; `dotenv_path` is the -location of the absolute path of `.env` file on the remote server. +It is possible for single- or double-quoted values to span multiple lines. The following +examples are equivalent: -```python -# fabfile.py - -import dotenv -from fabric.api import task, run, env - -# absolute path to the location of .env on remote server. -env.dotenv_path = '/opt/myapp/.env' - -@task -def config(action=None, key=None, value=None): - '''Manage project configuration via .env - - e.g: fab config:set,, - fab config:get, - fab config:unset, - fab config:list - ''' - run('touch %(dotenv_path)s' % env) - command = dotenv.get_cli_string(env.dotenv_path, action, key, value) - run(command) +```bash +FOO="first line +second line" ``` -Usage is designed to mirror the Heroku config API very closely. - -Get all your remote config info with `fab config`: - - $ fab config - foo="bar" - -Set remote config variables with `fab config:set,,`: - - $ fab config:set,hello,world - -Get a single remote config variables with `fab config:get,`: +```bash +FOO="first line\nsecond line" +``` - $ fab config:get,hello +### Variable expansion -Delete a remote config variables with `fab config:unset,`: +Python-dotenv can interpolate variables using POSIX variable expansion. - $ fab config:unset,hello +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the +first of the values defined in the following list: -Thanks entirely to fabric and not one bit to this project, you can chain -commands like so: -`fab config:set,, config:set,,` +- Value of that variable in the `.env` file. +- Value of that variable in the environment. +- Default value, if provided. +- Empty string. - $ fab config:set,hello,world config:set,foo,bar config:set,fizz=buzz +With `load_dotenv(override=False)`, the value of a variable is the first of the values +defined in the following list: +- Value of that variable in the environment. +- Value of that variable in the `.env` file. +- Default value, if provided. +- Empty string. ## Related Projects @@ -283,9 +200,17 @@ commands like so: - [environs](https://github.com/sloria/environs) - [dynaconf](https://github.com/rochacbruno/dynaconf) - ## Acknowledgements -This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and [Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not -have been possible without the support of these [awesome +This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and +[Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not have been possible +without the support of these [awesome people](https://github.com/theskumar/python-dotenv/graphs/contributors). + +[build_status_badge]: https://travis-ci.com/theskumar/python-dotenv.svg?branch=master +[build_status_link]: https://travis-ci.com/theskumar/python-dotenv +[coverage_status_badge]: https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master +[coverage_status_link]: https://coveralls.io/r/theskumar/python-dotenv?branch=master +[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg +[pypi_link]: http://badge.fury.io/py/python-dotenv +[python_streams]: https://docs.python.org/3/library/io.html From 192722508a62fc4e25293a9e6061744773a3fdf7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 28 Dec 2020 01:20:57 +0530 Subject: [PATCH 18/49] doc: add table of content --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 03638adc..127d46f0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ Python-dotenv reads key-value pairs from a `.env` file and can set them as envir variables. It helps in the development of applications following the [12-factor](http://12factor.net/) principles. +- [Getting Started](#getting-started) +- [Other Use Cases](#other-use-cases) + * [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) + * [Parse configuration as a stream](#parse-configuration-as-a-stream) + * [Load .env files in IPython](#load-env-files-in-ipython) +- [Command-line Interface](#command-line-interface) +- [File format](#file-format) + * [Multiline values](#multiline-values) + * [Variable expansion](#variable-expansion) +- [Related Projects](#related-projects) +- [Acknowledgements](#acknowledgements) + ## Getting Started ```shell From ac670cf993fd622bfc0a5ea961681341b291779e Mon Sep 17 00:00:00 2001 From: Serghei Iakovlev Date: Wed, 27 Jan 2021 02:36:45 +0200 Subject: [PATCH 19/49] Fix misspelling --- src/dotenv/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b366b18e..31c41ee7 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -90,7 +90,7 @@ def parse(self): def set_as_environment_variables(self): # type: () -> bool """ - Load the current dotenv as system environemt variable. + Load the current dotenv as system environment variable. """ for k, v in self.dict().items(): if k in os.environ and not self.override: From a7fe93f6cc73ab9de28191e3854f1a713d53363b Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 18 Oct 2020 17:29:07 +0530 Subject: [PATCH 20/49] Add GitHub actions to replace Travis CI --- .github/workflows/release.yml | 25 +++++++++++++++++ .github/workflows/test.yml | 25 +++++++++++++++++ .travis.yml | 52 ----------------------------------- setup.cfg | 4 ++- tox.ini | 19 ++++++++----- 5 files changed, 65 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a3abd994 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + make release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..04805932 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Run Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + max-parallel: 8 + matrix: + os: + - ubuntu-latest + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8ccd2405..00000000 --- a/.travis.yml +++ /dev/null @@ -1,52 +0,0 @@ -language: python -cache: pip -os: linux -dist: xenial - -jobs: - include: - - python: "3.6" - env: TOXENV=lint - - python: "3.6" - env: TOXENV=manifest - - python: "2.7" - env: TOXENV=py27 - - python: "3.5" - env: TOXENV=py35 - - python: "3.6" - env: TOXENV=py36 - - python: "3.7" - env: TOXENV=py37 - - python: "3.8" - env: TOXENV=py38 - - python: "3.9-dev" - env: TOXENV=py39 - - python: "pypy" - env: TOXENV=pypy - - python: "pypy3" - env: TOXENV=pypy3 - -install: - - pip install tox - -script: - - tox - -before_install: - - pip install --upgrade pip - - pip install coveralls - -after_success: - - tox -e coverage-report - - coveralls - -deploy: - provider: pypi - username: theskumar - password: - secure: DXUkl4YSC2RCltChik1csvQulnVMQQpD/4i4u+6pEyUfBMYP65zFYSNwLh+jt+URyX+MpN/Er20+TZ/F/fu7xkru6/KBqKLugeXihNbwGhbHUIkjZT/0dNSo03uAz6s5fWgqr8EJk9Ll71GexAsBPx2yqsjc2BMgOjwcNly40Co= - distributions: "sdist bdist_wheel" - skip_existing: true - on: - tags: true - repo: theskumar/python-dotenv diff --git a/setup.cfg b/setup.cfg index 9d69a202..e882b8db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,12 +17,13 @@ exclude = .tox,.git,docs,venv,.venv ignore_missing_imports = true [metadata] -description-file = README.rst +description-file = README.md [tool:pytest] testpaths = tests [coverage:run] +relative_files = True source = dotenv [coverage:paths] @@ -33,6 +34,7 @@ source = [coverage:report] show_missing = True +include = */site-packages/dotenv/* exclude_lines = if IS_TYPE_CHECKING: pragma: no cover diff --git a/tox.ini b/tox.ini index 0025c946..e4d6f638 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,16 @@ [tox] -envlist = lint,py{27,34,35,36,37,38,39,34-no-typing},pypy,pypy3,manifest,coverage-report +envlist = lint,py{27,35,36,37,38,39},pypy,pypy3,manifest,coverage-report + +[gh-actions] +python = + 2.7: py27, coverage-report + 3.5: py35, coverage-report + 3.6: py36, coverage-report + 3.7: py37, coverage-report + 3.8: py38, coverage-report + 3.9: py39, mypy, lint, manifest, coverage-report + pypy2: pypy, coverage-report + pypy3: pypy3, coverage-report [testenv] deps = @@ -9,15 +20,9 @@ deps = sh click py{27,py}: ipython<6.0.0 - py34{,-no-typing}: ipython<7.0.0 py{35,36,37,38,39,py3}: ipython commands = coverage run --parallel -m pytest {posargs} -[testenv:py34-no-typing] -commands = - pip uninstall --yes typing - coverage run --parallel -m pytest -k 'not test_ipython' {posargs} - [testenv:lint] skip_install = true deps = From b158aa721cdf625c72f79f3583e5cc1f0cea2950 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 10 Mar 2021 22:36:01 +0100 Subject: [PATCH 21/49] Use UTF-8 as default encoding The default value for the `encoding` paramter of `load_dotenv` and `dotenv_values` is now `"utf-8"` instead of `None` (which selected the encoding based on the user's locale). It is passed directly to `io.open`. The rationale for this change is that the encoding of a project file like `.env` should not depend on the user's locale by default. UTF-8 makes sense as the default encoding since it is also used for Python source files. The main drawback is that it departs from `open`'s default value of `None` for the `encoding` parameter. The default value of `None` was a source of confusion for some users. The Flask and Docker Compose projects already use `encoding="utf-8"` to enforce the use of UTF-8 and avoid that sort of confusion. This is a breaking change but only for users with a non-UTF-8 locale and non-UTF-8 characters in their .env files. --- CHANGELOG.md | 6 ++++- src/dotenv/main.py | 61 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index effa2510..1db10901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- Fix resolution order in variable expansion with `override=False` (#? by [@bbc2]). +### Changed + +- The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is + now `"utf-8"` instead of `None` (#? by [@bbc2]). +- Fix resolution order in variable expansion with `override=False` (#287 by [@bbc2]). ## [0.15.0] - 2020-10-28 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 31c41ee7..16f22d2c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -293,22 +293,63 @@ def _is_interactive(): return '' -def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, interpolate=True, **kwargs): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, bool, Union[None, Text]) -> bool +def load_dotenv( + dotenv_path=None, + stream=None, + verbose=False, + override=False, + interpolate=True, + encoding="utf-8", +): + # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. - - *stream*: `StringIO` object with .env content. - - *verbose*: whether to output the warnings related to missing .env file etc. Defaults to `False`. - - *override*: where to override the system environment variables with the variables in `.env` file. - Defaults to `False`. + - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. + - *verbose*: whether to output a warning the .env file is missing. Defaults to + `False`. + - *override*: whether to override the system environment variables with the variables + in `.env` file. Defaults to `False`. + - *encoding*: encoding to be used to read the file. + + If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. """ f = dotenv_path or stream or find_dotenv() - dotenv = DotEnv(f, verbose=verbose, interpolate=interpolate, override=override, **kwargs) + dotenv = DotEnv( + f, + verbose=verbose, + interpolate=interpolate, + override=override, + encoding=encoding, + ) return dotenv.set_as_environment_variables() -def dotenv_values(dotenv_path=None, stream=None, verbose=False, interpolate=True, **kwargs): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Union[None, Text]) -> Dict[Text, Optional[Text]] # noqa: E501 +def dotenv_values( + dotenv_path=None, + stream=None, + verbose=False, + interpolate=True, + encoding="utf-8", +): + # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 + """ + Parse a .env file and return its content as a dict. + + - *dotenv_path*: absolute or relative path to .env file. + - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. + - *verbose*: whether to output a warning the .env file is missing. Defaults to + `False`. + in `.env` file. Defaults to `False`. + - *encoding*: encoding to be used to read the file. + + If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. + """ f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, interpolate=interpolate, override=True, **kwargs).dict() + return DotEnv( + f, + verbose=verbose, + interpolate=interpolate, + override=True, + encoding=encoding, + ).dict() From b96db46dc8b66adba8afcfd3ec3b8ed2b4d6cefe Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 27 Mar 2021 10:00:25 +0100 Subject: [PATCH 22/49] Release version v0.16.0 --- CHANGELOG.md | 9 +++++++-- setup.cfg | 2 +- setup.py | 2 +- src/dotenv/version.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db10901..14f10d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +_There are no unreleased changes at this time._ + +## [0.16.0] - 2021-03-27 + ### Changed - The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is - now `"utf-8"` instead of `None` (#? by [@bbc2]). + now `"utf-8"` instead of `None` (#306 by [@bbc2]). - Fix resolution order in variable expansion with `override=False` (#287 by [@bbc2]). ## [0.15.0] - 2020-10-28 @@ -239,7 +243,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...HEAD +[0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 diff --git a/setup.cfg b/setup.cfg index e882b8db..03e6644f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.15.0 +current_version = 0.16.0 commit = True tag = True diff --git a/setup.py b/setup.py index 530ab129..3fc452c5 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def read_files(files): setup( name="python-dotenv", - description="Add .env support to your django/flask apps in development and deployments", + description="Read key-value pairs from a .env file and set them as environment variables", long_description=long_description, long_description_content_type='text/markdown', version=meta['__version__'], diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 9da2f8fc..5a313cc7 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.15.0" +__version__ = "0.16.0" From efc51829f8e7693dbf4b7655169a9f852e9943d2 Mon Sep 17 00:00:00 2001 From: zueve Date: Thu, 28 Jan 2021 12:33:48 +0200 Subject: [PATCH 23/49] Add --override/--no-override flag to "dotenv run" This makes it possible to not override previously defined environment variables when running `dotenv run`. It defaults to `--override` for compatibility with the previous behavior. --- CHANGELOG.md | 5 ++++- src/dotenv/cli.py | 15 ++++++++++++--- tests/test_cli.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f10d37..5a5b276b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -_There are no unreleased changes at this time._ +### Added + +- Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). ## [0.16.0] - 2021-03-27 @@ -242,6 +244,7 @@ _There are no unreleased changes at this time._ [@venthur]: https://github.com/venthur [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham +[@zueve]: https://github.com/zueve [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...HEAD [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index e17d248f..51f25e8d 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -107,9 +107,14 @@ def unset(ctx, key): @cli.command(context_settings={'ignore_unknown_options': True}) @click.pass_context +@click.option( + "--override/--no-override", + default=True, + help="Override variables from the environment file with those from the .env file.", +) @click.argument('commandline', nargs=-1, type=click.UNPROCESSED) -def run(ctx, commandline): - # type: (click.Context, List[str]) -> None +def run(ctx, override, commandline): + # type: (click.Context, bool, List[str]) -> None """Run command with environment variables present.""" file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -117,7 +122,11 @@ def run(ctx, commandline): 'Invalid value for \'-f\' "%s" does not exist.' % (file), ctx=ctx ) - dotenv_as_dict = {to_env(k): to_env(v) for (k, v) in dotenv_values(file).items() if v is not None} + dotenv_as_dict = { + to_env(k): to_env(v) + for (k, v) in dotenv_values(file).items() + if v is not None and (override or to_env(k) not in os.environ) + } if not commandline: click.echo('No command given.') diff --git a/tests/test_cli.py b/tests/test_cli.py index 23404e70..a048ef3b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -138,6 +138,34 @@ def test_run(tmp_path): assert result == "b\n" +def test_run_with_existing_variable(tmp_path): + sh.cd(str(tmp_path)) + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = sh.dotenv("run", "printenv", "a", _env={"LANG": "en_US.UTF-8", "a": "c"}) + + assert result == "b\n" + + +def test_run_with_existing_variable_not_overridden(tmp_path): + sh.cd(str(tmp_path)) + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = sh.dotenv( + "run", + "--no-override", + "printenv", + "a", + _env={"LANG": "en_US.UTF-8", "a": "c"}, + ) + + assert result == "c\n" + + def test_run_with_none_value(tmp_path): sh.cd(str(tmp_path)) dotenv_file = str(tmp_path / ".env") From 6242550a53efe45ef53d9904fbb8c4257470c276 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 27 Mar 2021 12:55:12 +0100 Subject: [PATCH 24/49] Use badge from GitHub Actions --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 127d46f0..7f0b2eb5 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.co without the support of these [awesome people](https://github.com/theskumar/python-dotenv/graphs/contributors). -[build_status_badge]: https://travis-ci.com/theskumar/python-dotenv.svg?branch=master -[build_status_link]: https://travis-ci.com/theskumar/python-dotenv +[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg +[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml [coverage_status_badge]: https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master [coverage_status_link]: https://coveralls.io/r/theskumar/python-dotenv?branch=master [pypi_badge]: https://badge.fury.io/py/python-dotenv.svg From f2eba2c1293b92f23bdf0d6a5b2e7210395dfedf Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 27 Mar 2021 12:57:17 +0100 Subject: [PATCH 25/49] Remove outdated Coveralls badge --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 7f0b2eb5..f8d49562 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # python-dotenv [![Build Status][build_status_badge]][build_status_link] -[![Coverage Status][coverage_status_badge]][coverage_status_link] [![PyPI version][pypi_badge]][pypi_link] Python-dotenv reads key-value pairs from a `.env` file and can set them as environment @@ -221,8 +220,6 @@ people](https://github.com/theskumar/python-dotenv/graphs/contributors). [build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg [build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml -[coverage_status_badge]: https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master -[coverage_status_link]: https://coveralls.io/r/theskumar/python-dotenv?branch=master [pypi_badge]: https://badge.fury.io/py/python-dotenv.svg [pypi_link]: http://badge.fury.io/py/python-dotenv [python_streams]: https://docs.python.org/3/library/io.html From 48c5c8e16c1dcb2188984f2245559cee37fe9db4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 28 Mar 2021 17:55:18 +0200 Subject: [PATCH 26/49] Only display value with `dotenv get` The `get` subcommand would return `key=value`, which is impractical to retrieve the value of a key in a script. Since the `key` is already known by the caller, there is no point in showing it. This also makes the output consistent with the documentation for the subcommand. --- CHANGELOG.md | 4 ++++ src/dotenv/cli.py | 2 +- tests/test_cli.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5b276b..8969b4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). + ### Added - Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 51f25e8d..bb96c023 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -85,7 +85,7 @@ def get(ctx, key): ) stored_value = get_key(file, key) if stored_value: - click.echo('%s=%s' % (key, stored_value)) + click.echo(stored_value) else: exit(1) diff --git a/tests/test_cli.py b/tests/test_cli.py index a048ef3b..b21725ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,7 +35,7 @@ def test_get_existing_value(cli, dotenv_file): result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'a']) - assert (result.exit_code, result.output) == (0, "a=b\n") + assert (result.exit_code, result.output) == (0, "b\n") def test_get_non_existent_value(cli, dotenv_file): @@ -124,7 +124,7 @@ def test_get_default_path(tmp_path): result = sh.dotenv("get", "a") - assert result == "a=b\n" + assert result == "b\n" def test_run(tmp_path): From cfca79a3cd384710c98651da79d66d964e0a65d1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 2 Apr 2021 23:11:33 +0200 Subject: [PATCH 27/49] Release version 0.17.0 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8969b4c7..85076147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.17.0] - 2021-04-02 ### Changed @@ -250,7 +250,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...HEAD +[0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 diff --git a/setup.cfg b/setup.cfg index 03e6644f..a2b27bfc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.16.0 +current_version = 0.17.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 5a313cc7..fd86b3ee 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.16.0" +__version__ = "0.17.0" From abde8e5e8409c70c05edadf379ec7308a4965c8c Mon Sep 17 00:00:00 2001 From: David Wesby Date: Tue, 13 Apr 2021 17:35:59 +0100 Subject: [PATCH 28/49] Fix stream parse example in README.md In the existing example, the name "stream" is undefined, causing a NameError. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8d49562..9757e672 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ from io import StringIO from dotenv import load_dotenv config = StringIO("USER=foo\nEMAIL=foo@example.org") -load_dotenv(stream=stream) +load_dotenv(stream=config) ``` ### Load .env files in IPython From 7d9b45a290b509c31daed780b97a3a3f15d25065 Mon Sep 17 00:00:00 2001 From: Karolina Surma Date: Wed, 28 Apr 2021 14:27:28 +0200 Subject: [PATCH 29/49] Copy existing environment for usage in tests Overriding the whole environment would remove critical variables like `PYTHONPATH`. This would break tests on some systems like during Fedora or Gentoo packaging. --- CHANGELOG.md | 7 +++++++ tests/test_cli.py | 16 ++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85076147..f623e61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). + ## [0.17.0] - 2021-04-02 ### Changed @@ -232,6 +238,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@andrewsmith]: https://github.com/andrewsmith [@asyncee]: https://github.com/asyncee [@bbc2]: https://github.com/bbc2 +[@befeleme]: https://github.com/befeleme [@cjauvin]: https://github.com/cjauvin [@earlbread]: https://github.com/earlbread [@ekohl]: https://github.com/ekohl diff --git a/tests/test_cli.py b/tests/test_cli.py index b21725ca..bc6b8d47 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import os + import pytest import sh @@ -143,8 +145,10 @@ def test_run_with_existing_variable(tmp_path): dotenv_file = str(tmp_path / ".env") with open(dotenv_file, "w") as f: f.write("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv("run", "printenv", "a", _env={"LANG": "en_US.UTF-8", "a": "c"}) + result = sh.dotenv("run", "printenv", "a", _env=env) assert result == "b\n" @@ -154,14 +158,10 @@ def test_run_with_existing_variable_not_overridden(tmp_path): dotenv_file = str(tmp_path / ".env") with open(dotenv_file, "w") as f: f.write("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv( - "run", - "--no-override", - "printenv", - "a", - _env={"LANG": "en_US.UTF-8", "a": "c"}, - ) + result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) assert result == "c\n" From 303423864ae00f8d5f21cb39d6421a7d775a3daf Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 29 Apr 2021 21:01:14 +0200 Subject: [PATCH 30/49] Release version 0.17.1 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f623e61f..e4b81353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.17.1] - 2021-04-29 ### Fixed @@ -257,7 +257,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...HEAD +[0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 diff --git a/setup.cfg b/setup.cfg index a2b27bfc..58054071 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.17.0 +current_version = 0.17.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index fd86b3ee..c6eae9f8 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.17.0" +__version__ = "0.17.1" From b3c31954c2cb907935f77cde653783d4e5a05ec0 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 29 May 2021 14:34:12 +0200 Subject: [PATCH 31/49] Improve quoting of values in `set_key` The value of `quote_mode` must now be one of `auto`, `never` or `always`, to ensure that users aren't accidentally relying on any other value for their scripts to work. Surrounding quotes are no longer stripped. This makes it possible for the user to control exactly what goes in the .env file. Note that when doing `dotenv set foo 'bar'` in Bash, the shell will have already removed the quotes. Single quotes are used instead of double quotes. This avoids accidentally having values interpreted by the parser or Bash (e.g. if you set a password with `dotenv set password 'af$rb0'`. Previously, the `auto` mode of quoting had the same effect as `always`. This commit restores the functionality of `auto` by not quoting alphanumeric values (which don't need quotes). Plenty of other kinds of values also don't need quotes but it's hard to know which ones without actually parsing them, so we just omit quotes for alphanumeric values, at least for now. --- CHANGELOG.md | 13 +++++++++++++ src/dotenv/main.py | 13 ++++++++----- tests/test_cli.py | 13 +++++++------ tests/test_main.py | 24 ++++++++++++------------ 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b81353..0852d66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in + `set_key` (#330 by [@bbc2]). +- When writing a value to a .env file with `set_key` or `dotenv set ` (#330 + by [@bbc2]): + - Use single quotes instead of double quotes. + - Don't strip surrounding quotes. + - In `auto` mode, don't add quotes if the value is only made of alphanumeric characters + (as determined by `string.isalnum`). + ## [0.17.1] - 2021-04-29 ### Fixed diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 16f22d2c..b85836a5 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -151,13 +151,16 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=F If the .env path given doesn't exist, fails instead of risking creating an orphan .env somewhere in the filesystem """ - value_to_set = value_to_set.strip("'").strip('"') + if quote_mode not in ("always", "auto", "never"): + raise ValueError("Unknown quote_mode: {}".format(quote_mode)) - if " " in value_to_set: - quote_mode = "always" + quote = ( + quote_mode == "always" + or (quote_mode == "auto" and not value_to_set.isalnum()) + ) - if quote_mode == "always": - value_out = '"{}"'.format(value_to_set.replace('"', '\\"')) + if quote: + value_out = "'{}'".format(value_to_set.replace("'", "\\'")) else: value_out = value_to_set if export: diff --git a/tests/test_cli.py b/tests/test_cli.py index bc6b8d47..d2558234 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -73,10 +73,11 @@ def test_unset_non_existent_value(cli, dotenv_file): @pytest.mark.parametrize( "quote_mode,variable,value,expected", ( - ("always", "HELLO", "WORLD", 'HELLO="WORLD"\n'), - ("never", "HELLO", "WORLD", 'HELLO=WORLD\n'), - ("auto", "HELLO", "WORLD", 'HELLO=WORLD\n'), - ("auto", "HELLO", "HELLO WORLD", 'HELLO="HELLO WORLD"\n'), + ("always", "a", "x", "a='x'\n"), + ("never", "a", "x", 'a=x\n'), + ("auto", "a", "x", "a=x\n"), + ("auto", "a", "x y", "a='x y'\n"), + ("auto", "a", "$", "a='$'\n"), ) ) def test_set_quote_options(cli, dotenv_file, quote_mode, variable, value, expected): @@ -92,8 +93,8 @@ def test_set_quote_options(cli, dotenv_file, quote_mode, variable, value, expect @pytest.mark.parametrize( "dotenv_file,export_mode,variable,value,expected", ( - (".nx_file", "true", "HELLO", "WORLD", "export HELLO=\"WORLD\"\n"), - (".nx_file", "false", "HELLO", "WORLD", "HELLO=\"WORLD\"\n"), + (".nx_file", "true", "a", "x", "export a='x'\n"), + (".nx_file", "false", "a", "x", "a='x'\n"), ) ) def test_set_export(cli, dotenv_file, export_mode, variable, value, expected): diff --git a/tests/test_main.py b/tests/test_main.py index b927d7f2..f36f7340 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,18 +28,18 @@ def test_set_key_no_file(tmp_path): @pytest.mark.parametrize( "before,key,value,expected,after", [ - ("", "a", "", (True, "a", ""), 'a=""\n'), - ("", "a", "b", (True, "a", "b"), 'a="b"\n'), - ("", "a", "'b'", (True, "a", "b"), 'a="b"\n'), - ("", "a", "\"b\"", (True, "a", "b"), 'a="b"\n'), - ("", "a", "b'c", (True, "a", "b'c"), 'a="b\'c"\n'), - ("", "a", "b\"c", (True, "a", "b\"c"), 'a="b\\\"c"\n'), - ("a=b", "a", "c", (True, "a", "c"), 'a="c"\n'), - ("a=b\n", "a", "c", (True, "a", "c"), 'a="c"\n'), - ("a=b\n\n", "a", "c", (True, "a", "c"), 'a="c"\n\n'), - ("a=b\nc=d", "a", "e", (True, "a", "e"), 'a="e"\nc=d'), - ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), 'a=b\nc="g"\ne=f'), - ("a=b\n", "c", "d", (True, "c", "d"), 'a=b\nc="d"\n'), + ("", "a", "", (True, "a", ""), "a=''\n"), + ("", "a", "b", (True, "a", "b"), "a='b'\n"), + ("", "a", "'b'", (True, "a", "'b'"), "a='\\'b\\''\n"), + ("", "a", "\"b\"", (True, "a", '"b"'), "a='\"b\"'\n"), + ("", "a", "b'c", (True, "a", "b'c"), "a='b\\'c'\n"), + ("", "a", "b\"c", (True, "a", "b\"c"), "a='b\"c'\n"), + ("a=b", "a", "c", (True, "a", "c"), "a='c'\n"), + ("a=b\n", "a", "c", (True, "a", "c"), "a='c'\n"), + ("a=b\n\n", "a", "c", (True, "a", "c"), "a='c'\n\n"), + ("a=b\nc=d", "a", "e", (True, "a", "e"), "a='e'\nc=d"), + ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"), + ("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), ], ) def test_set_key(dotenv_file, before, key, value, expected, after): From dbf8c7bd50745f2f2e8dd1ead500efb998eda7c4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 13:45:48 +0200 Subject: [PATCH 32/49] Fix CI Mypy was failing because the new version requires some type packages to be installed even when `ignore_missing_imports` is set to `true`. --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index e4d6f638..0f52ac23 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ python = 3.6: py36, coverage-report 3.7: py37, coverage-report 3.8: py38, coverage-report - 3.9: py39, mypy, lint, manifest, coverage-report + 3.9: py39, lint, manifest, coverage-report pypy2: pypy, coverage-report pypy3: pypy3, coverage-report @@ -27,7 +27,7 @@ commands = coverage run --parallel -m pytest {posargs} skip_install = true deps = flake8 - mypy + mypy<0.900 commands = flake8 src tests mypy --python-version=3.9 src tests From 72bc30773962cb23cabee2c41f4317bf88b896e3 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 17:04:17 +0200 Subject: [PATCH 33/49] Fix setuptools warning --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 58054071..3bb98964 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,7 +17,7 @@ exclude = .tox,.git,docs,venv,.venv ignore_missing_imports = true [metadata] -description-file = README.md +description_file = README.md [tool:pytest] testpaths = tests From 3c08eaf8a0129440613525deef767d3dbd01019d Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 18:18:15 +0200 Subject: [PATCH 34/49] Fix license metadata --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 3fc452c5..fd5785a9 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ def read_files(files): [console_scripts] dotenv=dotenv.cli:cli ''', + license='BSD-3-Clause', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', From 97615cdcd0b6c6ffcf18b272598e82bfa3a18938 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 18:39:22 +0200 Subject: [PATCH 35/49] Release version 0.18.0 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0852d66e..7aa4cfd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.18.0] - 2021-06-20 ### Changed @@ -270,7 +270,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...HEAD +[0.18.0]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...v0.18.0 [0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 diff --git a/setup.cfg b/setup.cfg index 3bb98964..9afbc4b3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.17.1 +current_version = 0.18.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index c6eae9f8..1317d755 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.17.1" +__version__ = "0.18.0" From 9d777e3907ee1c2d7550228c7089c0b244d25056 Mon Sep 17 00:00:00 2001 From: Serghei Iakovlev Date: Fri, 28 May 2021 17:27:30 +0300 Subject: [PATCH 36/49] Add django-environ-2 to the Related Projects list Added django-environ-2 because this project is developing independently from joke2k's django-environ. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9757e672..045da075 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ defined in the following list: Procfile-based applications. - [django-dotenv](https://github.com/jpadilla/django-dotenv) - [django-environ](https://github.com/joke2k/django-environ) +- [django-environ-2](https://github.com/sergeyklay/django-environ-2) - [django-configuration](https://github.com/jezdez/django-configurations) - [dump-env](https://github.com/sobolevn/dump-env) - [environs](https://github.com/sloria/environs) From 5c7f43f7dc6d351ea7e311b525f979bb24a054b4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 26 Jun 2021 00:19:55 +0200 Subject: [PATCH 37/49] Avoid leaving any file after running tests This notably prevents the file `.nx_file` from being created and not removed after running tests. That file could also lead to confusing test failures when changing and testing the code of python-dotenv. --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7a9ed7e5..24a82528 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,9 @@ @pytest.fixture def cli(): - yield CliRunner() + runner = CliRunner() + with runner.isolated_filesystem(): + yield runner @pytest.fixture From fbc7a6350e25503aaf8908261a2f2a62157afabb Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 26 Jun 2021 11:21:02 +0200 Subject: [PATCH 38/49] Require Python >= 3.5 This is a big change. It will make it possible to simplify the code, add more features, improve the robustness and lower the barrier to new contributions. As per [Python's packaging documentation][doc], the `python_requires` keyword argument needs `setuptools >= 24.2.0` (released in 2016) and will only have en effect for `pip >= 9.0.0` (released in 2016 as well). [doc]: https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 7 +++++++ setup.py | 6 +----- tox.ini | 7 +------ 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04805932..2865cf85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, pypy3] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa4cfd9..2b4340c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 + by [@bbc2]). + ## [0.18.0] - 2021-06-20 ### Changed diff --git a/setup.py b/setup.py index fd5785a9..5e27d26d 100644 --- a/setup.py +++ b/setup.py @@ -33,9 +33,7 @@ def read_files(files): package_data={ 'dotenv': ['py.typed'], }, - install_requires=[ - "typing; python_version<'3.5'", - ], + python_requires=">=3.5", extras_require={ 'cli': ['click>=5.0', ], }, @@ -47,8 +45,6 @@ def read_files(files): classifiers=[ 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', diff --git a/tox.ini b/tox.ini index 0f52ac23..7c2b4f9d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,13 @@ [tox] -envlist = lint,py{27,35,36,37,38,39},pypy,pypy3,manifest,coverage-report +envlist = lint,py{35,36,37,38,39},pypy3,manifest,coverage-report [gh-actions] python = - 2.7: py27, coverage-report 3.5: py35, coverage-report 3.6: py36, coverage-report 3.7: py37, coverage-report 3.8: py38, coverage-report 3.9: py39, lint, manifest, coverage-report - pypy2: pypy, coverage-report pypy3: pypy3, coverage-report [testenv] @@ -19,7 +17,6 @@ deps = coverage sh click - py{27,py}: ipython<6.0.0 py{35,36,37,38,39,py3}: ipython commands = coverage run --parallel -m pytest {posargs} @@ -35,8 +32,6 @@ commands = mypy --python-version=3.7 src tests mypy --python-version=3.6 src tests mypy --python-version=3.5 src tests - mypy --python-version=3.4 src tests - mypy --python-version=2.7 src tests [testenv:manifest] deps = check-manifest From 9e522b1221471d9b4bbdba6b0759edfd6a16d941 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 11:51:50 +0200 Subject: [PATCH 39/49] Remove some code specific to Python 2 `to_env` and `to_text` are no longer necessary since they were identity functions with Python 3. --- src/dotenv/cli.py | 6 +++--- src/dotenv/compat.py | 39 --------------------------------------- src/dotenv/main.py | 29 +++++++++-------------------- src/dotenv/parser.py | 4 ++-- tests/test_main.py | 11 ++++------- tests/test_parser.py | 5 +++-- 6 files changed, 21 insertions(+), 73 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index bb96c023..d15ea53e 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -9,7 +9,7 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) -from .compat import IS_TYPE_CHECKING, to_env +from .compat import IS_TYPE_CHECKING from .main import dotenv_values, get_key, set_key, unset_key from .version import __version__ @@ -123,9 +123,9 @@ def run(ctx, override, commandline): ctx=ctx ) dotenv_as_dict = { - to_env(k): to_env(v) + k: v for (k, v) in dotenv_values(file).items() - if v is not None and (override or to_env(k) not in os.environ) + if v is not None and (override or k not in os.environ) } if not commandline: diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py index f8089bf4..27b48562 100644 --- a/src/dotenv/compat.py +++ b/src/dotenv/compat.py @@ -1,13 +1,3 @@ -import sys - -PY2 = sys.version_info[0] == 2 # type: bool - -if PY2: - from StringIO import StringIO # noqa -else: - from io import StringIO # noqa - - def is_type_checking(): # type: () -> bool try: @@ -18,32 +8,3 @@ def is_type_checking(): IS_TYPE_CHECKING = is_type_checking() - - -if IS_TYPE_CHECKING: - from typing import Text - - -def to_env(text): - # type: (Text) -> str - """ - Encode a string the same way whether it comes from the environment or a `.env` file. - """ - if PY2: - return text.encode(sys.getfilesystemencoding() or "utf-8") - else: - return text - - -def to_text(string): - # type: (str) -> Text - """ - Make a string Unicode if it isn't already. - - This is useful for defining raw unicode strings because `ur"foo"` isn't valid in - Python 3. - """ - if PY2: - return string.decode("utf-8") - else: - return string diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b85836a5..f9cdde3d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -10,7 +10,7 @@ from collections import OrderedDict from contextlib import contextmanager -from .compat import IS_TYPE_CHECKING, PY2, StringIO, to_env +from .compat import IS_TYPE_CHECKING from .parser import Binding, parse_stream from .variables import parse_variables @@ -24,11 +24,6 @@ else: _PathLike = Text - if sys.version_info >= (3, 0): - _StringIO = StringIO - else: - _StringIO = StringIO[Text] - def with_warn_for_invalid_lines(mappings): # type: (Iterator[Binding]) -> Iterator[Binding] @@ -44,8 +39,8 @@ def with_warn_for_invalid_lines(mappings): class DotEnv(): def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, override=True): - # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text], bool, bool) -> None - self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, _StringIO] + # type: (Union[Text, _PathLike, io.StringIO], bool, Union[None, Text], bool, bool) -> None + self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, io.StringIO] self._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, Text] @@ -55,7 +50,7 @@ def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, @contextmanager def _get_stream(self): # type: () -> Iterator[IO[Text]] - if isinstance(self.dotenv_path, StringIO): + if isinstance(self.dotenv_path, io.StringIO): yield self.dotenv_path elif os.path.isfile(self.dotenv_path): with io.open(self.dotenv_path, encoding=self.encoding) as stream: @@ -63,7 +58,7 @@ def _get_stream(self): else: if self.verbose: logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') - yield StringIO('') + yield io.StringIO('') def dict(self): # type: () -> Dict[Text, Optional[Text]] @@ -96,7 +91,7 @@ def set_as_environment_variables(self): if k in os.environ and not self.override: continue if v is not None: - os.environ[to_env(k)] = to_env(v) + os.environ[k] = v return True @@ -271,13 +266,7 @@ def _is_interactive(): else: # will work for .py files frame = sys._getframe() - # find first frame that is outside of this file - if PY2 and not __file__.endswith('.py'): - # in Python2 __file__ extension could be .pyc or .pyo (this doesn't account - # for edge case of Python compiled for non-standard extension) - current_file = __file__.rsplit('.', 1)[0] + '.py' - else: - current_file = __file__ + current_file = __file__ while frame.f_code.co_filename == current_file: assert frame.f_back is not None @@ -304,7 +293,7 @@ def load_dotenv( interpolate=True, encoding="utf-8", ): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa + # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. @@ -335,7 +324,7 @@ def dotenv_values( interpolate=True, encoding="utf-8", ): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 + # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 """ Parse a .env file and return its content as a dict. diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 5cb1cdfa..a0b80b23 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,7 +1,7 @@ import codecs import re -from .compat import IS_TYPE_CHECKING, to_text +from .compat import IS_TYPE_CHECKING if IS_TYPE_CHECKING: from typing import ( # noqa:F401 @@ -12,7 +12,7 @@ def make_regex(string, extra_flags=0): # type: (str, int) -> Pattern[Text] - return re.compile(to_text(string), re.UNICODE | extra_flags) + return re.compile(string, re.UNICODE | extra_flags) _newline = make_regex(r"(\r\n|\n|\r)") diff --git a/tests/test_main.py b/tests/test_main.py index f36f7340..f417e295 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import io import logging import os import sys @@ -11,7 +12,6 @@ import sh import dotenv -from dotenv.compat import PY2, StringIO def test_set_key_no_file(tmp_path): @@ -281,15 +281,12 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_utf_8(): - stream = StringIO("a=à") + stream = io.StringIO("a=à") result = dotenv.load_dotenv(stream=stream) assert result is True - if PY2: - assert os.environ == {"a": "à".encode(sys.getfilesystemencoding())} - else: - assert os.environ == {"a": "à"} + assert os.environ == {"a": "à"} def test_load_dotenv_in_current_dir(tmp_path): @@ -361,7 +358,7 @@ def test_dotenv_values_file(dotenv_file): ) def test_dotenv_values_stream(env, string, interpolate, expected): with mock.patch.dict(os.environ, env, clear=True): - stream = StringIO(string) + stream = io.StringIO(string) stream.seek(0) result = dotenv.dotenv_values(stream=stream, interpolate=interpolate) diff --git a/tests/test_parser.py b/tests/test_parser.py index 48cecdce..bdef9c41 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- +import io + import pytest -from dotenv.compat import StringIO from dotenv.parser import Binding, Original, parse_stream @@ -166,6 +167,6 @@ ), ]) def test_parse_stream(test_input, expected): - result = parse_stream(StringIO(test_input)) + result = parse_stream(io.StringIO(test_input)) assert list(result) == expected From 9292074d13fb1e84887e9b908e014948b21bfa46 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 11:56:33 +0200 Subject: [PATCH 40/49] Remove "coding: utf-8" source declarations Now that we only support Python 3, we know that the encoding of source files is UTF-8 by default: https://www.python.org/dev/peps/pep-3120/. --- setup.py | 1 - src/dotenv/main.py | 1 - tests/test_cli.py | 1 - tests/test_main.py | 1 - tests/test_parser.py | 1 - 5 files changed, 5 deletions(-) diff --git a/setup.py b/setup.py index 5e27d26d..06ad2dd9 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import io from setuptools import setup diff --git a/src/dotenv/main.py b/src/dotenv/main.py index f9cdde3d..9568238e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals import io diff --git a/tests/test_cli.py b/tests/test_cli.py index d2558234..223476fe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import pytest diff --git a/tests/test_main.py b/tests/test_main.py index f417e295..a7c093b1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from __future__ import unicode_literals import io diff --git a/tests/test_parser.py b/tests/test_parser.py index bdef9c41..b0621173 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import io import pytest From 4617e39663bbe600ec54a806d380d7a4ec31d98f Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 12:17:58 +0200 Subject: [PATCH 41/49] Remove unnecessary future imports --- src/dotenv/ipython.py | 2 -- src/dotenv/main.py | 2 -- tests/test_ipython.py | 2 -- tests/test_main.py | 2 -- 4 files changed, 8 deletions(-) diff --git a/src/dotenv/ipython.py b/src/dotenv/ipython.py index 7f1b13d6..7df727cd 100644 --- a/src/dotenv/ipython.py +++ b/src/dotenv/ipython.py @@ -1,5 +1,3 @@ -from __future__ import print_function - from IPython.core.magic import Magics, line_magic, magics_class # type: ignore from IPython.core.magic_arguments import (argument, magic_arguments, # type: ignore parse_argstring) # type: ignore diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 9568238e..0ebaf581 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, print_function, unicode_literals - import io import logging import os diff --git a/tests/test_ipython.py b/tests/test_ipython.py index afbf4797..8983bf13 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import os import mock diff --git a/tests/test_main.py b/tests/test_main.py index a7c093b1..d612bb25 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import io import logging import os From 1914bac52a32b02361f6a1b5bd07ee2c5826a83b Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 13:32:10 +0200 Subject: [PATCH 42/49] Remove typing guards Since we now require Python 3.5+, we can assume that the `typing` module is available. The guards can also be removed because importing that module is cheap. This simplifies the code. --- requirements.txt | 1 - src/dotenv/__init__.py | 7 ++--- src/dotenv/cli.py | 5 +-- src/dotenv/compat.py | 10 ------ src/dotenv/main.py | 14 ++++----- src/dotenv/parser.py | 68 ++++++++++++----------------------------- src/dotenv/variables.py | 7 +---- 7 files changed, 30 insertions(+), 82 deletions(-) delete mode 100644 src/dotenv/compat.py diff --git a/requirements.txt b/requirements.txt index e5e4de12..952bfdce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ bumpversion -typing; python_version<"3.5" click flake8>=2.2.3 ipython diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index b88d9bc2..1d7a4233 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,8 +1,7 @@ -from .compat import IS_TYPE_CHECKING -from .main import load_dotenv, get_key, set_key, unset_key, find_dotenv, dotenv_values +from typing import Any, Optional -if IS_TYPE_CHECKING: - from typing import Any, Optional +from .main import (dotenv_values, find_dotenv, get_key, load_dotenv, set_key, + unset_key) def load_ipython_extension(ipython): diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index d15ea53e..bd593a66 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,6 +1,7 @@ import os import sys from subprocess import Popen +from typing import Any, Dict, List try: import click @@ -9,13 +10,9 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) -from .compat import IS_TYPE_CHECKING from .main import dotenv_values, get_key, set_key, unset_key from .version import __version__ -if IS_TYPE_CHECKING: - from typing import Any, List, Dict - @click.group() @click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py deleted file mode 100644 index 27b48562..00000000 --- a/src/dotenv/compat.py +++ /dev/null @@ -1,10 +0,0 @@ -def is_type_checking(): - # type: () -> bool - try: - from typing import TYPE_CHECKING - except ImportError: - return False - return TYPE_CHECKING - - -IS_TYPE_CHECKING = is_type_checking() diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 0ebaf581..9e6cb437 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -6,20 +6,18 @@ import tempfile from collections import OrderedDict from contextlib import contextmanager +from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, + Tuple, Union) -from .compat import IS_TYPE_CHECKING from .parser import Binding, parse_stream from .variables import parse_variables logger = logging.getLogger(__name__) -if IS_TYPE_CHECKING: - from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, - Tuple, Union) - if sys.version_info >= (3, 6): - _PathLike = os.PathLike - else: - _PathLike = Text +if sys.version_info >= (3, 6): + _PathLike = os.PathLike +else: + _PathLike = Text def with_warn_for_invalid_lines(mappings): diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index a0b80b23..0d9b9d3f 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,13 +1,7 @@ import codecs import re - -from .compat import IS_TYPE_CHECKING - -if IS_TYPE_CHECKING: - from typing import ( # noqa:F401 - IO, Iterator, Match, NamedTuple, Optional, Pattern, Sequence, Text, - Tuple - ) +from typing import (IO, Iterator, Match, NamedTuple, Optional, # noqa:F401 + Pattern, Sequence, Text, Tuple) def make_regex(string, extra_flags=0): @@ -32,47 +26,23 @@ def make_regex(string, extra_flags=0): _single_quote_escapes = make_regex(r"\\[\\']") -try: - # this is necessary because we only import these from typing - # when we are type checking, and the linter is upset if we - # re-import - import typing - - Original = typing.NamedTuple( - "Original", - [ - ("string", typing.Text), - ("line", int), - ], - ) - - Binding = typing.NamedTuple( - "Binding", - [ - ("key", typing.Optional[typing.Text]), - ("value", typing.Optional[typing.Text]), - ("original", Original), - ("error", bool), - ], - ) -except (ImportError, AttributeError): - from collections import namedtuple - Original = namedtuple( # type: ignore - "Original", - [ - "string", - "line", - ], - ) - Binding = namedtuple( # type: ignore - "Binding", - [ - "key", - "value", - "original", - "error", - ], - ) +Original = NamedTuple( + "Original", + [ + ("string", Text), + ("line", int), + ], +) + +Binding = NamedTuple( + "Binding", + [ + ("key", Optional[Text]), + ("value", Optional[Text]), + ("original", Original), + ("error", bool), + ], +) class Position: diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index 4828dfc2..83fe11c1 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -1,11 +1,6 @@ import re from abc import ABCMeta - -from .compat import IS_TYPE_CHECKING - -if IS_TYPE_CHECKING: - from typing import Iterator, Mapping, Optional, Pattern, Text - +from typing import Iterator, Mapping, Optional, Pattern, Text _posix_variable = re.compile( r""" From 4590015cd5190e11d447e57538bc55a702a76c71 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 15:02:28 +0200 Subject: [PATCH 43/49] Use Python 3 type hints for functions Unfortunately, we can't do the same for variables as that is only supported in Python 3.6+. --- src/dotenv/__init__.py | 12 ++++-- src/dotenv/cli.py | 21 +++------ src/dotenv/main.py | 96 ++++++++++++++++++++++------------------- src/dotenv/parser.py | 57 ++++++++---------------- src/dotenv/variables.py | 39 ++++++----------- 5 files changed, 98 insertions(+), 127 deletions(-) diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 1d7a4233..3512d101 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -4,14 +4,18 @@ unset_key) -def load_ipython_extension(ipython): - # type: (Any) -> None +def load_ipython_extension(ipython: Any) -> None: from .ipython import load_ipython_extension load_ipython_extension(ipython) -def get_cli_string(path=None, action=None, key=None, value=None, quote=None): - # type: (Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> str +def get_cli_string( + path: Optional[str] = None, + action: Optional[str] = None, + key: Optional[str] = None, + value: Optional[str] = None, + quote: Optional[str] = None, +): """Returns a string suitable for running as a shell script. Useful for converting a arguments passed to a fabric task diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index bd593a66..b7ae24af 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -26,8 +26,7 @@ help="Whether to write the dot file as an executable bash script.") @click.version_option(version=__version__) @click.pass_context -def cli(ctx, file, quote, export): - # type: (click.Context, Any, Any, Any) -> None +def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: '''This script is used to set, get or unset values from a .env file.''' ctx.obj = {} ctx.obj['QUOTE'] = quote @@ -37,8 +36,7 @@ def cli(ctx, file, quote, export): @cli.command() @click.pass_context -def list(ctx): - # type: (click.Context) -> None +def list(ctx: click.Context) -> None: '''Display all the stored key/value.''' file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -55,8 +53,7 @@ def list(ctx): @click.pass_context @click.argument('key', required=True) @click.argument('value', required=True) -def set(ctx, key, value): - # type: (click.Context, Any, Any) -> None +def set(ctx: click.Context, key: Any, value: Any) -> None: '''Store the given key/value.''' file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] @@ -71,8 +68,7 @@ def set(ctx, key, value): @cli.command() @click.pass_context @click.argument('key', required=True) -def get(ctx, key): - # type: (click.Context, Any) -> None +def get(ctx: click.Context, key: Any) -> None: '''Retrieve the value for the given key.''' file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -90,8 +86,7 @@ def get(ctx, key): @cli.command() @click.pass_context @click.argument('key', required=True) -def unset(ctx, key): - # type: (click.Context, Any) -> None +def unset(ctx: click.Context, key: Any) -> None: '''Removes the given key.''' file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] @@ -110,8 +105,7 @@ def unset(ctx, key): help="Override variables from the environment file with those from the .env file.", ) @click.argument('commandline', nargs=-1, type=click.UNPROCESSED) -def run(ctx, override, commandline): - # type: (click.Context, bool, List[str]) -> None +def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: """Run command with environment variables present.""" file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -132,8 +126,7 @@ def run(ctx, override, commandline): exit(ret) -def run_command(command, env): - # type: (List[str], Dict[str, str]) -> int +def run_command(command: List[str], env: Dict[str, str]) -> int: """Run command in sub process. Runs the command in a sub process with the variables from `env` diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 9e6cb437..e4e140f3 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -20,8 +20,7 @@ _PathLike = Text -def with_warn_for_invalid_lines(mappings): - # type: (Iterator[Binding]) -> Iterator[Binding] +def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: for mapping in mappings: if mapping.error: logger.warning( @@ -32,9 +31,14 @@ def with_warn_for_invalid_lines(mappings): class DotEnv(): - - def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, override=True): - # type: (Union[Text, _PathLike, io.StringIO], bool, Union[None, Text], bool, bool) -> None + def __init__( + self, + dotenv_path: Union[Text, _PathLike, io.StringIO], + verbose: bool = False, + encoding: Union[None, Text] = None, + interpolate: bool = True, + override: bool = True, + ) -> None: self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, io.StringIO] self._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool @@ -43,8 +47,7 @@ def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, self.override = override # type: bool @contextmanager - def _get_stream(self): - # type: () -> Iterator[IO[Text]] + def _get_stream(self) -> Iterator[IO[Text]]: if isinstance(self.dotenv_path, io.StringIO): yield self.dotenv_path elif os.path.isfile(self.dotenv_path): @@ -55,8 +58,7 @@ def _get_stream(self): logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') yield io.StringIO('') - def dict(self): - # type: () -> Dict[Text, Optional[Text]] + def dict(self) -> Dict[Text, Optional[Text]]: """Return dotenv as dict""" if self._dict: return self._dict @@ -70,15 +72,13 @@ def dict(self): return self._dict - def parse(self): - # type: () -> Iterator[Tuple[Text, Optional[Text]]] + def parse(self) -> Iterator[Tuple[Text, Optional[Text]]]: with self._get_stream() as stream: for mapping in with_warn_for_invalid_lines(parse_stream(stream)): if mapping.key is not None: yield mapping.key, mapping.value - def set_as_environment_variables(self): - # type: () -> bool + def set_as_environment_variables(self) -> bool: """ Load the current dotenv as system environment variable. """ @@ -90,8 +90,7 @@ def set_as_environment_variables(self): return True - def get(self, key): - # type: (Text) -> Optional[Text] + def get(self, key: Text) -> Optional[Text]: """ """ data = self.dict() @@ -105,8 +104,7 @@ def get(self, key): return None -def get_key(dotenv_path, key_to_get): - # type: (Union[Text, _PathLike], Text) -> Optional[Text] +def get_key(dotenv_path: Union[Text, _PathLike], key_to_get: Text) -> Optional[Text]: """ Gets the value of a given key from the given .env @@ -116,8 +114,7 @@ def get_key(dotenv_path, key_to_get): @contextmanager -def rewrite(path): - # type: (_PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]] +def rewrite(path: _PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]]: try: if not os.path.isfile(path): with io.open(path, "w+") as source: @@ -133,8 +130,13 @@ def rewrite(path): shutil.move(dest.name, path) -def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=False): - # type: (_PathLike, Text, Text, Text, bool) -> Tuple[Optional[bool], Text, Text] +def set_key( + dotenv_path: _PathLike, + key_to_set: Text, + value_to_set: Text, + quote_mode: Text = "always", + export: bool = False, +) -> Tuple[Optional[bool], Text, Text]: """ Adds or Updates a key/value to the given .env @@ -172,8 +174,11 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=F return True, key_to_set, value_to_set -def unset_key(dotenv_path, key_to_unset, quote_mode="always"): - # type: (_PathLike, Text, Text) -> Tuple[Optional[bool], Text] +def unset_key( + dotenv_path: _PathLike, + key_to_unset: Text, + quote_mode: Text = "always", +) -> Tuple[Optional[bool], Text]: """ Removes a given key from the given .env @@ -199,9 +204,10 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): return removed, key_to_unset -def resolve_variables(values, override): - # type: (Iterable[Tuple[Text, Optional[Text]]], bool) -> Mapping[Text, Optional[Text]] - +def resolve_variables( + values: Iterable[Tuple[Text, Optional[Text]]], + override: bool, +) -> Mapping[Text, Optional[Text]]: new_values = {} # type: Dict[Text, Optional[Text]] for (name, value) in values: @@ -223,8 +229,7 @@ def resolve_variables(values, override): return new_values -def _walk_to_root(path): - # type: (Text) -> Iterator[Text] +def _walk_to_root(path: Text) -> Iterator[Text]: """ Yield directories starting from the given directory up to the root """ @@ -242,8 +247,11 @@ def _walk_to_root(path): last_dir, current_dir = current_dir, parent_dir -def find_dotenv(filename='.env', raise_error_if_not_found=False, usecwd=False): - # type: (Text, bool, bool) -> Text +def find_dotenv( + filename: Text = '.env', + raise_error_if_not_found: bool = False, + usecwd: bool = False, +) -> Text: """ Search in increasingly higher folders for the given file @@ -281,14 +289,13 @@ def _is_interactive(): def load_dotenv( - dotenv_path=None, - stream=None, - verbose=False, - override=False, - interpolate=True, - encoding="utf-8", -): - # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa + dotenv_path: Union[Text, _PathLike, None] = None, + stream: Optional[io.StringIO] = None, + verbose: bool = False, + override: bool = False, + interpolate: bool = True, + encoding: Optional[Text] = "utf-8", +) -> bool: """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. @@ -313,13 +320,12 @@ def load_dotenv( def dotenv_values( - dotenv_path=None, - stream=None, - verbose=False, - interpolate=True, - encoding="utf-8", -): - # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 + dotenv_path: Union[Text, _PathLike, None] = None, + stream: Optional[io.StringIO] = None, + verbose: bool = False, + interpolate: bool = True, + encoding: Optional[Text] = "utf-8", +) -> Dict[Text, Optional[Text]]: """ Parse a .env file and return its content as a dict. diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 0d9b9d3f..8a976c51 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -4,8 +4,7 @@ Pattern, Sequence, Text, Tuple) -def make_regex(string, extra_flags=0): - # type: (str, int) -> Pattern[Text] +def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: return re.compile(string, re.UNICODE | extra_flags) @@ -46,23 +45,19 @@ def make_regex(string, extra_flags=0): class Position: - def __init__(self, chars, line): - # type: (int, int) -> None + def __init__(self, chars: int, line: int) -> None: self.chars = chars self.line = line @classmethod - def start(cls): - # type: () -> Position + def start(cls) -> "Position": return cls(chars=0, line=1) - def set(self, other): - # type: (Position) -> None + def set(self, other: "Position") -> None: self.chars = other.chars self.line = other.line - def advance(self, string): - # type: (Text) -> None + def advance(self, string: Text) -> None: self.chars += len(string) self.line += len(re.findall(_newline, string)) @@ -72,41 +67,34 @@ class Error(Exception): class Reader: - def __init__(self, stream): - # type: (IO[Text]) -> None + def __init__(self, stream: IO[Text]) -> None: self.string = stream.read() self.position = Position.start() self.mark = Position.start() - def has_next(self): - # type: () -> bool + def has_next(self) -> bool: return self.position.chars < len(self.string) - def set_mark(self): - # type: () -> None + def set_mark(self) -> None: self.mark.set(self.position) - def get_marked(self): - # type: () -> Original + def get_marked(self) -> Original: return Original( string=self.string[self.mark.chars:self.position.chars], line=self.mark.line, ) - def peek(self, count): - # type: (int) -> Text + def peek(self, count: int) -> Text: return self.string[self.position.chars:self.position.chars + count] - def read(self, count): - # type: (int) -> Text + def read(self, count: int) -> Text: result = self.string[self.position.chars:self.position.chars + count] if len(result) < count: raise Error("read: End of string") self.position.advance(result) return result - def read_regex(self, regex): - # type: (Pattern[Text]) -> Sequence[Text] + def read_regex(self, regex: Pattern[Text]) -> Sequence[Text]: match = regex.match(self.string, self.position.chars) if match is None: raise Error("read_regex: Pattern not found") @@ -114,17 +102,14 @@ def read_regex(self, regex): return match.groups() -def decode_escapes(regex, string): - # type: (Pattern[Text], Text) -> Text - def decode_match(match): - # type: (Match[Text]) -> Text +def decode_escapes(regex: Pattern[Text], string: Text) -> Text: + def decode_match(match: Match[Text]) -> Text: return codecs.decode(match.group(0), 'unicode-escape') # type: ignore return regex.sub(decode_match, string) -def parse_key(reader): - # type: (Reader) -> Optional[Text] +def parse_key(reader: Reader) -> Optional[Text]: char = reader.peek(1) if char == "#": return None @@ -135,14 +120,12 @@ def parse_key(reader): return key -def parse_unquoted_value(reader): - # type: (Reader) -> Text +def parse_unquoted_value(reader: Reader) -> Text: (part,) = reader.read_regex(_unquoted_value) return re.sub(r"\s+#.*", "", part).rstrip() -def parse_value(reader): - # type: (Reader) -> Text +def parse_value(reader: Reader) -> Text: char = reader.peek(1) if char == u"'": (value,) = reader.read_regex(_single_quoted_value) @@ -156,8 +139,7 @@ def parse_value(reader): return parse_unquoted_value(reader) -def parse_binding(reader): - # type: (Reader) -> Binding +def parse_binding(reader: Reader) -> Binding: reader.set_mark() try: reader.read_regex(_multiline_whitespace) @@ -194,8 +176,7 @@ def parse_binding(reader): ) -def parse_stream(stream): - # type: (IO[Text]) -> Iterator[Binding] +def parse_stream(stream: IO[Text]) -> Iterator[Binding]: reader = Reader(stream) while reader.has_next(): yield parse_binding(reader) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index 83fe11c1..bddd07e1 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -18,71 +18,58 @@ class Atom(): __metaclass__ = ABCMeta - def __ne__(self, other): - # type: (object) -> bool + def __ne__(self, other: object) -> bool: result = self.__eq__(other) if result is NotImplemented: return NotImplemented return not result - def resolve(self, env): - # type: (Mapping[Text, Optional[Text]]) -> Text + def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: raise NotImplementedError class Literal(Atom): - def __init__(self, value): - # type: (Text) -> None + def __init__(self, value: Text) -> None: self.value = value - def __repr__(self): - # type: () -> str + def __repr__(self) -> str: return "Literal(value={})".format(self.value) - def __eq__(self, other): - # type: (object) -> bool + def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): return NotImplemented return self.value == other.value - def __hash__(self): - # type: () -> int + def __hash__(self) -> int: return hash((self.__class__, self.value)) - def resolve(self, env): - # type: (Mapping[Text, Optional[Text]]) -> Text + def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: return self.value class Variable(Atom): - def __init__(self, name, default): - # type: (Text, Optional[Text]) -> None + def __init__(self, name: Text, default: Optional[Text]) -> None: self.name = name self.default = default - def __repr__(self): - # type: () -> str + def __repr__(self) -> str: return "Variable(name={}, default={})".format(self.name, self.default) - def __eq__(self, other): - # type: (object) -> bool + def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): return NotImplemented return (self.name, self.default) == (other.name, other.default) - def __hash__(self): - # type: () -> int + def __hash__(self) -> int: return hash((self.__class__, self.name, self.default)) - def resolve(self, env): - # type: (Mapping[Text, Optional[Text]]) -> Text + def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: default = self.default if self.default is not None else "" result = env.get(self.name, default) return result if result is not None else "" -def parse_variables(value): - # type: (Text) -> Iterator[Atom] +def parse_variables(value: Text) -> Iterator[Atom]: cursor = 0 for match in _posix_variable.finditer(value): From b8fdfba09957c6c4872f98c721b185a4ba1711ec Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 15:27:04 +0200 Subject: [PATCH 44/49] Replace `Text` with `str` Those are synonyms in Python 3. --- src/dotenv/main.py | 66 ++++++++++++++++++++--------------------- src/dotenv/parser.py | 34 ++++++++++----------- src/dotenv/variables.py | 16 +++++----- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index e4e140f3..6b29fc90 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -6,8 +6,8 @@ import tempfile from collections import OrderedDict from contextlib import contextmanager -from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, - Tuple, Union) +from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, + Union) from .parser import Binding, parse_stream from .variables import parse_variables @@ -17,7 +17,7 @@ if sys.version_info >= (3, 6): _PathLike = os.PathLike else: - _PathLike = Text + _PathLike = str def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: @@ -33,21 +33,21 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding class DotEnv(): def __init__( self, - dotenv_path: Union[Text, _PathLike, io.StringIO], + dotenv_path: Union[str, _PathLike, io.StringIO], verbose: bool = False, - encoding: Union[None, Text] = None, + encoding: Union[None, str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, io.StringIO] - self._dict = None # type: Optional[Dict[Text, Optional[Text]]] + self.dotenv_path = dotenv_path # type: Union[str,_PathLike, io.StringIO] + self._dict = None # type: Optional[Dict[str, Optional[str]]] self.verbose = verbose # type: bool - self.encoding = encoding # type: Union[None, Text] + self.encoding = encoding # type: Union[None, str] self.interpolate = interpolate # type: bool self.override = override # type: bool @contextmanager - def _get_stream(self) -> Iterator[IO[Text]]: + def _get_stream(self) -> Iterator[IO[str]]: if isinstance(self.dotenv_path, io.StringIO): yield self.dotenv_path elif os.path.isfile(self.dotenv_path): @@ -58,7 +58,7 @@ def _get_stream(self) -> Iterator[IO[Text]]: logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') yield io.StringIO('') - def dict(self) -> Dict[Text, Optional[Text]]: + def dict(self) -> Dict[str, Optional[str]]: """Return dotenv as dict""" if self._dict: return self._dict @@ -72,7 +72,7 @@ def dict(self) -> Dict[Text, Optional[Text]]: return self._dict - def parse(self) -> Iterator[Tuple[Text, Optional[Text]]]: + def parse(self) -> Iterator[Tuple[str, Optional[str]]]: with self._get_stream() as stream: for mapping in with_warn_for_invalid_lines(parse_stream(stream)): if mapping.key is not None: @@ -90,7 +90,7 @@ def set_as_environment_variables(self) -> bool: return True - def get(self, key: Text) -> Optional[Text]: + def get(self, key: str) -> Optional[str]: """ """ data = self.dict() @@ -104,7 +104,7 @@ def get(self, key: Text) -> Optional[Text]: return None -def get_key(dotenv_path: Union[Text, _PathLike], key_to_get: Text) -> Optional[Text]: +def get_key(dotenv_path: Union[str, _PathLike], key_to_get: str) -> Optional[str]: """ Gets the value of a given key from the given .env @@ -114,7 +114,7 @@ def get_key(dotenv_path: Union[Text, _PathLike], key_to_get: Text) -> Optional[T @contextmanager -def rewrite(path: _PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]]: +def rewrite(path: _PathLike) -> Iterator[Tuple[IO[str], IO[str]]]: try: if not os.path.isfile(path): with io.open(path, "w+") as source: @@ -132,11 +132,11 @@ def rewrite(path: _PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]]: def set_key( dotenv_path: _PathLike, - key_to_set: Text, - value_to_set: Text, - quote_mode: Text = "always", + key_to_set: str, + value_to_set: str, + quote_mode: str = "always", export: bool = False, -) -> Tuple[Optional[bool], Text, Text]: +) -> Tuple[Optional[bool], str, str]: """ Adds or Updates a key/value to the given .env @@ -176,9 +176,9 @@ def set_key( def unset_key( dotenv_path: _PathLike, - key_to_unset: Text, - quote_mode: Text = "always", -) -> Tuple[Optional[bool], Text]: + key_to_unset: str, + quote_mode: str = "always", +) -> Tuple[Optional[bool], str]: """ Removes a given key from the given .env @@ -205,17 +205,17 @@ def unset_key( def resolve_variables( - values: Iterable[Tuple[Text, Optional[Text]]], + values: Iterable[Tuple[str, Optional[str]]], override: bool, -) -> Mapping[Text, Optional[Text]]: - new_values = {} # type: Dict[Text, Optional[Text]] +) -> Mapping[str, Optional[str]]: + new_values = {} # type: Dict[str, Optional[str]] for (name, value) in values: if value is None: result = None else: atoms = parse_variables(value) - env = {} # type: Dict[Text, Optional[Text]] + env = {} # type: Dict[str, Optional[str]] if override: env.update(os.environ) # type: ignore env.update(new_values) @@ -229,7 +229,7 @@ def resolve_variables( return new_values -def _walk_to_root(path: Text) -> Iterator[Text]: +def _walk_to_root(path: str) -> Iterator[str]: """ Yield directories starting from the given directory up to the root """ @@ -248,10 +248,10 @@ def _walk_to_root(path: Text) -> Iterator[Text]: def find_dotenv( - filename: Text = '.env', + filename: str = '.env', raise_error_if_not_found: bool = False, usecwd: bool = False, -) -> Text: +) -> str: """ Search in increasingly higher folders for the given file @@ -289,12 +289,12 @@ def _is_interactive(): def load_dotenv( - dotenv_path: Union[Text, _PathLike, None] = None, + dotenv_path: Union[str, _PathLike, None] = None, stream: Optional[io.StringIO] = None, verbose: bool = False, override: bool = False, interpolate: bool = True, - encoding: Optional[Text] = "utf-8", + encoding: Optional[str] = "utf-8", ) -> bool: """Parse a .env file and then load all the variables found as environment variables. @@ -320,12 +320,12 @@ def load_dotenv( def dotenv_values( - dotenv_path: Union[Text, _PathLike, None] = None, + dotenv_path: Union[str, _PathLike, None] = None, stream: Optional[io.StringIO] = None, verbose: bool = False, interpolate: bool = True, - encoding: Optional[Text] = "utf-8", -) -> Dict[Text, Optional[Text]]: + encoding: Optional[str] = "utf-8", +) -> Dict[str, Optional[str]]: """ Parse a .env file and return its content as a dict. diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 8a976c51..398bd49a 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,10 +1,10 @@ import codecs import re from typing import (IO, Iterator, Match, NamedTuple, Optional, # noqa:F401 - Pattern, Sequence, Text, Tuple) + Pattern, Sequence, Tuple) -def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: +def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: return re.compile(string, re.UNICODE | extra_flags) @@ -28,7 +28,7 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: Original = NamedTuple( "Original", [ - ("string", Text), + ("string", str), ("line", int), ], ) @@ -36,8 +36,8 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: Binding = NamedTuple( "Binding", [ - ("key", Optional[Text]), - ("value", Optional[Text]), + ("key", Optional[str]), + ("value", Optional[str]), ("original", Original), ("error", bool), ], @@ -57,7 +57,7 @@ def set(self, other: "Position") -> None: self.chars = other.chars self.line = other.line - def advance(self, string: Text) -> None: + def advance(self, string: str) -> None: self.chars += len(string) self.line += len(re.findall(_newline, string)) @@ -67,7 +67,7 @@ class Error(Exception): class Reader: - def __init__(self, stream: IO[Text]) -> None: + def __init__(self, stream: IO[str]) -> None: self.string = stream.read() self.position = Position.start() self.mark = Position.start() @@ -84,17 +84,17 @@ def get_marked(self) -> Original: line=self.mark.line, ) - def peek(self, count: int) -> Text: + def peek(self, count: int) -> str: return self.string[self.position.chars:self.position.chars + count] - def read(self, count: int) -> Text: + def read(self, count: int) -> str: result = self.string[self.position.chars:self.position.chars + count] if len(result) < count: raise Error("read: End of string") self.position.advance(result) return result - def read_regex(self, regex: Pattern[Text]) -> Sequence[Text]: + def read_regex(self, regex: Pattern[str]) -> Sequence[str]: match = regex.match(self.string, self.position.chars) if match is None: raise Error("read_regex: Pattern not found") @@ -102,14 +102,14 @@ def read_regex(self, regex: Pattern[Text]) -> Sequence[Text]: return match.groups() -def decode_escapes(regex: Pattern[Text], string: Text) -> Text: - def decode_match(match: Match[Text]) -> Text: +def decode_escapes(regex: Pattern[str], string: str) -> str: + def decode_match(match: Match[str]) -> str: return codecs.decode(match.group(0), 'unicode-escape') # type: ignore return regex.sub(decode_match, string) -def parse_key(reader: Reader) -> Optional[Text]: +def parse_key(reader: Reader) -> Optional[str]: char = reader.peek(1) if char == "#": return None @@ -120,12 +120,12 @@ def parse_key(reader: Reader) -> Optional[Text]: return key -def parse_unquoted_value(reader: Reader) -> Text: +def parse_unquoted_value(reader: Reader) -> str: (part,) = reader.read_regex(_unquoted_value) return re.sub(r"\s+#.*", "", part).rstrip() -def parse_value(reader: Reader) -> Text: +def parse_value(reader: Reader) -> str: char = reader.peek(1) if char == u"'": (value,) = reader.read_regex(_single_quoted_value) @@ -155,7 +155,7 @@ def parse_binding(reader: Reader) -> Binding: reader.read_regex(_whitespace) if reader.peek(1) == "=": reader.read_regex(_equal_sign) - value = parse_value(reader) # type: Optional[Text] + value = parse_value(reader) # type: Optional[str] else: value = None reader.read_regex(_comment) @@ -176,7 +176,7 @@ def parse_binding(reader: Reader) -> Binding: ) -def parse_stream(stream: IO[Text]) -> Iterator[Binding]: +def parse_stream(stream: IO[str]) -> Iterator[Binding]: reader = Reader(stream) while reader.has_next(): yield parse_binding(reader) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index bddd07e1..d77b700c 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -1,6 +1,6 @@ import re from abc import ABCMeta -from typing import Iterator, Mapping, Optional, Pattern, Text +from typing import Iterator, Mapping, Optional, Pattern _posix_variable = re.compile( r""" @@ -12,7 +12,7 @@ \} """, re.VERBOSE, -) # type: Pattern[Text] +) # type: Pattern[str] class Atom(): @@ -24,12 +24,12 @@ def __ne__(self, other: object) -> bool: return NotImplemented return not result - def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: + def resolve(self, env: Mapping[str, Optional[str]]) -> str: raise NotImplementedError class Literal(Atom): - def __init__(self, value: Text) -> None: + def __init__(self, value: str) -> None: self.value = value def __repr__(self) -> str: @@ -43,12 +43,12 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.__class__, self.value)) - def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: + def resolve(self, env: Mapping[str, Optional[str]]) -> str: return self.value class Variable(Atom): - def __init__(self, name: Text, default: Optional[Text]) -> None: + def __init__(self, name: str, default: Optional[str]) -> None: self.name = name self.default = default @@ -63,13 +63,13 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.__class__, self.name, self.default)) - def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: + def resolve(self, env: Mapping[str, Optional[str]]) -> str: default = self.default if self.default is not None else "" result = env.get(self.name, default) return result if result is not None else "" -def parse_variables(value: Text) -> Iterator[Atom]: +def parse_variables(value: str) -> Iterator[Atom]: cursor = 0 for match in _posix_variable.finditer(value): From 28dbb23b3fc05596877b36b0ea2761af14c4e706 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:23:53 +0200 Subject: [PATCH 45/49] Fix documentation of `dotenv set` --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 045da075..9b56b546 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,8 @@ without manually opening it. ```shell $ pip install "python-dotenv[cli]" -$ dotenv set USER=foo -$ dotenv set EMAIL=foo@example.org +$ dotenv set USER foo +$ dotenv set EMAIL foo@example.org $ dotenv list USER=foo EMAIL=foo@example.org From f5d0c546249321066d2e6a4a81acbbba06c998bf Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:49:01 +0200 Subject: [PATCH 46/49] Enable the use of Mypy 0.900+ Mypy would complain about the missing `types-mock` package, which it now needs to perform accurate type checking and despite `ignore_missing_imports` set to `True`: tests/test_ipython.py:3: error: Library stubs not installed for "mock" (or incompatible with Python 3.9) tests/test_ipython.py:3: note: Hint: "python3 -m pip install types-mock" tests/test_ipython.py:3: note: (or run "mypy --install-types" to install all missing stub packages) tests/test_ipython.py:3: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports tests/test_main.py:7: error: Library stubs not installed for "mock" (or incompatible with Python 3.9) Found 2 errors in 2 files (checked 15 source files) --- requirements.txt | 1 + tox.ini | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 952bfdce..39302b21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ pytest-cov pytest>=3.9 sh>=1.09 tox +types-mock wheel twine portray diff --git a/tox.ini b/tox.ini index 7c2b4f9d..2cd63024 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,8 @@ commands = coverage run --parallel -m pytest {posargs} skip_install = true deps = flake8 - mypy<0.900 + mypy + types-mock commands = flake8 src tests mypy --python-version=3.9 src tests From 955e2a4ea6391a322c779e737f5a7aca7eaa963d Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:54:39 +0200 Subject: [PATCH 47/49] Enable checking of "untyped defs" and fix types `set_key` and `unset_key` were more restrictive than other functions such as `dotenv_values` with regards to their `dotenv_path` argument. --- CHANGELOG.md | 5 +++++ setup.cfg | 1 + src/dotenv/main.py | 6 +++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4340c1..f52cf07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, + os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). + ### Changed - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 diff --git a/setup.cfg b/setup.cfg index 9afbc4b3..2723d8a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,6 +14,7 @@ max-line-length = 120 exclude = .tox,.git,docs,venv,.venv [mypy] +check_untyped_defs = true ignore_missing_imports = true [metadata] diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 6b29fc90..d550f6f8 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -114,7 +114,7 @@ def get_key(dotenv_path: Union[str, _PathLike], key_to_get: str) -> Optional[str @contextmanager -def rewrite(path: _PathLike) -> Iterator[Tuple[IO[str], IO[str]]]: +def rewrite(path: Union[str, _PathLike]) -> Iterator[Tuple[IO[str], IO[str]]]: try: if not os.path.isfile(path): with io.open(path, "w+") as source: @@ -131,7 +131,7 @@ def rewrite(path: _PathLike) -> Iterator[Tuple[IO[str], IO[str]]]: def set_key( - dotenv_path: _PathLike, + dotenv_path: Union[str, _PathLike], key_to_set: str, value_to_set: str, quote_mode: str = "always", @@ -175,7 +175,7 @@ def set_key( def unset_key( - dotenv_path: _PathLike, + dotenv_path: Union[str, _PathLike], key_to_unset: str, quote_mode: str = "always", ) -> Tuple[Optional[bool], str]: From 134ed435c9a0d2a8eebc9e72e1157b3c6f022e33 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:47:42 +0200 Subject: [PATCH 48/49] Allow any text stream (`IO[str]`) as `stream` This applies to the `load_dotenv` and `dotenv_values` functions. This makes it possible to pass a file stream such as `open("foo", "r")` to these functions. --- CHANGELOG.md | 13 ++++++++----- src/dotenv/main.py | 38 +++++++++++++++++++++++++------------- tests/test_main.py | 26 ++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f52cf07e..cea20534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,19 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Added - -- The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, - os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). - ### Changed - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). +### Added + +- The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, + os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). +- The `stream` argument of `load_dotenv` and `dotenv_values` can now be a text stream + (`IO[str]`), which includes values like `io.StringIO("foo")` and `open("file.env", + "r")` (#348 by [@bbc2]). + ## [0.18.0] - 2021-06-20 ### Changed diff --git a/src/dotenv/main.py b/src/dotenv/main.py index d550f6f8..b8d0a4e0 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -33,13 +33,15 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding class DotEnv(): def __init__( self, - dotenv_path: Union[str, _PathLike, io.StringIO], + dotenv_path: Optional[Union[str, _PathLike]], + stream: Optional[IO[str]] = None, verbose: bool = False, encoding: Union[None, str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Union[str,_PathLike, io.StringIO] + self.dotenv_path = dotenv_path # type: Optional[Union[str, _PathLike]] + self.stream = stream # type: Optional[IO[str]] self._dict = None # type: Optional[Dict[str, Optional[str]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, str] @@ -48,14 +50,17 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: - if isinstance(self.dotenv_path, io.StringIO): - yield self.dotenv_path - elif os.path.isfile(self.dotenv_path): + if self.dotenv_path and os.path.isfile(self.dotenv_path): with io.open(self.dotenv_path, encoding=self.encoding) as stream: yield stream + elif self.stream is not None: + yield self.stream else: if self.verbose: - logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') + logger.info( + "Python-dotenv could not find configuration file %s.", + self.dotenv_path or '.env', + ) yield io.StringIO('') def dict(self) -> Dict[str, Optional[str]]: @@ -290,7 +295,7 @@ def _is_interactive(): def load_dotenv( dotenv_path: Union[str, _PathLike, None] = None, - stream: Optional[io.StringIO] = None, + stream: Optional[IO[str]] = None, verbose: bool = False, override: bool = False, interpolate: bool = True, @@ -299,7 +304,8 @@ def load_dotenv( """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. - - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. + - *stream*: Text stream (such as `io.StringIO`) with .env content, used if + `dotenv_path` is `None`. - *verbose*: whether to output a warning the .env file is missing. Defaults to `False`. - *override*: whether to override the system environment variables with the variables @@ -308,9 +314,12 @@ def load_dotenv( If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. """ - f = dotenv_path or stream or find_dotenv() + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + dotenv = DotEnv( - f, + dotenv_path=dotenv_path, + stream=stream, verbose=verbose, interpolate=interpolate, override=override, @@ -321,7 +330,7 @@ def load_dotenv( def dotenv_values( dotenv_path: Union[str, _PathLike, None] = None, - stream: Optional[io.StringIO] = None, + stream: Optional[IO[str]] = None, verbose: bool = False, interpolate: bool = True, encoding: Optional[str] = "utf-8", @@ -338,9 +347,12 @@ def dotenv_values( If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. """ - f = dotenv_path or stream or find_dotenv() + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + return DotEnv( - f, + dotenv_path=dotenv_path, + stream=stream, verbose=verbose, interpolate=interpolate, override=True, diff --git a/tests/test_main.py b/tests/test_main.py index d612bb25..13e2791c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -277,7 +277,7 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): @mock.patch.dict(os.environ, {}, clear=True) -def test_load_dotenv_utf_8(): +def test_load_dotenv_string_io_utf_8(): stream = io.StringIO("a=à") result = dotenv.load_dotenv(stream=stream) @@ -286,6 +286,18 @@ def test_load_dotenv_utf_8(): assert os.environ == {"a": "à"} +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_file_stream(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + with open(dotenv_file, "r") as f: + result = dotenv.load_dotenv(stream=f) + + assert result is True + assert os.environ == {"a": "b"} + + def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / '.env' dotenv_path.write_bytes(b'a=b') @@ -353,7 +365,7 @@ def test_dotenv_values_file(dotenv_file): ({}, "a=b\nc=${a}\nd=e\nc=${d}", True, {"a": "b", "c": "e", "d": "e"}), ], ) -def test_dotenv_values_stream(env, string, interpolate, expected): +def test_dotenv_values_string_io(env, string, interpolate, expected): with mock.patch.dict(os.environ, env, clear=True): stream = io.StringIO(string) stream.seek(0) @@ -361,3 +373,13 @@ def test_dotenv_values_stream(env, string, interpolate, expected): result = dotenv.dotenv_values(stream=stream, interpolate=interpolate) assert result == expected + + +def test_dotenv_values_file_stream(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + with open(dotenv_file, "r") as f: + result = dotenv.dotenv_values(stream=f) + + assert result == {"a": "b"} From b043829d810b4bf46ebb4addcf0e8ca97dff3bdd Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 24 Jul 2021 17:57:38 +0200 Subject: [PATCH 49/49] Release version 0.19.0 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cea20534..5da48f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.19.0] - 2021-07-24 ### Changed @@ -285,7 +285,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...HEAD +[0.19.0]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...v0.18.0 [0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 diff --git a/setup.cfg b/setup.cfg index 2723d8a2..a20d2498 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.18.0 +current_version = 0.19.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 1317d755..11ac8e1a 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.18.0" +__version__ = "0.19.0"