diff --git a/CHANGELOG.md b/CHANGELOG.md index f60d0c2..5218647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.21 +### 0.21.2 +- fix: action inference when `default` is an `Env`. + ### 0.21.1 - feat: Update automatic inference to support `date`, `time`, and `datetime` parsing (for isoformat). diff --git a/pyproject.toml b/pyproject.toml index 034b1d0..0013be2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cappa" -version = "0.21.1" +version = "0.21.2" description = "Declarative CLI argument parser." repository = "https://github.com/dancardin/cappa" diff --git a/src/cappa/arg.py b/src/cappa/arg.py index 9315720..dd13309 100644 --- a/src/cappa/arg.py +++ b/src/cappa/arg.py @@ -445,6 +445,9 @@ def infer_action( # Coerce raw `bool` into flags by default if is_subclass(origin, bool): + if isinstance(default, Env): + default = default.default + if default is not missing and bool(default): return ArgAction.store_false diff --git a/src/cappa/argparse.py b/src/cappa/argparse.py index 44a042c..0ddf423 100644 --- a/src/cappa/argparse.py +++ b/src/cappa/argparse.py @@ -10,7 +10,7 @@ from cappa.arg import Arg, ArgAction, no_extra_arg_actions from cappa.command import Command, Subcommand from cappa.help import generate_arg_groups -from cappa.invoke import fullfill_deps +from cappa.invoke import fulfill_deps from cappa.output import Exit, HelpExit, Output from cappa.parser import RawOption, Value from cappa.typing import assert_type, missing @@ -129,7 +129,7 @@ def __call__( # type: ignore if option_string: fullfilled_deps[RawOption] = RawOption.from_str(option_string) - deps = fullfill_deps(action, fullfilled_deps) + deps = fulfill_deps(action, fullfilled_deps) result = action(**deps) setattr(namespace, self.dest, result) diff --git a/src/cappa/invoke.py b/src/cappa/invoke.py index 138a5c8..245e7f3 100644 --- a/src/cappa/invoke.py +++ b/src/cappa/invoke.py @@ -21,7 +21,7 @@ class InvokeResolutionError(RuntimeError): @dataclass(frozen=True) class Dep(typing.Generic[C]): - """Describes the callable required to fullfill a given dependency.""" + """Describes the callable required to fulfill a given dependency.""" callable: Callable @@ -151,8 +151,8 @@ def resolve_callable( global_deps = resolve_global_deps(deps, implicit_deps) - fullfilled_deps = {**implicit_deps, **global_deps} - kwargs = fullfill_deps(fn, fullfilled_deps) + fulfilled_deps = {**implicit_deps, **global_deps} + kwargs = fulfill_deps(fn, fulfilled_deps) except InvokeResolutionError as e: raise InvokeResolutionError( f"Failed to invoke {parsed_command.cmd_cls} due to resolution failure." @@ -177,7 +177,7 @@ def resolve_global_deps( for source_function, dep in deps.items(): # Deps need to be fulfilled, whereas raw values are taken directly. if isinstance(dep, Dep): - value = Resolved(dep.callable, fullfill_deps(dep.callable, implicit_deps)) + value = Resolved(dep.callable, fulfill_deps(dep.callable, implicit_deps)) else: value = Resolved(source_function, result=dep, is_resolved=True) @@ -259,7 +259,7 @@ def resolve_implicit_deps(command: Command, instance: HasCommand) -> dict: return deps -def fullfill_deps(fn: Callable, fullfilled_deps: dict) -> typing.Any: +def fulfill_deps(fn: Callable, fulfilled_deps: dict) -> typing.Any: result = {} signature = getattr(fn, "__signature__", None) or inspect.signature(fn) @@ -286,29 +286,29 @@ def fullfill_deps(fn: Callable, fullfilled_deps: dict) -> typing.Any: if dep is None: # Non-annotated args are either implicit dependencies (and thus already fulfilled), - # or arguments that we cannot fullfill - if annotation not in fullfilled_deps: + # or arguments that we cannot fulfill + if annotation not in fulfilled_deps: if param.default is param.empty: annotation_name = annotation.__name__ if annotation else "" raise InvokeResolutionError( f"`{name}: {annotation_name}` is not a valid dependency for Dep({fn.__name__})." ) - # if there's a default, we can just skip it and let the default fullfill the value. + # if there's a default, we can just skip it and let the default fulfill the value. continue - value = fullfilled_deps[annotation] + value = fulfilled_deps[annotation] else: # Whereas everything else should be a resolvable explicit Dep, which might have either # already been fullfullfilled, or yet need to be. - if dep in fullfilled_deps: - value = fullfilled_deps[dep] + if dep in fulfilled_deps: + value = fulfilled_deps[dep] else: value = Resolved( - dep.callable, fullfill_deps(dep.callable, fullfilled_deps) + dep.callable, fulfill_deps(dep.callable, fulfilled_deps) ) - fullfilled_deps[dep] = value + fulfilled_deps[dep] = value result[name] = value diff --git a/src/cappa/parser.py b/src/cappa/parser.py index 6c1d6c3..725c5ab 100644 --- a/src/cappa/parser.py +++ b/src/cappa/parser.py @@ -8,7 +8,7 @@ from cappa.command import Command, Subcommand from cappa.completion.types import Completion, FileCompletion from cappa.help import format_subcommand_names -from cappa.invoke import fullfill_deps +from cappa.invoke import fulfill_deps from cappa.output import Exit, HelpExit, Output from cappa.typing import T, assert_type @@ -579,7 +579,7 @@ def consume_arg( else: action_handler = action - fullfilled_deps: dict = { + fulfilled_deps: dict = { Command: context.command, Output: context.output, ParseContext: context, @@ -587,9 +587,9 @@ def consume_arg( Value: Value(result), } if option: - fullfilled_deps[RawOption] = option + fulfilled_deps[RawOption] = option - kwargs = fullfill_deps(action_handler, fullfilled_deps) + kwargs = fulfill_deps(action_handler, fulfilled_deps) context.result[arg.field_name] = action_handler(**kwargs) check_deprecated(context, arg, option) diff --git a/tests/arg/test_bool.py b/tests/arg/test_bool.py index 2d88b5f..47d8544 100644 --- a/tests/arg/test_bool.py +++ b/tests/arg/test_bool.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from unittest.mock import patch import cappa import pytest @@ -142,3 +143,28 @@ class ArgTest: test = parse(ArgTest, "-t", "asdf", backend=backend) assert test.true_false is True + + +@backends +def test_env_default_value_precedence(backend): + """Assert a bool flag yields correct value given an env default.""" + + @dataclass + class ArgTest: + env_default: Annotated[ + bool, cappa.Arg(long=True, default=cappa.Env("ENV_DEFAULT")) + ] = False + + test = parse(ArgTest, backend=backend) + assert test.env_default is False + + with patch("os.environ", new={"ENV_DEFAULT": "1"}): + test = parse(ArgTest, backend=backend) + assert test.env_default is True + + test = parse(ArgTest, "--env-default", backend=backend) + assert test.env_default is True + + with patch("os.environ", new={"ENV_DEFAULT": "1"}): + test = parse(ArgTest, "--env-default", backend=backend) + assert test.env_default is True