Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Environment-variable reading is now **opt-in per option** via `@option(env=...)`:
`env=True` uses a name from the class `env_var_template` (default
`"{name}_{option}"`, upper-cased), and `env="NAME"` sets an explicit name.
**Breaking:** the `envvar=` option argument and the `auto_env_vars` class
attribute are removed; add `env=` to each option that should read the
environment. The new `env_var_template` class attribute customises generated
names.
- Replaced the single `@option(cli_only=True)` flag with two independent
toggles: `@option(cli=False)` hides an option from the command line, and
`@option(config=False)` stops it being loaded from TOML config files. The
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,27 @@ early. Set `strict_config = False` on your class to silently ignore them instead

### Environment variables

Set a name per option with `@option(envvar="MYTOOL_LOG")`, or enable
`auto_env_vars = True` on the class to expose every configurable option as
`<NAME>_<OPTION>` (e.g. `MYTOOL_CONSOLE`).
Reading from the environment is **opt-in per option**. Pass `env=True` to use a
generated name, or `env="MY_NAME"` for an explicit one:

```python
@option(env=True) # reads $MYTOOL_LOG (from the class template)
def log(self, value: str = "log.html") -> str: ...


@option(env="LOG_FILE") # reads $LOG_FILE
def log2(self, value: str = "log.html") -> str: ...
```

The generated name comes from the class `env_var_template` (default
`"{name}_{option}"`), formatted with the tool `name` and the `option` attribute
name and upper-cased — e.g. `MYTOOL_LOG`. Override it per class:

```python
class Args(ArgConfig):
name = "mytool"
env_var_template = "MYTOOL_CFG_{option}" # -> MYTOOL_CFG_LOG
```

### Restricting where an option is read from

Expand All @@ -100,7 +118,7 @@ Two independent toggles control which sources feed an option:
options above are defined this way).

Combine them as needed, e.g. a CLI-only switch is `@option(config=False)` with
no `envvar`.
`env` left off.

## Options in depth

Expand Down
10 changes: 4 additions & 6 deletions examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,25 @@ class Greeter(ArgConfig):
"""greeter - a tiny self-contained CLI built with confargs."""

name = "greeter"
# Expose GREETER_<OPTION> for every configurable option.
auto_env_vars = True

@confargs.option(names="--argumentfile/-A", config=False, is_eager=True)
def argumentfile(self, value: str | None = None) -> list[str] | None:
"""Read more command-line arguments from a file (resolved first)."""
return confargs.read_argument_file(value) if value else None

@confargs.option
@confargs.option(env=True)
def who(self, value: str = "World") -> str:
"""Who to greet."""
"""Who to greet. Also reads $GREETER_WHO."""
return value

@confargs.option
@confargs.option(env=True)
def repeat(self, value: int = 1) -> int:
"""How many times to print the greeting."""
if value < 1:
raise confargs.OptionValueError("repeat must be >= 1")
return value

@confargs.option
@confargs.option(env=True)
def color(self, value: bool = True) -> bool:
"""Colorize the output. Disable with --no-color."""
return value
Expand Down
13 changes: 7 additions & 6 deletions src/confargs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ class ArgConfig:

Attributes:
name: The tool name. Used for the default TOML section
(``[tool.<name>]``) and, when ``auto_env_vars`` is enabled, for the
environment variable prefix.
(``[tool.<name>]``) and, for options declared with ``env=True``, as
the ``{name}`` part of the environment variable name.
config_names: File names to look for when discovering TOML config,
in priority order.
default_config_section: Dotted path of the TOML table to read
(e.g. ``"tool.mytool"``). When unset, ``tool.<name>`` is used.
auto_env_vars: When true, every option loadable from config
(``config=True``) gets an implicit environment variable named
``<NAME>_<OPTION>``.
env_var_template: Template used to build the environment variable name
for options declared with ``env=True``. Formatted with ``name``
(the tool name) and ``option`` (the attribute name), then
upper-cased. Defaults to ``"{name}_{option}"``.
strict_config: When true (the default), unknown keys or options declared
with ``config=False`` found in a TOML config section raise an error
instead of being ignored.
Expand All @@ -31,7 +32,7 @@ class ArgConfig:
name: str | None = None
config_names: list[str] = ["pyproject.toml"] # noqa: RUF012 - documented, per-subclass override
default_config_section: str | None = None
auto_env_vars: bool = False
env_var_template: str = "{name}_{option}"
strict_config: bool = True

