Skip to content

Commit

Permalink
📝 Update dynamic source docs (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
edornd committed Aug 6, 2024
1 parent 53f4c05 commit 9651241
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 48 deletions.
2 changes: 1 addition & 1 deletion argdantic/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def argument_from_field(
assert not lenient_issubclass(field_info.annotation, BaseModel)
base_option_name = delimiter.join(parent_path + (kebab_name,))
full_option_name = f"--{base_option_name}"
extra_fields: dict[str, Any] = (
extra_fields: Dict[str, Any] = (
field_info.json_schema_extra or {} if isinstance(field_info.json_schema_extra, dict) else {}
)
extra_names = extra_fields.get("names", ())
Expand Down
8 changes: 4 additions & 4 deletions argdantic/core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import inspect
from argparse import ArgumentParser, Namespace, _SubParsersAction
from typing import Any, Callable, Generic, Iterable, List, Optional, Sequence, Type, TypeVar, cast, get_type_hints
from typing import Any, Callable, Dict, Generic, Iterable, List, Optional, Sequence, Type, TypeVar, cast, get_type_hints

from pydantic import BaseModel, ValidationError, create_model
from pydantic.v1.utils import lenient_issubclass
Expand Down Expand Up @@ -43,7 +43,7 @@ def __init__(
self.delimiter = delimiter
self.arguments = arguments or []
self.stores = stores or []
self.trackers: dict[str, ActionTracker] = {}
self.trackers: Dict[str, ActionTracker] = {}

def __repr__(self) -> str:
return f"<Command {self.name}>"
Expand Down Expand Up @@ -110,7 +110,7 @@ def __init__(
internal_delimiter: str = "__",
subcommand_meta: str = "<command>",
) -> None:
self.entrypoint: ArgumentParser | None = None
self.entrypoint: Optional[ArgumentParser] = None
self.name = name
self.description = description
self.force_group = force_group
Expand All @@ -125,7 +125,7 @@ def __init__(
self._subcommand_meta = subcommand_meta
# keeping a reference to subparser is necessary to add subparsers
# Each cli level can only have one subparser.
self._subparser: _SubParsersAction | None = None
self._subparser: Optional[_SubParsersAction] = None

def __repr__(self) -> str:
name = f" '{self.name}'" if self.name else ""
Expand Down
4 changes: 2 additions & 2 deletions argdantic/registry.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from collections.abc import MutableMapping
from typing import Any, Iterator, Type, Union, get_origin
from typing import Any, Dict, Iterator, Type, Union, get_origin


class Registry(MutableMapping):
"""Simple class registry for mapping types and their argument handlers."""

def __init__(self) -> None:
self.store: dict[type, Any] = dict()
self.store: Dict[type, Any] = dict()

def __getitem__(self, key: type) -> Any:
# do not allow Union types (unless they are Optional, handled in conversion)
Expand Down
12 changes: 6 additions & 6 deletions argdantic/sources/dynamic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Any, Optional, Tuple, Type, cast
from typing import Any, Dict, Optional, Tuple, Type, cast

from pydantic import BaseModel, ConfigDict
from pydantic_settings import BaseSettings, InitSettingsSource, PydanticBaseSettingsSource
Expand All @@ -16,9 +16,9 @@ class DynamicFileSource(PydanticBaseSettingsSource):

def __init__(
self,
settings_cls: type[BaseSettings],
source_cls: type[FileBaseSettingsSource],
init_kwargs: dict[str, Any],
settings_cls: Type[BaseSettings],
source_cls: Type[FileBaseSettingsSource],
init_kwargs: Dict[str, Any],
required: bool,
field_name: Optional[str] = None,
):
Expand All @@ -32,11 +32,11 @@ def __init__(
else:
self.source = source_cls(settings_cls, init_kwargs[self.field_name])

def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
def get_field_value(self, field: Any, field_name: str) -> Tuple[Any, str, bool]:
# Nothing to do here. Only implement the return statement to make mypy happy
return None, "", False # pragma: no cover

def __call__(self) -> dict[str, Any]:
def __call__(self) -> Dict[str, Any]:
if self.source is not None:
main_kwargs = self.source()
main_kwargs.update(self.init_kwargs)
Expand Down
92 changes: 60 additions & 32 deletions docs/guide/sources.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Input Sources
# Static Sources

`argdantic` allows you to define the arguments of your CLI in a variety of ways, including:

Expand Down Expand Up @@ -37,11 +37,11 @@ $ python sources.py
to define CLI arguments that may be set via file.
**Use default values or `None` as a workaround.**

## Sourced Models
## Dynamic Sources

Reading the full configuration may not be your cup of tea.
Sometimes you may want to define a model with its own fields, configurable from CLI, while also being able to read its configuration
from a file or environment variables.
Reading or writing a full configuration from scratch may not be your cup of tea.
Sometimes you may want to define a model with its own fields, reading its configuration
from a file, while still being able to override some of its fields from the command line.

Imagine you have a model like this:

Expand All @@ -54,37 +54,39 @@ class Fruit(BaseModel):
price: float
```

The CLI may define a `--fruit` argument to point to a file with the `Fruit` model, as well as a `--fruit.name` argument, or `--fruit.color` argument, etc.
The CLI may define a `--fruit` argument to point to a file with the content of a `Fruit` instance, as well as a `--fruit.name` argument, or `--fruit.color` argument, etc.

You can do that with ad-hoc models, named `YamlModel`, `JsonModel`, and `TomlModel`.
In argdantic, you can do that with the `from_file` annotation.

```python title="models.py" linenums="1" hl_lines="2 5 11"
{!examples/sources/models.py!}
```python title="dynamic.py" linenums="1" hl_lines="4 7 14"
{!examples/sources/dynamic.py!}
```

without additional configuration, the `from_file` decorator will automatically add an extra argument, equal to the name of the field, to the command line interface, in this case `--dataset` and `--optim`:

This will enable two extra arguments, namely `--dataset` and `--optim:

```console
$ python models.py --help
>usage: models.py [-h] [--dataset.name TEXT] [--dataset.batch-size INT] [--dataset.tile-size INT] [--dataset.shuffle | --no-dataset.shuffle] --dataset PATH
> [--optim.name TEXT] [--optim.learning-rate FLOAT] [--optim.momentum FLOAT] --optim PATH
```diff
$ python dynamic.py --help
> usage: models.py [-h] [--dataset.name TEXT] [--dataset.batch-size INT] [--dataset.tile-size INT] [--dataset.shuffle | --no-dataset.shuffle] --dataset PATH
> [--optim.name TEXT] [--optim.learning-rate FLOAT] [--optim.momentum FLOAT] --optim PATH
>
>options:
> -h, --help show this help message and exit
> --dataset.name TEXT (default: CIFAR10)
> --dataset.batch-size INT
> (default: 32)
> --dataset.tile-size INT
> (default: 256)
> --dataset.shuffle (default: True)
> --no-dataset.shuffle
> --dataset PATH (required)
> --optim.name TEXT (default: SGD)
> --optim.learning-rate FLOAT
> (default: 0.01)
> --optim.momentum FLOAT
> (default: 0.9)
> --optim PATH (required)
> options:
> -h, --help show this help message and exit
> --dataset.name TEXT (default: CIFAR10)
> --dataset.batch-size INT
> (default: 32)
> --dataset.tile-size INT
> (default: 256)
> --dataset.shuffle (default: True)
> --no-dataset.shuffle
+> --dataset PATH (required)
> --optim.name TEXT (default: SGD)
> --optim.learning-rate FLOAT
> (default: 0.01)
> --optim.momentum FLOAT
> (default: 0.9)
+> --optim PATH (required)
```

Invoking the command with the `--dataset` and `--optim` arguments will read the configuration from the files, which are defined as follows:
Expand All @@ -98,11 +100,37 @@ Invoking the command with the `--dataset` and `--optim` arguments will read the
```

```console
$ python models.py --dataset resources/dataset.yml --optim resources/optim.yml
$ python dynamic.py --dataset resources/dataset.yml --optim resources/optim.yml
> name='coco' batch_size=32 tile_size=512 shuffle=True
> name='adam' learning_rate=0.001 momentum=0.9
```

!!! warning
### Customizing the `from_file` behavior

The `from_file` decorator has a few options that can be used to customize its behavior:

- `required`: If `True`, the file path is required. If `False`, the file path is optional. Defaults to `True`.

- `loader`: A function that takes as input the model class itself, and the file path, and returns an instance of the model. `argdantic` provides three built-in loaders:
- `JsonFileLoader`
- `YamlFileLoader`
- `TomlFileLoader`

- `use_field`: When specified, the model field indicated by the string will be used as the file path to look for the configuration.
In this case, the extra argument will not be added to the command line interface, and the file path is naturally provided by the pydantic model itself. It may be useful when the file path is needed later on.

Here's an example providing both the `required` and `use_field` options:

```python title="dynamic_custom.py" linenums="1" hl_lines="4 7 14"
{!examples/sources/dynamic_custom.py!}
```

Specifying the following command will read the configuration from the optim instance only:

```diff
+$ python dynamic_custom.py --optim.path resources/optim.yml
> name='CIFAR10' batch_size=32 tile_size=256 shuffle=True
> path=PosixPath('resources/optim.yml') name='adam' learning_rate=0.001 momentum=0.9
```

`YamlModel`, `JsonModel`, and `TomlModel` are still experimental, and the API may change in the future.
Notice that the path this time is provided using a standard field, but the loader automatically reads the configuration from the specified file.
10 changes: 7 additions & 3 deletions examples/sources/models.py → examples/sources/dynamic.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
from pydantic import BaseModel

from argdantic import ArgParser
from argdantic.sources import YamlModel
from argdantic.sources import YamlFileLoader, from_file


class Optimizer(YamlModel):
@from_file(loader=YamlFileLoader)
class Optimizer(BaseModel):
name: str = "SGD"
learning_rate: float = 0.01
momentum: float = 0.9


class Dataset(YamlModel):
@from_file(loader=YamlFileLoader)
class Dataset(BaseModel):
name: str = "CIFAR10"
batch_size: int = 32
tile_size: int = 256
Expand Down
35 changes: 35 additions & 0 deletions examples/sources/dynamic_custom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from pathlib import Path

from pydantic import BaseModel

from argdantic import ArgParser
from argdantic.sources import YamlFileLoader, from_file


@from_file(loader=YamlFileLoader, use_field="path")
class Optimizer(BaseModel):
path: Path
name: str = "SGD"
learning_rate: float = 0.01
momentum: float = 0.9


@from_file(loader=YamlFileLoader, required=False)
class Dataset(BaseModel):
name: str = "CIFAR10"
batch_size: int = 32
tile_size: int = 256
shuffle: bool = True


cli = ArgParser()


@cli.command()
def create_item(optim: Optimizer, dataset: Dataset = Dataset()):
print(dataset)
print(optim)


if __name__ == "__main__":
cli()

0 comments on commit 9651241

Please sign in to comment.