Skip to content

Commit

Permalink
Merge pull request #180 from DanCardin/dc/fancy-default
Browse files Browse the repository at this point in the history
feat: Expand default functionality to enable fallback lookups.
  • Loading branch information
DanCardin authored Nov 26, 2024
2 parents de0fe5b + c46749e commit 98f5bfa
Show file tree
Hide file tree
Showing 29 changed files with 909 additions and 243 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.26

### 0.26.0

- Add `Default` object with associated fallback semantics for sequences of default handlers.
- Add `ValueFrom` for handling default_factory lazily, as well as arbitrary function dispatch.
- Add `State` as object accessible to invoke, Arg.parse, and ValueFrom.callable for sharing
state amongst different stages of argument parsing.
- fix: Skip non-init fields in dataclasses.

## 0.25

### 0.25.1
Expand Down
2 changes: 1 addition & 1 deletion docs/source/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

```{eval-rst}
.. autoapimodule:: cappa
:members: parse, invoke, invoke_async, collect, command, Command, Subcommand, Dep, Arg, ArgAction, Exit, Env, Completion, Output, FileMode, unpack_arguments, Group
:members: parse, invoke, invoke_async, collect, command, Command, Subcommand, Dep, Arg, ArgAction, Exit, Env, Completion, Output, FileMode, unpack_arguments, Group, Prompt, Confirm, ValueFrom, Default, State
```

```{eval-rst}
Expand Down
114 changes: 100 additions & 14 deletions docs/source/arg.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,20 @@ See [annotations](./annotation.md) for more details.

Controls the default argument value at the CLI level. Generally, you can avoid
direct use of cappa's default by simply using the source class' native default
mechanism. (i.e. `foo: int = 0` or `foo: int = field(default=0)` for
dataclasses).
mechanism. (i.e. `foo: int = 0`, `foo: int = field(default=0)`, or
`foo: list[str] = field(default_factory=list)` for dataclasses).

However it can be convenient to use cappa's default because it does not affect
the optionality of the field in question in the resultant class constructor.
However it **can** be convenient to use cappa's default because it does not affect
whether the underlying class definition makes that field required in the class'
constructor.

```{note}
The `default` value is not parsed by `parse`. That is to say, if no value is
selected at the CLI and the default value is used instead, it will not be
coerced into the annotated type automatically.
The `default` value is not **typically** parsed by the given `parse` function.
That is to say, if no value is selected at the CLI and the default value is used
instead; it will not be coerced into the annotated type automatically.

(`Env`, `Prompt`, and `Confirm` are exceptions to this rule, explained in their
sections below).

The reason for this is twofold:

