Skip to content

Inconsistent handling of option values from environment variables #2146

@ddnomad

Description

@ddnomad

There are 2 ways of configuring Click to load a value of a specific option from an environment variable: "automatic" via auto_envvar_prefix and "explicit" via passing envvar keyword argument to the option definition.

When a name of an environment variable is set explicitly, Click treats empty string values (i.e. export MY_OPTION=) as not set and keeps a default value (None if not configured otherwise). But when a name is inferred from auto_envvar_prefix configuration, Click treats empty string values as valid values, does not set a default and passes the option to ParamType.convert() if type keyword argument is specified for that option.

This is inconsistent.

import click

@click.command()
@click.option('--auto')
@click.option('--ex', envvar="EXEV")
def cli(auto, ex):
    click.echo(f"{auto=} {type(auto)=}")
    click.echo(f"{ex=} {type(ex)=}")

if __name__ == '__main__':
    cli(auto_envvar_prefix='MY')

Actual output:

$ MY_AUTO= EXEV= ./cli.py
auto='' type(auto)=<class 'str'>
ex=None type(ex)=<class 'NoneType'>

Expected output:

$ MY_AUTO= EXEV= ./cli.py
auto=None type(auto)=<class 'NoneType'>
ex=None type(ex)=<class 'NoneType'>

Environment:

  • Python version: 3.9.7
  • Click version: 8.0.3

It's being a nuisance for quite a while at my company, we have .env.example file with variables containing multiple export FOO= lines for each project. When my colleagues work on the project, they just copy .env.example to .env, fill out required values (not all of the values) and then do a test run.

Then for variables like export OPTIONAL_VALUE= that are left as is, click loads them as empty strings and calls type validators TypeInstance.convert() with that value, which usually fails as an empty string is not a valid value for the majority of options.

Now when I go diving into the code base, it seems like it's handled "correctly" here:

click/src/click/core.py

Lines 2267 to 2283 in 6411f42

def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]:
if self.envvar is None:
return None
if isinstance(self.envvar, str):
rv = os.environ.get(self.envvar)
if rv:
return rv
else:
for envvar in self.envvar:
rv = os.environ.get(envvar)
if rv:
return rv
return None

(i.e. if value is an empty string, None is returned)

But then here it is fetched as is without checking for an empty string:

click/src/click/core.py

Lines 2777 to 2783 in 6411f42

if (
self.allow_from_autoenv
and ctx.auto_envvar_prefix is not None
and self.name is not None
):
envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}"
rv = os.environ.get(envvar)

The most obvious fix would be to add the following 2 lines

if rv:
    return rv

after

rv = os.environ.get(envvar)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions