Skip to content

Commit

Permalink
Merge pull request #19 from explosion/feature/common-converter-utils
Browse files Browse the repository at this point in the history
Add commonly used types/converters to core library
  • Loading branch information
honnibal committed Mar 8, 2023
2 parents 6506669 + 5b0af7a commit f883696
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 44 deletions.
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ if __name__ == "__main__":

### Custom types and converters

The package includes several custom types implemented as `TypeVar`s with pre-defined converter functions. If these custom types are used in the decorated function, the values received from the CLI will be converted and validated accordingly.
The package includes several converters enabled by default, as well as custom types implemented as `NewType`s with pre-defined converter functions. If these types are used in the decorated function, the values received from the CLI will be converted and validated accordingly.

| Name | Type | Description |
| ------------------------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -619,3 +619,21 @@ The package includes several custom types implemented as `TypeVar`s with pre-def
| `ExistingFilePathOrDash` | `Union[Path, Literal["-"]]` | Returns an existing file path but also accepts `"-"` (typically used to indicate that a function should read from standard input). |
| `ExistingDirPathOrDash` | `Union[Path, Literal["-"]]` | Returns an existing directory path but also accepts `"-"` (typically used to indicate that a function should read from standard input). |
| `PathOrDash` | `Union[Path, Literal["-"]]` | Returns a path but also accepts `"-"` (typically used to indicate that a function should read from standard input). |
| `UUID` | `UUID` | Converts a value to a UUID. |
| `StrOrUUID` | `Union[str, UUID]` | Converts a value to a UUID if valid, otherwise returns the string. |

#### `get_list_converter`

Helper function that creates a list converter that takes a string of list items separated by a delimiter and returns a list of items of a given type. This can be useful if you prefer lists to be defined as comma-separated strings on the CLI instead of via repeated arguments.

```python
@cli.command("hello", items=Arg("--items", converter=get_list_converter(str)))
def hello(items: List[str]) -> None:
print(items)
```

| Argument | Type | Description |
| ----------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `type_func` | `Callable[[Any], Union[bool, int, float]]` | The function to convert the list items. Can be a builtin like `str` or `int`, or a custom converter function. |
| `delimiter` | `str` | Delimiter of the string. Defaults to `","`. |
| **RETURNS** | `Callable[[str], List]` | Converter function that converts a string to a list of the given type. |
7 changes: 4 additions & 3 deletions radicli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from .util import ArgparseArg, Arg, get_arg, format_type, DEFAULT_PLACEHOLDER
from .util import CommandNotFoundError, CliParserError, CommandExistsError
from .util import ConverterType, ConvertersType, ErrorHandlersType
from .util import ExistingPath, ExistingFilePath, ExistingDirPath
from .util import StaticData, ExistingPath, ExistingFilePath, ExistingDirPath
from .util import ExistingPathOrDash, ExistingFilePathOrDash, PathOrDash
from .util import ExistingDirPathOrDash
from .util import ExistingDirPathOrDash, StrOrUUID, get_list_converter

# fmt: off
__all__ = [
Expand All @@ -15,6 +15,7 @@
"CommandExistsError", "ConvertersType", "ConverterType", "ErrorHandlersType",
"DEFAULT_PLACEHOLDER", "ExistingPath", "ExistingFilePath", "ExistingDirPath",
"ExistingPathOrDash", "ExistingFilePathOrDash", "PathOrDash",
"ExistingDirPathOrDash", "StaticRadicli"
"ExistingDirPathOrDash", "StrOrUUID", "StaticRadicli", "StaticData",
"get_list_converter",
]
# fmt: on
38 changes: 9 additions & 29 deletions radicli/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
from typing import List, Iterator, Optional, Literal, TypeVar, Generic, Type, Union
from enum import Enum
from uuid import UUID
from dataclasses import dataclass
import pytest
import sys
from contextlib import contextmanager
import tempfile
import shutil
from zipfile import ZipFile
from pathlib import Path
from radicli import Radicli, StaticRadicli, Arg, get_arg, ArgparseArg
from radicli.util import CommandNotFoundError, CliParserError
from radicli.util import ExistingPath, ExistingFilePath, ExistingDirPath
from radicli.util import ExistingFilePathOrDash, DEFAULT_CONVERTERS
from radicli.util import stringify_type
from radicli.util import stringify_type, get_list_converter