Expand All @@ -196,21 +200,103 @@ The reason for this is twofold:
would infer `parse=Foo` and attempt to pass `Foo(Foo(''))` during parsing.
```

### Environment Variable Fallback
Additionally there are a number of natively integrated objects that can be used
as default to create more complex behaviors given a **missing** CLI argument.
The below objects will not be evaluated unless the user did not supply a value
for the argument it's attached to.

- [cappa.Default](cappa.Default)
- [cappa.Env](cappa.Env)
- [cappa.Prompt](cappa.Prompt)/[cappa.Confirm](cappa.Confirm)
- [cappa.ValueFrom](cappa.ValueFrom)

### `Default`

All other types of default are ultimately shorthand forms of `Default`. The
`Default` construct allows for specifying an ordered chain of default fallback
options, with an optional static fallback item at the end.

For example:

- `foo: int = 4` is the same as `default=Default(default=4)`. This unconditionally defaults to 4.

- `foo: Annotated[int, Arg(default=Env("FOO"))] = 4` is the same as
`Arg(default=Default(Env("FOO"), default=4)`. This attempts to read the environment variable `FOO`,
and falls back to 4 if the env var is unset.
- `foo: Annotated[int, Arg(default=Env("FOO") | Prompt("Gimme"))]` is the same as
`Arg(default=Default(Env("FOO"), Prompt("Gimme"))`. This attempts to read the environment variable `FOO`,
followed by a user prompt if the env var is unset.
As shown above, any combination of defaultable values can be used as fallbacks of one another by using
the `|` operator to chain them together.
You can also use the default field to supply supported kinds of
default-value-getting behaviors.
As noted above, a value produced by `Default.default` **does not** invoke the `Arg.parse` parser. This is
for similar reasons as to native dataclass defaults. The programmer is supplying the default value
which should not **need** to be parsed.
### `Env`
[cappa.Env](cappa.Env) performs environment variable lookups in an attempt to provide a value to the
class field.
`Env` is one such example, where with
`Arg(..., default=Env("FOO", default='default value'))`, cappa will attempt to
look up the environment variable `FOO` for the default value, if there was no
supplied value at the CLI level.
```{eval-rst}
.. autoapiclass:: cappa.Env
:noindex:
As noted above, a value produced by `Env` **does** invoke the `Arg.parse` parser. This is
because `Env` values will always be returned as a string, very similarly to a normal pre-parse
CLI value.
### `Prompt`/`Confirm`
[cappa.Prompt](cappa.Prompt) and [cappa.Confirm](cappa.Confirm) can be used to ask for user input
to fulfill the value.
```{note}
`rich.prompt.Prompt` and `rich.prompt.Confirm` can also be used transparently for the same purpose.
```
```python
import cappa
class Example:
value: Annotated[int, cappa.Arg(default=cappa.Prompt("A number value"))]
is_ok: Annotated[bool, cappa.Arg(default=cappa.Confirm("You sure?"))]
```
As noted above, a value produced by `Prompt`/`Confirm` **does** invoke the `Arg.parse` parser. This is
because these values will always be returned as a string, very similarly to a normal pre-parse
CLI value.
### `ValueFrom`
[cappa.ValueFrom](cappa.ValueFrom) is a means for calling an arbitrary function at mapping time,
to allow for dynamic default values.
```{info}
A dataclass `field(default_factory=list)` is internally the same thing as `default=ValueFrom(list)`!
```
```python
from pathlib import Path
import cappa
def load_default(key):
config = json.loads(Path("config.json").read_text())
return config[key]
class Example:
value: Annotated[int, cappa.Arg(default=cappa.ValueFrom(load_default, key='value'))]
```
This construct is able to be automatically supplied with [cappa.State](State), in the even shared
parse state is required to evaluate a field's default.
As noted above, a value produced by `ValueFrom` **does not** invoke the `Arg.parse` parser. This is
because the called function is programmer-supplied and can/should just return the correct end
value.
(arg-group)=
## `Arg.group`: Groups (and Mutual Exclusion)
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Help/Help Inference <help>
Asyncio <asyncio>
Manual Construction <manual_construction>
Sphinx/Docutils Directive <sphinx>
Shared State <state>
```

```{toctree}
Expand Down
13 changes: 4 additions & 9 deletions docs/source/rich.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ You can define your own rich `Theme` object and supply it into
[invoke](cappa.invoke) or [parse](cappa.parse), which will used when rendering
help-text output (and error messaging).

Cappa's theme defines and uses the follwing style groups, which you would need
Cappa's theme defines and uses the following style groups, which you would need
to also supply:

- `cappa.prog`
Expand All @@ -29,8 +29,7 @@ to also supply:

## Prompt/Confirm

Cappa does not come with a native prompt/confirm option. However it does ship
with built-in integration with `rich.prompt.Prompt`.
Cappa integrates directly with `rich.prompt.Prompt`/`rich.prompt.Confirm`.

You can directly make use of confirm/prompt from within your code anywhere, and
it should "just work"
Expand Down Expand Up @@ -59,12 +58,8 @@ In the event the value for that argument was omitted at the command-line, the
prompt will be evaluated.

```{note}
Input prompts can be a hazzard for testing. `cappa.rich.TestPrompt` can be used
in any CLI-level testing, which relocates rich's `default` and `stream` arguments
off the `.ask` function.
You can create a `TestPrompt("message", input='text input', default='some default')`
to simulate a user inputting values to stdin inside tests.
Input prompts can be a hazard for testing. As such, you can supply `input=StringIO(...)` to
either of `parse`/`invoke` as a way to write tests that exercise the prompt code.
```

## Pretty Tracebacks
Expand Down
44 changes: 44 additions & 0 deletions docs/source/state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# State

A [cappa.State](cappa.State) is ultimately a thin wrapper around a simple dictionary, that can
be used to share state among different parts of the overall cappa parsing process.

A `state=` argument can be supplied to [parse](cappa.parse) or [invoke](cappa.invoke), which accepts a
`State` instance. If no upfront `State` instance is supplied, one will be constructed automatically
so it can always be assumed to exist.

```{note}
It is also optionally generic over the dict type. So it can be annotated with `TypedDict` to retain
safety over the dict's fields.
```

```python
import cappa
from typing import Any, Annotated, Any
from dataclasses import dataclass

class CliState:
config: dict[str, Any]


def get_config(key: str, state: State[CliState]):
return state.state["config"][key]

@dataclass
class Example:
token: Annotated[str, cappa.Arg(default=cappa.ValueFrom(get_config, key="token"))]


config = load_config()
state = State({"config": config})
cappa.parse(Example, state=state)
```

The above example shows some pre-cappa data/state being loaded and provided to cappa through `state`.
Then some field accesses the shared `state` by getting it dependency injected into the `ValueFrom`
callable.

```{note}
`Arg.parse` and `invoke` functions can **also** accept `State` annotated inputs in order to
be provided with the `State` instance.
```
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cappa"
version = "0.25.1"
version = "0.26.0"
description = "Declarative CLI argument parser."

urls = {repository = "https://github.com/dancardin/cappa"}
Expand Down
8 changes: 7 additions & 1 deletion src/cappa/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from cappa.base import collect, command, invoke, invoke_async, parse
from cappa.command import Command
from cappa.completion.types import Completion
from cappa.env import Env
from cappa.default import Confirm, Default, Env, Prompt, ValueFrom
from cappa.file_io import FileMode
from cappa.help import HelpFormatable, HelpFormatter
from cappa.invoke import Dep
from cappa.output import Exit, HelpExit, Output
from cappa.parse import unpack_arguments
from cappa.state import State
from cappa.subcommand import Subcommand, Subcommands

# isort: split
Expand All @@ -21,6 +22,8 @@
"ArgAction",
"Command",
"Completion",
"Confirm",
"Default",
"Dep",
"Env",
"Exit",
Expand All @@ -30,8 +33,11 @@
"HelpFormatable",
"HelpFormatter",
"Output",
"Prompt",
"State",
"Subcommand",
"Subcommands",
"ValueFrom",
"argparse",
"backend",
"collect",
Expand Down
Loading

0 comments on commit 98f5bfa

Please sign in to comment.