@option(names="--help/-h", config=False)
Expand Down
7 changes: 3 additions & 4 deletions src/confargs/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,23 @@ class MyArgs(ArgConfig):
"""

name = "mytool"
auto_env_vars = True

@confargs.option
@confargs.option(env=True)
def log(self, value: str | None = "log.html") -> str | None:
"""HTML log file. Disable with the special value 'NONE'."""
if value == "NONE":
return None
return value

@confargs.option(names="--console/-c")
@confargs.option(names="--console/-c", env=True)
def console(self, value: str = "verbose") -> str:
"""Console output mode: verbose, dotted, quiet or none."""
choices = ["verbose", "dotted", "quiet", "none"]
if value not in choices:
raise confargs.OptionValueError(f"console must be one of {choices}, got {value!r}")
return value

@confargs.option
@confargs.option(env=True)
def retries(self, value: int = 3) -> int:
"""Number of retries on failure."""
if value < 0:
Expand Down
37 changes: 23 additions & 14 deletions src/confargs/env_source.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Environment-variable configuration source.

Each option can read from an environment variable in one of two ways:
Reading an option from the environment is **opt-in** per option via the
``env`` argument to :func:`~confargs.option`:

* explicitly, via ``@option(envvar="MY_TOOL_LOG")``, or
* implicitly, when the config class sets ``auto_env_vars = True``, in which case
every option that is loadable from config (``config`` is true) gets an
implicit variable named ``<TOOL_NAME>_<OPTION>`` (upper-cased).
* ``@option(env=True)`` uses a name generated from the config class'
``env_var_template`` (by default ``"{name}_{option}"`` upper-cased, e.g.
``MYTOOL_LOG``), and
* ``@option(env="MY_TOOL_LOG")`` sets an explicit variable name verbatim.

An explicit ``envvar`` always wins over the auto-generated name.
Options left at the default ``env=False`` are never read from the environment.
"""

from __future__ import annotations
Expand All @@ -19,32 +20,40 @@

from confargs.options import Option

DEFAULT_ENV_VAR_TEMPLATE = "{name}_{option}"


def env_var_name(
option: Option,
tool_name: str,
*,
auto_env_vars: bool,
template: str = DEFAULT_ENV_VAR_TEMPLATE,
) -> str | None:
"""Return the environment variable name for ``option``, or ``None``."""
if option.envvar:
return option.envvar
if auto_env_vars and option.config:
return f"{tool_name.upper()}_{option.attr_name.upper()}"
"""Return the environment variable name for ``option``, or ``None``.

An explicit string ``env`` is used verbatim; ``env=True`` formats
``template`` with ``name`` (the tool name) and ``option`` (the attribute
name) and upper-cases the result; ``env=False`` disables the source.
"""
spec = option.env
if spec is True:
return template.format(name=tool_name, option=option.attr_name).upper()
if isinstance(spec, str) and spec:
return spec
return None


def collect_env_values(
options: Mapping[str, Option],
tool_name: str,
*,
auto_env_vars: bool,
template: str = DEFAULT_ENV_VAR_TEMPLATE,
environ: Mapping[str, str],
) -> dict[str, str]:
"""Collect raw option values present in ``environ``."""
values: dict[str, str] = {}
for attr, option in options.items():
name = env_var_name(option, tool_name, auto_env_vars=auto_env_vars)
name = env_var_name(option, tool_name, template=template)
if name is not None and name in environ:
values[attr] = environ[name]
return values
23 changes: 13 additions & 10 deletions src/confargs/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ def __init__(
names: str | None = None,
cli: bool = True,
config: bool = True,
envvar: str | None = None,
env: bool | str = False,
is_eager: bool = False,
) -> None:
self.func = func
self.explicit_names = names
self.cli = cli
self.config = config
self.envvar = envvar
self.env = env
self.is_eager = is_eager
self.attr_name: str = func.__name__
# Names the option *wants*; short-name collisions are resolved later.
Expand Down Expand Up @@ -146,7 +146,7 @@ def option(
names: str | None = ...,
cli: bool = ...,
config: bool = ...,
envvar: str | None = ...,
env: bool | str = ...,
is_eager: bool = ...,
) -> Callable[[OptionMethod], Option]: ...

Expand All @@ -157,13 +157,13 @@ def option(
names: str | None = None,
cli: bool = True,
config: bool = True,
envvar: str | None = None,
env: bool | str = False,
is_eager: bool = False,
) -> Option | Callable[[OptionMethod], Option]:
"""Mark a method as an confargs option.

Usable bare (``@option``) or with keyword arguments
(``@option(names="--console/-c", config=False, envvar="MY_CONSOLE")``).
(``@option(names="--console/-c", config=False, env=True)``).

Args:
names: Explicit names spec, e.g. ``"--console/-c"``. When omitted the
Expand All @@ -174,10 +174,13 @@ def option(
should only come from config files or the environment.
config: When false, the option is never loaded from TOML config files.
Combine with the environment/CLI toggles to build, for example, a
CLI-only switch (``config=False`` plus no ``envvar``) that controls
the tool run itself.
envvar: Name of an environment variable that provides this option's
value.
CLI-only switch (``config=False``, no ``env``) that controls the
tool run itself.
env: Opt this option into the environment-variable source. ``True`` uses
a name generated from the class ``env_var_template`` (by default
``"{name}_{option}"`` upper-cased, e.g. ``MYTOOL_LOG``); a string
sets an explicit variable name. ``False`` (the default) means the
option is never read from the environment.
is_eager: If true, the option is resolved *before* any other option,
directly against ``argv``. The method's return value (an iterable of
strings, or ``None``) replaces the option's own tokens in ``argv``,
Expand All @@ -186,7 +189,7 @@ def option(
"""

