Source code for tests.test_envvar

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import os

import click
import cloup
import pytest
from extra_platforms import is_windows

from click_extra import command, echo, option
from click_extra.envvar import clean_envvar_id, env_copy, merge_envvar_ids


[docs] @pytest.mark.parametrize( ("envvars", "result"), ( (("MY_VAR", "MY_VAR"), ("MY_VAR",)), ((None, "MY_VAR"), ("MY_VAR",)), (("MY_VAR", None), ("MY_VAR",)), ((["MY_VAR"], "MY_VAR"), ("MY_VAR",)), ((["MY_VAR"], None), ("MY_VAR",)), (("MY_VAR", ["MY_VAR"]), ("MY_VAR",)), ((None, ["MY_VAR"]), ("MY_VAR",)), ((["MY_VAR"], ["MY_VAR"]), ("MY_VAR",)), ((["MY_VAR1"], ["MY_VAR2"]), ("MY_VAR1", "MY_VAR2")), ((["MY_VAR1", "MY_VAR2"], ["MY_VAR2"]), ("MY_VAR1", "MY_VAR2")), ((["MY_VAR1"], ["MY_VAR1", "MY_VAR2"]), ("MY_VAR1", "MY_VAR2")), ((["MY_VAR1"], ["MY_VAR2", "MY_VAR2"]), ("MY_VAR1", "MY_VAR2")), ((["MY_VAR1", "MY_VAR1"], ["MY_VAR2"]), ("MY_VAR1", "MY_VAR2")), ( (["MY_VAR1", ["MY_VAR1", None, "MY_VAR1"]], ["MY_VAR2"]), ("MY_VAR1", "MY_VAR2"), ), ), ) def test_merge_envvar_ids(envvars, result): assert merge_envvar_ids(*envvars) == result
[docs] @pytest.mark.parametrize( ("env_name", "clean_name"), ( ("show-params-cli_VERSION", "SHOW_PARAMS_CLI_VERSION"), ("show---params-cli___VERSION", "SHOW_PARAMS_CLI_VERSION"), ("__show-__params-_-_-", "SHOW_PARAMS"), ), ) def test_clean_envvar_id(env_name, clean_name): assert clean_envvar_id(env_name) == clean_name
[docs] @pytest.mark.parametrize( ("cmd_decorator", "option_help"), ( # Click and Cloup do not show the auto-generated envvar in the help screen. ( click.command, " --flag / --no-flag [env var: custom]\n", ), ( cloup.command, " --flag / --no-flag [env var: custom]\n", ), # Click Extra always adds the auto-generated envvar to the help screen # (and show the defaults). ( command, " --flag / --no-flag [env var: " + ("CUSTOM, YO_FLAG" if os.name == "nt" else "custom, yo_FLAG") + "; default: no-flag]\n", ), ), ) def test_show_auto_envvar_help(invoke, cmd_decorator, option_help): """Check that the auto-generated envvar appears in the help screen with the extra variants. Checks that https://github.com/pallets/click/issues/2483 is addressed. """ @cmd_decorator(context_settings={"auto_envvar_prefix": "yo"}) @option("--flag/--no-flag", envvar=["custom"], show_envvar=True) def envvar_help(): pass # Remove colors to simplify output comparison. result = invoke(envvar_help, "--help", color=False) assert option_help in result.stdout assert not result.stderr assert result.exit_code == 0
[docs] def envvars_test_cases(): params = [] # Which spellings each framework answers to, given `auto_envvar_prefix="yo"` # and `envvar=["Magic", "sUper"]` on the option. # # Two rules explain every row, and neither is a defect: # # 1. A *user-defined* envvar is matched byte-for-byte, so `Magic` works and # `MAGIC` does not. # 2. An *auto-generated* envvar is uppercased by Click: `Context.__init__` # upper-cases `auto_envvar_prefix`, and `Parameter.resolve_envvar_value` # builds `f"{prefix}_{name.upper()}"`. So the generated name is `YO_FLAG`, # whatever case the prefix was declared in, and the literal `yo_FLAG` is # not a name Click ever looks up. # # Rule 2 was raised upstream as a case-sensitivity bug in # https://github.com/pallets/click/issues/2483 and declined: "Environment # variables are case sensitive except on Windows. Traditionally, variables # are all uppercase... there's nothing to fix regarding case." So the # `YO_FLAG` rows below pin Click's intended behavior rather than a bug # awaiting a fix, and a Click release that stopped matching it would be a # real regression this test is meant to catch. # # What click-extra adds is the missing half: it registers the literal # `yo_FLAG` alongside, so an option answers to the prefix as it was written. # Neither framework matches an arbitrary mixed case like `yo_FlAg`. matrix = { (click.command, "click.command"): { "working_envvar": ( # User-defined envvars are recognized as-is (rule 1). "Magic", "sUper", # The uppercased form is the generated name (rule 2). "YO_FLAG", ), "unknown_envvar": ( # Uppercased user-defined envvar is not recognized (rule 1). "MAGIC", # The prefix as declared is never looked up (rule 2); this is # the spelling click-extra adds below. "yo_FLAG", # Mixed-cased auto-generated envvar matches neither form. "yo_FlAg", ), }, (cloup.command, "cloup.command"): { # Cloup inherits Click's resolution untouched, so the two agree. "working_envvar": ( "Magic", "sUper", "YO_FLAG", ), "unknown_envvar": ( "MAGIC", "yo_FLAG", "yo_FlAg", ), }, (command, "click_extra.command"): { "working_envvar": ( # User-defined envvars are recognized as-is (rule 1). "Magic", "sUper", # click-extra's addition: the prefix as declared is registered # explicitly, so this spelling resolves where vanilla Click # leaves it unmatched. "yo_FLAG", # Click's generated name still resolves (rule 2). "YO_FLAG", ), "unknown_envvar": ( # Uppercased user-defined envvar is not recognized (rule 1). "MAGIC", # Mixed-cased auto-generated envvar matches neither form. "yo_FlAg", ), }, } # Windows automatically normalizes any env var to upper-case, see: # https://github.com/python/cpython/blob/e715da6/Lib/os.py#L748-L749 # https://docs.python.org/3/library/os.html?highlight=environ#os.environ # So Windows needs its own test case. if is_windows(): all_envvars = ( "Magic", "MAGIC", "sUper", "yo_FLAG", "YO_FLAG", "yo_FlAg", ) matrix = { (click.command, "click.command"): { "working_envvar": all_envvars, "unknown_envvar": (), }, (cloup.command, "cloup.command"): { "working_envvar": all_envvars, "unknown_envvar": (), }, (command, "click_extra.command"): { "working_envvar": all_envvars, "unknown_envvar": (), }, } # If properly recognized, these envvar values should be passed to the flag. working_value_map = { "True": True, "true": True, "tRuE": True, "1": True, # An empty value never reaches the flag's type: `resolve_envvar_value` # guards its lookup with a bare `if rv`, and its docstring states that a # variable "present but has an empty string" resolves to `None`. So the # option falls back to its default, and `False` here is that default # rather than a parse of `""`. Flipping the option's default to `True` # flips this expectation with it. # # This is the one point where click-extra reads a flag differently: # `parse_envvar_flag("")` returns `True`, since the variables it serves # by hand (`NO_COLOR` and friends) follow the convention that bare # presence is the signal. Those never route through a Click option. "": False, "False": False, "false": False, "fAlsE": False, "0": False, } # No envvar value will have an effect on the flag if the envvar is not recognized. broken_value_map = {k: False for k in working_value_map} for (cmd_decorator, decorator_name), envvar_cases in matrix.items(): for case_name, envvar_names in ( envvar_cases.items() # type: ignore[attr-defined] ): value_map = ( working_value_map if case_name == "working_envvar" else broken_value_map ) for envvar_name in envvar_names: for envar_value, expected_flag in value_map.items(): envvar = {envvar_name: envar_value} test_id = ( f"{decorator_name}|{case_name}={envvar}" f"|expected_flag={expected_flag}" ) params.append( pytest.param(cmd_decorator, envvar, expected_flag, id=test_id) ) return params
[docs] @pytest.mark.parametrize( ("cmd_decorator", "envvars", "expected_flag"), envvars_test_cases() ) def test_auto_envvar_parsing(invoke, cmd_decorator, envvars, expected_flag): """This test highlights the way Click recognize and parse envvars. It shows that the default behavior is not ideal, and covers how ``command`` improves the situation by normalizing the envvar name. """ @cmd_decorator(context_settings={"auto_envvar_prefix": "yo"}) @option("--flag/--no-flag", envvar=["Magic", "sUper"]) def my_cli(flag): echo(f"Flag value: {flag}") registered_envvars: list[str] | tuple[str, ...] = ["Magic", "sUper"] # Specific behavior of @click_extra.command that is not present in vanilla Click. if cmd_decorator == command: # @command forces registration of auto-generated envvar. registered_envvars = [*registered_envvars, "yo_FLAG"] # On Windows, envvars are normalizes to uppercase. if os.name == "nt": registered_envvars = [envvar.upper() for envvar in registered_envvars] # @command parameters returns envvar property as tuple, while vanilla Click # returns a list. registered_envvars = tuple(registered_envvars) assert my_cli.params[0].envvar == registered_envvars result = invoke(my_cli, env=envvars) assert result.stdout == f"Flag value: {expected_flag}\n" assert not result.stderr assert result.exit_code == 0
[docs] @pytest.mark.parametrize("default", (False, True)) @pytest.mark.parametrize("cmd_decorator", (click.command, cloup.command, command)) def test_empty_envvar_falls_back_to_the_default(invoke, cmd_decorator, default): """An empty variable is read as unset, so the flag keeps its own default. Pins the rule the ``""`` row of :func:`envvars_test_cases` relies on: ``Parameter.resolve_envvar_value`` guards its lookup with a bare ``if rv``, so an empty value never reaches the flag's type. The ``False`` that row expects is the option's default showing through, which is why flipping the default flips the outcome with it. """ # Parenthesized: Cloup rejects the naked form, and the rest of this module # calls every decorator anyway. @cmd_decorator() @option("--flag/--no-flag", default=default, envvar="Magic") def my_cli(flag): echo(f"Flag value: {flag}") for env in (None, {"Magic": ""}): result = invoke(my_cli, env=env) assert result.stdout == f"Flag value: {default}\n" assert result.exit_code == 0 # A value that does parse still wins over the default, in both directions. for value, expected in (("1", True), ("0", False)): result = invoke(my_cli, env={"Magic": value}) assert result.stdout == f"Flag value: {expected}\n" assert result.exit_code == 0
[docs] def test_env_copy(): envvar = "MPM_DUMMY_ENVVAR_93725" assert envvar not in os.environ no_env = env_copy() assert no_env is None extended_env = env_copy({envvar: "yo"}) assert extended_env is not None assert envvar in extended_env assert extended_env[envvar] == "yo" assert envvar not in os.environ
[docs] def test_env_copy_removes_on_none(monkeypatch): """A `None` value drops its variable, the way Click's ``CliRunner`` reads it. The only way to hide an inherited variable from a child: assigning the empty string leaves it set, which :func:`parse_envvar_flag` counts as activation. """ envvar = "MPM_DUMMY_ENVVAR_93725" monkeypatch.setenv(envvar, "inherited") assert env_copy({envvar: "override"})[envvar] == "override" # type: ignore[index] assert envvar not in env_copy({envvar: None}) # type: ignore[operator] # Removing what is not there is a no-op, not a KeyError. assert "MPM_DUMMY_ENVVAR_93726" not in env_copy({"MPM_DUMMY_ENVVAR_93726": None}) # type: ignore[operator] # The process environment is never touched. assert os.environ[envvar] == "inherited"