@contextmanager
Expand Down Expand Up @@ -866,10 +866,10 @@ def test_static_deserialize_types(arg_type):
@pytest.mark.parametrize(
"arg_type",
[
UUID,
List[str],
Optional[UUID],
Union[UUID, str],
ZipFile,
Optional[ZipFile],
Union[ZipFile, str, Path],
CustomGeneric,
CustomGeneric[int],
CustomGeneric[str],
Expand All @@ -879,17 +879,16 @@ def test_static_deserialize_types(arg_type):
def test_static_deserialize_types_custom_deserialize(arg_type):
"""Test deserialization with custom type deserializer"""

def split_string(text: str) -> List[str]:
return [t.strip() for t in text.split(",")] if text else []
split_string = get_list_converter(str)

def convert_uuid(value: str) -> UUID:
return UUID(value)
def convert_zipfile(value: str) -> ZipFile:
return ZipFile(value, "r")

def convert_generic(value: str) -> str:
return f"generic: {value}"

converters = {
UUID: convert_uuid,
ZipFile: convert_zipfile, # new type with custom converter
List[str]: split_string,
CustomGeneric: convert_generic,
CustomGeneric[str]: str,
Expand All @@ -913,22 +912,3 @@ def convert_generic(value: str) -> str:
new_arg = ArgparseArg.from_static_json(arg_json)
assert new_arg.type is str
assert new_arg.orig_type == stringify_type(arg.orig_type)


@pytest.mark.parametrize(
"arg_type,expected",
[
(str, "str"),
(bool, "bool"),
(Path, "Path"),
(List[int], "List[int]"),
(CustomGeneric, "CustomGeneric"),
(CustomGeneric[str], "CustomGeneric[str]"),
(UUID, "UUID"),
(shutil.rmtree, "rmtree"),
("foo.bar", "foo.bar"),
(None, None),
],
)
def test_stringify_type(arg_type, expected):
assert stringify_type(arg_type) == expected
55 changes: 55 additions & 0 deletions radicli/tests/test_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from typing import Union, Generic, List, TypeVar
from pathlib import Path
from uuid import UUID
import pytest
import shutil
from radicli.util import stringify_type, get_list_converter

_KindT = TypeVar("_KindT", bound=Union[str, int, float, Path])


class CustomGeneric(Generic[_KindT]):
...


@pytest.mark.parametrize(
"arg_type,expected",
[
(str, "str"),
(bool, "bool"),
(Path, "Path"),
(List[int], "List[int]"),
(CustomGeneric, "CustomGeneric"),
(CustomGeneric[str], "CustomGeneric[str]"),
(UUID, "UUID"),
(shutil.rmtree, "rmtree"),
("foo.bar", "foo.bar"),
(None, None),
],
)
def test_stringify_type(arg_type, expected):
assert stringify_type(arg_type) == expected


@pytest.mark.parametrize(
"item_type,value,expected",
[
# Separated string
(str, "hello, world,test", ["hello", "world", "test"]),
(int, " 1,2,3 ", [1, 2, 3]),
(float, "0.123,5, 1.234", [0.123, 5.0, 1.234]),
# Quoted list
(str, "[hello, world]", ["hello", "world"]),
(str, '["hello", "world"]', ["hello", "world"]),
(str, "['hello', 'world']", ["hello", "world"]),
(int, "[1,2,3]", [1, 2, 3]),
(int, '["1","2","3"]', [1, 2, 3]),
(int, "['1','2','3']", [1, 2, 3]),
(float, "[0.23,2,3.45]", [0.23, 2.0, 3.45]),
(float, '["0.23","2","3.45"]', [0.23, 2.0, 3.45]),
(float, "['0.23','2','3.45']", [0.23, 2.0, 3.45]),
],
)
def test_get_list_converter(item_type, value, expected):
converter = get_list_converter(item_type)
assert converter(value) == expected
59 changes: 49 additions & 10 deletions radicli/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import List, Literal, NewType, get_args, get_origin, TypeVar
from typing import TypedDict, cast
from enum import Enum
from uuid import UUID
from dataclasses import dataclass
from pathlib import Path
import inspect
Expand Down Expand Up @@ -215,17 +216,10 @@ def deserialize_type(
data: StaticArg,
converters: ConvertersType = SimpleFrozenDict(),
) -> ArgTypeType:
# Setting some common defaults here to handle out-of-the-box
if data["type"] is None:
# No type or special args with no type
if data["type"] is None or data["action"] in ("store_true", "count"):
return None
types_map = {**BASE_TYPES_MAP}
for value in DEFAULT_CONVERTERS.values():
types_map[stringify_type(value)] = value # type: ignore
if data["action"] in ("store_true", "count"): # special args with no type
return None
if data["type"] in types_map:
return types_map[data["type"]]
# Handle unknown types: we use the orig_type here, since this corresponds to
# Handle custom types: we use the orig_type here, since this corresponds to
# what was actually set as an argument type hint
orig_type = data["orig_type"]
if orig_type is None:
Expand All @@ -238,6 +232,12 @@ def deserialize_type(
origin = orig_type.split("[", 1)[0]
if origin in converters_map:
return converters_map[orig_type.split("[", 1)[0]]
# Check defaults last to honor custom converters for builtins
types_map = {**BASE_TYPES_MAP}
for value in DEFAULT_CONVERTERS.values():
types_map[stringify_type(value)] = value # type: ignore
if data["type"] in types_map:
return types_map[data["type"]]
return str


Expand Down Expand Up @@ -405,6 +405,31 @@ def expand_error_subclasses(
return output


_InT = TypeVar("_InT", bound=Union[str, int, float])


def get_list_converter(
type_func: Callable[[Any], _InT] = str, delimiter: str = ","
) -> Callable[[str], List[_InT]]:
def converter(value: str) -> List[_InT]:
if not value:
return []
if value.startswith("[") and value.endswith("]"):
value = value[1:-1]
result = []
for p in value.split(delimiter):
p = p.strip()
if p.startswith("'") and p.endswith("'"):
p = p[1:-1]
if p.startswith('"') and p.endswith('"'):
p = p[1:-1]
p = type_func(p.strip())
result.append(p)
return result

return converter


def convert_existing_path(path_str: str) -> Path:
path = Path(path_str)
if not path.exists():
Expand Down Expand Up @@ -450,6 +475,17 @@ def convert_path_or_dash(path_str: str) -> Union[Path, str]:
return Path(path_str)


def convert_uuid(value: str) -> UUID:
return UUID(value)


def convert_str_or_uuid(value: str) -> Union[str, UUID]:
try:
return UUID(value)
except ValueError:
return value


# Custom path types for custom converters
ExistingPath = NewType("ExistingPath", Path)
ExistingFilePath = NewType("ExistingFilePath", Path)
Expand All @@ -459,6 +495,7 @@ def convert_path_or_dash(path_str: str) -> Union[Path, str]:
ExistingFilePathOrDash = Union[ExistingFilePath, Literal["-"]]
ExistingDirPathOrDash = Union[ExistingDirPath, Literal["-"]]
PathOrDash = Union[Path, Literal["-"]]
StrOrUUID = Union[str, UUID]


DEFAULT_CONVERTERS: ConvertersType = {
Expand All @@ -469,4 +506,6 @@ def convert_path_or_dash(path_str: str) -> Union[Path, str]:
ExistingFilePathOrDash: convert_existing_file_path_or_dash,
ExistingDirPathOrDash: convert_existing_dir_path_or_dash,
PathOrDash: convert_path_or_dash,
UUID: convert_uuid,
StrOrUUID: convert_str_or_uuid,
}
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[metadata]
version = 0.0.14
version = 0.0.15
description = Radically lightweight command-line interfaces
url = https://github.com/explosion/radicli
author = Explosion
Expand Down

0 comments on commit f883696

Please sign in to comment.