diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7c6d504..49e1399f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,21 +5,26 @@ on: [push, pull_request] jobs: test: runs-on: ${{ matrix.os }} + strategy: max-parallel: 8 matrix: os: - ubuntu-latest - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11.0-beta.4 - 3.11", pypy3.9] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", pypy3.9] + steps: - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 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/CHANGELOG.md b/CHANGELOG.md index e53060a5..2f78c0d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,29 +5,56 @@ 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). +## [1.0.0] + +**Fixed** + +* Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) +* Handle situations where the cwd does not exist. (#446 by [@jctanner]) + +## [0.21.1] - 2022-01-21 + +**Added** + +* Use Python 3.11 non-beta in CI (#438 by [@bbc2]) +* Modernize variables code (#434 by [@Nougat-Waffle]) +* Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) +* Improve conciseness of cli.py and __init__.py (#439 by [@Nougat-Waffle]) +* Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) +* Updated License to align with BSD OSI template (#433 by [@lsmith77]) + + +**Fixed** + +* Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) +* Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) +* Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) + ## [0.21.0] - 2022-09-03 -### Added -* CLI: add support for invocations via 'python -m'. (#395 by @theskumar) -* `load_dotenv` function now returns `False`. (#388 by @larsks) -* CLI: add --format= option to list command. (#407 by @sammck) +**Added** + +* CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) +* `load_dotenv` function now returns `False`. (#388 by [@larsks]) +* CLI: add --format= option to list command. (#407 by [@sammck]) + +**Fixed** -### Fixed -* Drop Python 3.5 and 3.6 and upgrade GA (#393 by @eggplants) -* Use `open` instead of `io.open`. (#389 by @rabinadk1) -* Improve documentation for variables without a value (#390 by @bbc2) -* Add `parse_it` to Related Projects by (#410 by @naorlivne) -* Update README.md by (#415 by @harveer07) -* Improve documentation with direct use of MkDocs by (#398 by @bbc2) +* Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) +* Use `open` instead of `io.open`. (#389 by [@rabinadk1]) +* Improve documentation for variables without a value (#390 by [@bbc2]) +* Add `parse_it` to Related Projects (#410 by [@naorlivne]) +* Update README.md (#415 by [@harveer07]) +* Improve documentation with direct use of MkDocs (#398 by [@bbc2]) ## [0.20.0] - 2022-03-24 -### Added +**Added** - Add `encoding` (`Optional[str]`) parameter to `get_key`, `set_key` and `unset_key`. (#379 by [@bbc2]) -### Fixed +**Fixed** - Use dict to specify the `entry_points` parameter of `setuptools.setup` (#376 by [@mgorny]). @@ -35,25 +62,25 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.19.2] - 2021-11-11 -### Fixed +**Fixed** - In `set_key`, add missing newline character before new entry if necessary. (#361 by [@bbc2]) ## [0.19.1] - 2021-08-09 -### Added +**Added** - Add support for Python 3.10. (#359 by [@theskumar]) ## [0.19.0] - 2021-07-24 -### Changed +**Changed** - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). -### Added +**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]). @@ -63,7 +90,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.18.0] - 2021-06-20 -### Changed +**Changed** - Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in `set_key` (#330 by [@bbc2]). @@ -76,23 +103,23 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.17.1] - 2021-04-29 -### Fixed +**Fixed** - Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). ## [0.17.0] - 2021-04-02 -### Changed +**Changed** - Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). -### Added +**Added** - Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). ## [0.16.0] - 2021-03-27 -### Changed +**Changed** - The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is now `"utf-8"` instead of `None` (#306 by [@bbc2]). @@ -100,17 +127,17 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.15.0] - 2020-10-28 -### Added +**Added** - Add `--export` option to `set` to make it prepend the binding with `export` (#270 by [@jadutter]). -### Changed +**Changed** - Make `set` command create the `.env` file in the current directory if no `.env` file was found (#270 by [@jadutter]). -### Fixed +**Fixed** - 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]). @@ -119,30 +146,30 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.14.0] - 2020-07-03 -### Changed +**Changed** - Privilege definition in file over the environment in variable expansion (#256 by [@elbehery95]). -### Fixed +**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 +**Added** - Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). ## [0.12.0] - 2020-02-28 -### Changed +**Changed** - Use current working directory to find `.env` when bundled by PyInstaller (#213 by [@gergelyk]). -### Fixed +**Fixed** - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). - Fix `dotenv run` crashing on environment variables without values (#237 by [@yannham]). @@ -150,23 +177,23 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.11.0] - 2020-02-07 -### Added +**Added** - Add `interpolate` argument to `load_dotenv` and `dotenv_values` to disable interpolation (#232 by [@ulyssessouza]). -### Changed +**Changed** - Use logging instead of warnings (#231 by [@bbc2]). -### Fixed +**Fixed** - Fix installation in non-UTF-8 environments (#225 by [@altendky]). - Fix PyPI classifiers (#228 by [@bbc2]). ## [0.10.5] - 2020-01-19 -### Fixed +**Fixed** - Fix handling of malformed lines and lines without a value (#222 by [@bbc2]): - Don't print warning when key has no value. @@ -175,7 +202,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.10.4] - 2020-01-17 -### Added +**Added** - Make typing optional (#179 by [@techalchemy]). - Print a warning on malformed line (#211 by [@bbc2]). @@ -309,6 +336,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@bbc2]: https://github.com/bbc2 [@befeleme]: https://github.com/befeleme [@cjauvin]: https://github.com/cjauvin +[@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread [@eggplants]: https://github.com/@eggplants [@ekohl]: https://github.com/ekohl @@ -319,14 +347,18 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@greyli]: https://github.com/greyli [@harveer07]: https://github.com/@harveer07 [@jadutter]: https://github.com/jadutter +[@jctanner]: https://github.com/jctanner [@larsks]: https://github.com/@larsks +[@lsmith77]: https://github.com/lsmith77 [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/@naorlivne +[@Nougat-Waffle]: https://github.com/Nougat-Waffle [@qnighy]: https://github.com/qnighy [@rabinadk1]: https://github.com/@rabinadk1 [@sammck]: https://github.com/@sammck [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy +[@theGOTOguy]: https://github.com/theGOTOguy [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur @@ -335,7 +367,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 +[0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 [0.21.0]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...v0.21.0 [0.20.0]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 [0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...v0.19.2 diff --git a/LICENSE b/LICENSE index 39372fee..3a971190 100644 --- a/LICENSE +++ b/LICENSE @@ -1,78 +1,18 @@ -python-dotenv -Copyright (c) 2014, Saurabh Kumar - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of python-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -django-dotenv-rw -Copyright (c) 2013, Ted Tieken - -All rights reserved. +Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss (django-dotenv) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Original django-dotenv -Copyright (c) 2013, Jacob Kaplan-Moss - -All rights reserved. +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. +- Neither the name of django-dotenv nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT diff --git a/README.md b/README.md index 983b7d15..89fc6434 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ defined in the following list: - [environs](https://github.com/sloria/environs) - [dynaconf](https://github.com/rochacbruno/dynaconf) - [parse_it](https://github.com/naorlivne/parse_it) +- [python-decouple](https://github.com/HBNetwork/python-decouple) ## Acknowledgements diff --git a/docs/license.md b/docs/license.md new file mode 120000 index 00000000..ea5b6064 --- /dev/null +++ b/docs/license.md @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/docs/reference.md b/docs/reference.md index a126448e..8a3762ad 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,3 +1,2 @@ -# Reference +# ::: dotenv -::: dotenv diff --git a/mkdocs.yml b/mkdocs.yml index 27063ca2..331965df 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,8 +5,13 @@ theme: name: material palette: primary: green + features: + - toc.follow + - navigation.sections + markdown_extensions: - mdx_truly_sane_lists + plugins: - mkdocstrings: handlers: @@ -21,3 +26,4 @@ nav: - Changelog: changelog.md - Contributing: contributing.md - Reference: reference.md + - License: license.md diff --git a/requirements.txt b/requirements.txt index 0206316f..af7e1bc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ flake8>=2.2.3 ipython pytest-cov pytest>=3.9 -sh>=1.09 +sh>=2 tox twine wheel diff --git a/setup.cfg b/setup.cfg index 4d49c291..3fefd1f0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.21.0 +current_version = 1.0.0 commit = True tag = True diff --git a/setup.py b/setup.py index bcf1b0dc..8ceddf92 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def read_files(files): package_data={ 'dotenv': ['py.typed'], }, - python_requires=">=3.7", + python_requires=">=3.8", extras_require={ 'cli': ['click>=5.0', ], }, @@ -45,11 +45,11 @@ def read_files(files): 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 3512d101..7f4c631b 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -23,16 +23,16 @@ def get_cli_string( """ command = ['dotenv'] if quote: - command.append('-q %s' % quote) + command.append(f'-q {quote}') if path: - command.append('-f %s' % path) + command.append(f'-f {path}') if action: command.append(action) if key: command.append(key) if value: if ' ' in value: - command.append('"%s"' % value) + command.append(f'"{value}"') else: command.append(value) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index b845b95e..65ead461 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -2,8 +2,9 @@ import os import shlex import sys +from contextlib import contextmanager from subprocess import Popen -from typing import Any, Dict, List +from typing import Any, Dict, IO, Iterator, List try: import click @@ -12,12 +13,26 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) -from .main import dotenv_values, get_key, set_key, unset_key +from .main import dotenv_values, set_key, unset_key from .version import __version__ +def enumerate_env(): + """ + Return a path for the ${pwd}/.env file. + + If pwd does not exist, return None. + """ + try: + cwd = os.getcwd() + except FileNotFoundError: + return None + path = os.path.join(cwd, '.env') + return path + + @click.group() -@click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), +@click.option('-f', '--file', default=enumerate_env(), 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', @@ -29,11 +44,24 @@ @click.version_option(version=__version__) @click.pass_context 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 - ctx.obj['EXPORT'] = export - ctx.obj['FILE'] = file + """This script is used to set, get or unset values from a .env file.""" + ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILE': file} + + +@contextmanager +def stream_file(path: os.PathLike) -> Iterator[IO[str]]: + """ + Open a file and yield the corresponding (decoded) stream. + + Exits with error code 2 if the file cannot be opened. + """ + + try: + with open(path) as stream: + yield stream + except OSError as exc: + print(f"Error opening env file: {exc}", file=sys.stderr) + exit(2) @cli.command() @@ -43,24 +71,22 @@ def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: help="The format in which to display the list. Default format is simple, " "which displays name=value without quotes.") def list(ctx: click.Context, format: bool) -> None: - '''Display all the stored key/value.''' + """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) + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + if format == 'json': - click.echo(json.dumps(dotenv_as_dict, indent=2, sort_keys=True)) + click.echo(json.dumps(values, indent=2, sort_keys=True)) else: prefix = 'export ' if format == 'export' else '' - for k in sorted(dotenv_as_dict): - v = dotenv_as_dict[k] + for k in sorted(values): + v = values[k] if v is not None: if format in ('export', 'shell'): v = shlex.quote(v) - click.echo('%s%s=%s' % (prefix, k, v)) + click.echo(f'{prefix}{k}={v}') @cli.command() @@ -68,13 +94,13 @@ def list(ctx: click.Context, format: bool) -> None: @click.argument('key', required=True) @click.argument('value', required=True) def set(ctx: click.Context, key: Any, value: Any) -> None: - '''Store the given key/value.''' + """Store the given key/value.""" file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] export = ctx.obj['EXPORT'] success, key, value = set_key(file, key, value, quote, export) if success: - click.echo('%s=%s' % (key, value)) + click.echo(f'{key}={value}') else: exit(1) @@ -83,14 +109,13 @@ def set(ctx: click.Context, key: Any, value: Any) -> None: @click.pass_context @click.argument('key', required=True) def get(ctx: click.Context, key: Any) -> None: - '''Retrieve the value for the given key.''' + """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) + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + stored_value = values.get(key) if stored_value: click.echo(stored_value) else: @@ -101,12 +126,12 @@ def get(ctx: click.Context, key: Any) -> None: @click.pass_context @click.argument('key', required=True) def unset(ctx: click.Context, key: Any) -> None: - '''Removes the given key.''' + """Removes the given key.""" file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] success, key = unset_key(file, key, quote) if success: - click.echo("Successfully removed %s" % key) + click.echo(f"Successfully removed {key}") else: exit(1) @@ -124,7 +149,7 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: file = ctx.obj['FILE'] if not os.path.isfile(file): raise click.BadParameter( - 'Invalid value for \'-f\' "%s" does not exist.' % (file), + f'Invalid value for \'-f\' "{file}" does not exist.', ctx=ctx ) dotenv_as_dict = { diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 05d377a9..f40c20ea 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -12,6 +12,12 @@ from .parser import Binding, parse_stream from .variables import parse_variables +# A type alias for a string path to be used for the paths in this file. +# These paths may flow to `open()` and `shutil.move()`; `shutil.move()` +# only accepts string paths, not byte paths or file descriptors. See +# https://github.com/python/typeshed/pull/6832. +StrPath = Union[str, 'os.PathLike[str]'] + logger = logging.getLogger(__name__) @@ -25,23 +31,23 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding yield mapping -class DotEnv(): +class DotEnv: def __init__( self, - dotenv_path: Optional[Union[str, os.PathLike]], + dotenv_path: Optional[StrPath], stream: Optional[IO[str]] = None, verbose: bool = False, - encoding: Union[None, str] = None, + encoding: Optional[str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Optional[Union[str, os.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] - self.interpolate = interpolate # type: bool - self.override = override # type: bool + self.dotenv_path: Optional[StrPath] = dotenv_path + self.stream: Optional[IO[str]] = stream + self._dict: Optional[Dict[str, Optional[str]]] = None + self.verbose: bool = verbose + self.encoding: Optional[str] = encoding + self.interpolate: bool = interpolate + self.override: bool = override @contextmanager def _get_stream(self) -> Iterator[IO[str]]: @@ -108,7 +114,7 @@ def get(self, key: str) -> Optional[str]: def get_key( - dotenv_path: Union[str, os.PathLike], + dotenv_path: StrPath, key_to_get: str, encoding: Optional[str] = "utf-8", ) -> Optional[str]: @@ -122,26 +128,24 @@ def get_key( @contextmanager def rewrite( - path: Union[str, os.PathLike], + path: StrPath, encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: - try: - if not os.path.isfile(path): - with open(path, "w+", encoding=encoding) as source: - source.write("") - with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) as dest: + if not os.path.isfile(path): + with open(path, mode="w", encoding=encoding) as source: + source.write("") + with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest: + try: with open(path, encoding=encoding) as source: - yield (source, dest) # type: ignore - except BaseException: - if os.path.isfile(dest.name): + yield (source, dest) + except BaseException: os.unlink(dest.name) - raise - else: - shutil.move(dest.name, path) + raise + shutil.move(dest.name, path) def set_key( - dotenv_path: Union[str, os.PathLike], + dotenv_path: StrPath, key_to_set: str, value_to_set: str, quote_mode: str = "always", @@ -155,7 +159,7 @@ def set_key( an orphan .env somewhere in the filesystem """ if quote_mode not in ("always", "auto", "never"): - raise ValueError("Unknown quote_mode: {}".format(quote_mode)) + raise ValueError(f"Unknown quote_mode: {quote_mode}") quote = ( quote_mode == "always" @@ -167,9 +171,9 @@ def set_key( else: value_out = value_to_set if export: - line_out = 'export {}={}\n'.format(key_to_set, value_out) + line_out = f'export {key_to_set}={value_out}\n' else: - line_out = "{}={}\n".format(key_to_set, value_out) + line_out = f"{key_to_set}={value_out}\n" with rewrite(dotenv_path, encoding=encoding) as (source, dest): replaced = False @@ -190,7 +194,7 @@ def set_key( def unset_key( - dotenv_path: Union[str, os.PathLike], + dotenv_path: StrPath, key_to_unset: str, quote_mode: str = "always", encoding: Optional[str] = "utf-8", @@ -224,14 +228,14 @@ def resolve_variables( values: Iterable[Tuple[str, Optional[str]]], override: bool, ) -> Mapping[str, Optional[str]]: - new_values = {} # type: Dict[str, Optional[str]] + new_values: Dict[str, Optional[str]] = {} for (name, value) in values: if value is None: result = None else: atoms = parse_variables(value) - env = {} # type: Dict[str, Optional[str]] + env: Dict[str, Optional[str]] = {} if override: env.update(os.environ) # type: ignore env.update(new_values) @@ -305,7 +309,7 @@ def _is_interactive(): def load_dotenv( - dotenv_path: Union[str, os.PathLike, None] = None, + dotenv_path: Optional[StrPath] = None, stream: Optional[IO[str]] = None, verbose: bool = False, override: bool = False, @@ -323,7 +327,7 @@ def load_dotenv( from the `.env` file. encoding: Encoding to be used to read the file. Returns: - Bool: True if atleast one environment variable is set elese False + Bool: True if at least one environment variable is set else False If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the .env file. @@ -343,7 +347,7 @@ def load_dotenv( def dotenv_values( - dotenv_path: Union[str, os.PathLike, None] = None, + dotenv_path: Optional[StrPath] = None, stream: Optional[IO[str]] = None, verbose: bool = False, interpolate: bool = True, diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 398bd49a..735f14a3 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -25,23 +25,16 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _single_quote_escapes = make_regex(r"\\[\\']") -Original = NamedTuple( - "Original", - [ - ("string", str), - ("line", int), - ], -) - -Binding = NamedTuple( - "Binding", - [ - ("key", Optional[str]), - ("value", Optional[str]), - ("original", Original), - ("error", bool), - ], -) +class Original(NamedTuple): + string: str + line: int + + +class Binding(NamedTuple): + key: Optional[str] + value: Optional[str] + original: Original + error: bool class Position: @@ -155,7 +148,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[str] + value: Optional[str] = parse_value(reader) else: value = None reader.read_regex(_comment) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index d77b700c..667f2f26 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -1,8 +1,8 @@ import re -from abc import ABCMeta +from abc import ABCMeta, abstractmethod from typing import Iterator, Mapping, Optional, Pattern -_posix_variable = re.compile( +_posix_variable: Pattern[str] = re.compile( r""" \$\{ (?P[^\}:]*) @@ -12,20 +12,18 @@ \} """, re.VERBOSE, -) # type: Pattern[str] +) -class Atom(): - __metaclass__ = ABCMeta - +class Atom(metaclass=ABCMeta): def __ne__(self, other: object) -> bool: result = self.__eq__(other) if result is NotImplemented: return NotImplemented return not result - def resolve(self, env: Mapping[str, Optional[str]]) -> str: - raise NotImplementedError + @abstractmethod + def resolve(self, env: Mapping[str, Optional[str]]) -> str: ... class Literal(Atom): @@ -33,7 +31,7 @@ def __init__(self, value: str) -> None: self.value = value def __repr__(self) -> str: - return "Literal(value={})".format(self.value) + return f"Literal(value={self.value})" def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): @@ -53,7 +51,7 @@ def __init__(self, name: str, default: Optional[str]) -> None: self.default = default def __repr__(self) -> str: - return "Variable(name={}, default={})".format(self.name, self.default) + return f"Variable(name={self.name}, default={self.default})" def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): @@ -74,8 +72,8 @@ def parse_variables(value: str) -> Iterator[Atom]: for match in _posix_variable.finditer(value): (start, end) = match.span() - name = match.groupdict()["name"] - default = match.groupdict()["default"] + name = match["name"] + default = match["default"] if start > cursor: yield Literal(value=value[cursor:start]) diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 6a726d85..5becc17c 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.21.0" +__version__ = "1.0.0" diff --git a/tests/test_cli.py b/tests/test_cli.py index ca5ba2a1..8afbe59d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,7 +40,14 @@ def test_list_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) assert result.exit_code == 2, result.output - assert "does not exist" in result.output + assert "Error opening env file" in result.output + + +def test_list_not_a_file(cli): + result = cli.invoke(dotenv_cli, ['--file', '.', 'list']) + + assert result.exit_code == 2, result.output + assert "Error opening env file" in result.output def test_list_no_file(cli): @@ -64,11 +71,18 @@ def test_get_non_existent_value(cli, dotenv_file): assert (result.exit_code, result.output) == (1, "") -def test_get_no_file(cli): +def test_get_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) assert result.exit_code == 2 - assert "does not exist" in result.output + assert "Error opening env file" in result.output + + +def test_get_not_a_file(cli): + result = cli.invoke(dotenv_cli, ['--file', '.', 'get', 'a']) + + assert result.exit_code == 2 + assert "Error opening env file" in result.output def test_unset_existing_value(cli, dotenv_file): @@ -139,61 +153,61 @@ def test_set_no_file(cli): def test_get_default_path(tmp_path): - sh.cd(str(tmp_path)) - with open(str(tmp_path / ".env"), "w") as f: - f.write("a=b") + with sh.pushd(str(tmp_path)): + with open(str(tmp_path / ".env"), "w") as f: + f.write("a=b") - result = sh.dotenv("get", "a") + result = sh.dotenv("get", "a") - assert result == "b\n" + assert result == "b\n" def test_run(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") + with sh.pushd(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") + result = sh.dotenv("run", "printenv", "a") - assert result == "b\n" + 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") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + with sh.pushd(str(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=env) + result = sh.dotenv("run", "printenv", "a", _env=env) - assert result == "b\n" + 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") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + with sh.pushd(str(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=env) + result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) - assert result == "c\n" + assert result == "c\n" def test_run_with_none_value(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b\nc") + with sh.pushd(str(tmp_path)): + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b\nc") - result = sh.dotenv("run", "printenv", "a") + result = sh.dotenv("run", "printenv", "a") - assert result == "b\n" + assert result == "b\n" def test_run_with_other_env(dotenv_file): diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 921dfd60..f988bd9c 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -17,8 +17,8 @@ def test_ipython_existing_variable_no_override(tmp_path): os.environ["a"] = "c" ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "c"} @@ -33,8 +33,8 @@ def test_ipython_existing_variable_override(tmp_path): os.environ["a"] = "c" ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv -o") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "-o") assert os.environ == {"a": "b"} @@ -48,7 +48,7 @@ def test_ipython_new_variable(tmp_path): os.chdir(str(tmp_path)) ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "b"} diff --git a/tests/test_main.py b/tests/test_main.py index 82c73ba1..9c895851 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -178,6 +178,13 @@ def test_unset_encoding(dotenv_file): assert f.read() == "" +def test_set_key_unauthorized_file(dotenv_file): + os.chmod(dotenv_file, 0o000) + + with pytest.raises(PermissionError): + dotenv.set_key(dotenv_file, "a", "x") + + def test_unset_non_existent_file(tmp_path): nx_file = str(tmp_path / "nx") logger = logging.getLogger("dotenv.main") diff --git a/tox.ini b/tox.ini index cb8a6625..fad86f73 100644 --- a/tox.ini +++ b/tox.ini @@ -1,23 +1,26 @@ [tox] -envlist = lint,py{35,36,37,38,39,310,311},pypy3,manifest,coverage-report +envlist = lint,py{38,39,310,311,312-dev},pypy3,manifest,coverage-report [gh-actions] python = - 3.7: py37, coverage-report - 3.8: py38, coverage-report - 3.9: py39, coverage-report - 3.10: py310, lint, manifest, coverage-report - 3.11: py311, coverage-report - pypy-3.9: pypy3, coverage-report + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311, lint, manifest + 3.12-dev: py312-dev + pypy-3.9: pypy3 [testenv] deps = pytest - coverage - sh + pytest-cov + sh >= 2.0.2, <3 click - py{37,38,39,310,311,py3}: ipython -commands = coverage run --parallel -m pytest {posargs} + py{38,39,310,311,py312-dev,pypy3}: ipython +commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} +depends = + py{38,39,310,311,312-dev},pypy3: coverage-clean + coverage-report: py{38,39,310,311,312-dev},pypy3 [testenv:lint] skip_install = true @@ -26,11 +29,11 @@ deps = mypy commands = flake8 src tests + mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests mypy --python-version=3.10 src tests mypy --python-version=3.9 src tests mypy --python-version=3.8 src tests - mypy --python-version=3.7 src tests [testenv:manifest] deps = check-manifest @@ -46,5 +49,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage combine coverage report