def wrap(f: OptionMethod) -> Option:
return Option(f, names=names, cli=cli, config=config, envvar=envvar, is_eager=is_eager)
return Option(f, names=names, cli=cli, config=config, env=env, is_eager=is_eager)

if func is not None:
return wrap(func)
Expand Down
2 changes: 1 addition & 1 deletion src/confargs/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def process(self) -> Namespace:
env_values = collect_env_values(
self.options,
self.instance.tool_name,
auto_env_vars=self.instance.auto_env_vars,
template=self.instance.env_var_template,
environ=self.environ,
)

Expand Down
42 changes: 21 additions & 21 deletions tests/test_env_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,58 +9,58 @@
class Tool(ArgConfig):
name = "mytool"

@option(envvar="EXPLICIT_LOG")
@option(env="EXPLICIT_LOG")
def log(self, value: str = "log.html") -> str:
return value

@option
@option(env=True)
def console(self, value: str = "verbose") -> str:
return value

@option(config=False)
def no_config(self, value: bool = False) -> bool:
@option
def quiet(self, value: bool = False) -> bool:
return value

@option(config=False, envvar="FORCE_ENV")
@option(config=False, env="FORCE_ENV")
def forced(self, value: str = "") -> str:
return value


OPTIONS = collect_options(Tool)


def test_explicit_envvar_name() -> None:
assert env_var_name(OPTIONS["log"], "mytool", auto_env_vars=False) == "EXPLICIT_LOG"
def test_explicit_env_name_used_verbatim() -> None:
assert env_var_name(OPTIONS["log"], "mytool") == "EXPLICIT_LOG"


def test_no_envvar_without_auto() -> None:
assert env_var_name(OPTIONS["console"], "mytool", auto_env_vars=False) is None
def test_no_env_when_not_opted_in() -> None:
assert env_var_name(OPTIONS["quiet"], "mytool") is None


def test_auto_envvar_name() -> None:
assert env_var_name(OPTIONS["console"], "mytool", auto_env_vars=True) == "MYTOOL_CONSOLE"
def test_auto_env_name_from_template() -> None:
assert env_var_name(OPTIONS["console"], "mytool") == "MYTOOL_CONSOLE"


def test_non_config_excluded_from_auto() -> None:
assert env_var_name(OPTIONS["no_config"], "mytool", auto_env_vars=True) is None
def test_custom_template() -> None:
assert env_var_name(OPTIONS["console"], "mytool", template="cfg_{name}__{option}") == "CFG_MYTOOL__CONSOLE"


def test_explicit_envvar_honoured_even_for_non_config() -> None:
assert env_var_name(OPTIONS["forced"], "mytool", auto_env_vars=True) == "FORCE_ENV"
def test_env_works_regardless_of_config_toggle() -> None:
assert env_var_name(OPTIONS["forced"], "mytool") == "FORCE_ENV"


def test_collect_reads_explicit_and_auto() -> None:
environ = {"EXPLICIT_LOG": "a.html", "MYTOOL_CONSOLE": "quiet"}
values = collect_env_values(OPTIONS, "mytool", auto_env_vars=True, environ=environ)
values = collect_env_values(OPTIONS, "mytool", environ=environ)
assert values == {"log": "a.html", "console": "quiet"}


def test_collect_ignores_absent_vars() -> None:
values = collect_env_values(OPTIONS, "mytool", auto_env_vars=True, environ={})
values = collect_env_values(OPTIONS, "mytool", environ={})
assert values == {}


def test_collect_without_auto_only_explicit() -> None:
environ = {"EXPLICIT_LOG": "a.html", "MYTOOL_CONSOLE": "quiet"}
values = collect_env_values(OPTIONS, "mytool", auto_env_vars=False, environ=environ)
assert values == {"log": "a.html"}
def test_collect_ignores_options_not_opted_in() -> None:
environ = {"MYTOOL_QUIET": "true"}
values = collect_env_values(OPTIONS, "mytool", environ=environ)
assert values == {}
4 changes: 2 additions & 2 deletions tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def console(self, value: str = "verbose") -> str:
"""Console output mode."""
return value

@option(config=False, envvar="SAMPLE_VERBOSE")
@option(config=False, env="SAMPLE_VERBOSE")
def verbose(self, value: bool = False) -> bool:
"""Be verbose."""
return value
Expand Down Expand Up @@ -72,7 +72,7 @@ def test_option_metadata_flags() -> None:
verbose = Sample.__dict__["verbose"]
assert verbose.cli is True
assert verbose.config is False
assert verbose.envvar == "SAMPLE_VERBOSE"
assert verbose.env == "SAMPLE_VERBOSE"


def test_default_and_missing_default() -> None:
Expand Down
Loading
Loading