diff --git a/CHANGELOG.md b/CHANGELOG.md index d3512c6..911fab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.31.0 +- feat: Apply default parsing automatically, with Arg.parse_inference == False to disable. + ## 0.30.4 - fix: Use of a synchronous context manager inside an `invoke_async` invoke context. - fix: Stripped type aliases losing e.g. Dep metadata. diff --git a/docs/source/arg.md b/docs/source/arg.md index 9ff7842..39bb306 100644 --- a/docs/source/arg.md +++ b/docs/source/arg.md @@ -527,9 +527,14 @@ Note cappa itself contains a number of component `parse_*` functions inside the module, which can be used in combination with your own custom `parse` functions. ``` -### `default_parse` +### `default_parse`/`Arg.parse_inference -A function [cappa.default_parse](cappa.default_parse) is exposed +A function [cappa.default_parse](cappa.default_parse) is exposed and **can** be directly referenced +in parse function sequences. However by default the `default_parse` function is applied automatically +at the end of the user-provided parse function/sequence. + +With that said, if the default behavior is undesirable, one can set `Arg(parse_inference=False)` to +**only** execute the user provided parser function(s). (parsing-json)= ### Parsing JSON diff --git a/pyproject.toml b/pyproject.toml index 594914e..39f02ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cappa" -version = "0.30.4" +version = "0.31.0" description = "Declarative CLI argument parser." urls = { repository = "https://github.com/dancardin/cappa" } diff --git a/src/cappa/arg.py b/src/cappa/arg.py index ae54a7f..8694489 100644 --- a/src/cappa/arg.py +++ b/src/cappa/arg.py @@ -149,6 +149,7 @@ class Arg(Generic[T]): return the parsed value. This can be either a single function, or a sequence of functions. If a sequence is provided, they will be called (in order) with their return value chained into the next function in the sequence. + parse_inference: Whether to include the default inferred parser for the annotated. group: Optional group names for the argument. This affects how they're displayed in the backend's help text. Note this can also be a `Group` instance in order @@ -192,6 +193,7 @@ def __hash__(self): default: T | EmptyType | None = Empty help: str | None = None parse: Callable[..., T] | Sequence[Callable[..., Any]] | None = None + parse_inference: bool = True group: str | tuple[int, str] | Group | EmptyType = Empty @@ -627,22 +629,27 @@ def infer_num_args( def infer_parse( arg: Arg[Any], type_view: TypeView[Any], state: State[Any] | None = None ) -> Callable[..., Any]: - if arg.parse: - parse: Parser[Any] | Sequence[Parser[Any]] = arg.parse - else: - parse = parse_value(type_view) + parse: Parser[Any] | Sequence[Parser[Any]] = arg.parse # type: ignore + default_parse = parse_value(type_view) # This is the original arg choices, e.g. explicitly provided. Inferred choices # need to be handled internally to the parsers. - if arg.choices: - if not isinstance(parse, Sequence): - parse = [parse] + parsers: Sequence[Parser[Any]] = [] + if parse: + if isinstance(parse, Sequence): + parsers = [*cast(Sequence[Any], parse)] + else: + parsers = [parse] + if arg.choices: literal_type = Literal[tuple(arg.choices)] # type: ignore literal_parse: Parser[Any] = parse_literal(literal_type) # type: ignore - parse = cast(Sequence[Parser[Any]], [*parse, literal_parse]) + parsers = cast(Sequence[Parser[Any]], [*parsers, literal_parse]) + + if arg.parse_inference: + parsers = [*parsers, default_parse] - return evaluate_parse(parse, type_view, state=state) + return evaluate_parse(parsers, type_view, state=state) def infer_help(arg: Arg[Any], fallback_help: str | None) -> str | None: diff --git a/src/cappa/parse.py b/src/cappa/parse.py index 7d977ae..db56933 100644 --- a/src/cappa/parse.py +++ b/src/cappa/parse.py @@ -17,8 +17,6 @@ cast, ) -from typing_extensions import Never - from cappa.file_io import FileMode from cappa.state import S, State from cappa.type_view import TypeView @@ -105,7 +103,7 @@ def parse_value(typ: MaybeTypeView[T]) -> Parser[T]: return type_view.annotation if type_view.is_none_type: - return parse_none + return parse_none # type: ignore if type_view.is_subclass_of(enum.Enum): return parse_enum(type_view) @@ -131,7 +129,17 @@ def parse_value(typ: MaybeTypeView[T]) -> Parser[T]: if type_view.is_subclass_of((TextIO, BinaryIO)): return parse_file_io(type_view) - return type_view.annotation + return parse_fallback(type_view.annotation) + + +def parse_fallback(fallback: type[T]) -> Parser[T]: + def fallback_mapper(v: Any) -> T: + if isinstance(v, fallback): + return v + + return fallback(v) # type: ignore + + return fallback_mapper def parse_literal(typ: MaybeTypeView[T]) -> Parser[T]: @@ -252,11 +260,10 @@ def union_mapper(value: Any) -> Any: return union_mapper -def parse_none(value: Any) -> Never: - """Create a value parser for None. - - Default values are not run through Arg.parse, so there's no way to arrive at a `None` value. - """ +def parse_none(value: Any) -> None: + """Create a value parser for None.""" + if value is None: + return raise ValueError(value) diff --git a/tests/arg/test_parse_inference.py b/tests/arg/test_parse_inference.py new file mode 100644 index 0000000..bf4e1b9 --- /dev/null +++ b/tests/arg/test_parse_inference.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from typing_extensions import Annotated + +import cappa +from tests.utils import Backend, backends, parse + + +@dataclass +class Args: + numbers: Annotated[str, cappa.Arg(parse=int, parse_inference=False)] + + +@backends +def test_disable_default_parser(backend: Backend): + test = parse(Args, "1", backend=backend) + assert test.numbers == 1 diff --git a/uv.lock b/uv.lock index e53557b..bbac5ed 100644 --- a/uv.lock +++ b/uv.lock @@ -84,7 +84,7 @@ wheels = [ [[package]] name = "cappa" -version = "0.30.3" +version = "0.30.4" source = { editable = "." } dependencies = [ { name = "rich" },