Skip to content

Commit

Permalink
Merge pull request #36 from mpkocher/new-bool-model
Browse files Browse the repository at this point in the history
New bool model with backward incompatible changes for 4.0.0. Use Field instead of CLI_EXTRA_OPTIONS
  • Loading branch information
mpkocher authored Aug 23, 2021
2 parents d11cc0e + 413d064 commit 2ca496f
Show file tree
Hide file tree
Showing 11 changed files with 371 additions and 248 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# CHANGELOG

## Version 4.0.0

- Backward incompatible change for semantics of boolean options
- `Field` should be used instead of Config.CLI_EXTRA_OPTIONS

## Version 3.4.0

- Improve support for simple `Enum`s.
Expand Down
260 changes: 130 additions & 130 deletions README.md

Large diffs are not rendered by default.

130 changes: 103 additions & 27 deletions pydantic_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,31 +68,43 @@ def __repr__(self):
return "<{k} func:{f} >".format(**d)


def __try_to_pretty_type(prefix, field_type) -> str:
def __try_to_pretty_type(field_type, allow_none: bool) -> str:
"""
This is a marginal improvement to get the types to be
displayed in slightly better format.
FIXME. This needs to be display Union types better.
"""
prefix = "Optional[" if allow_none else ""
suffix = "]" if allow_none else ""
try:
sx = field_type.__name__
return f"{prefix}:{sx}"
name = field_type.__name__
except AttributeError:
return f"{field_type}"
name = repr(field_type)
return "".join(["type:", prefix, name, suffix])


def __to_field_description(
default_value=NOT_PROVIDED, field_type=NOT_PROVIDED, description=None
def __to_type_description(
default_value=NOT_PROVIDED,
field_type=NOT_PROVIDED,
allow_none: bool = False,
is_required: bool = False,
):
desc = "" if description is None else description
t = "" if field_type is NOT_PROVIDED else __try_to_pretty_type("type", field_type)
v = "" if default_value is NOT_PROVIDED else f"default:{default_value}"
if not (t + v):
xs = "".join([t, v])
else:
xs = " ".join([t, v])
return f"{desc} ({xs})"
t = (
""
if field_type is NOT_PROVIDED
else __try_to_pretty_type(field_type, allow_none)
)
# FIXME Pydantic has a very odd default of None, which makes often can make the
# the "default" is actually None, or is not None
allowed_defaults: T.Set[T.Any] = (
{NOT_PROVIDED} if allow_none else {NOT_PROVIDED, None}
)
v = "" if default_value in allowed_defaults else f"default:{default_value}"
required = " required=True" if is_required else ""
sep = " " if v else ""
xs = sep.join([t, v]) + required
return xs


def __process_tuple(
Expand All @@ -116,7 +128,7 @@ def is_short(xs) -> int:
else:
# this is the positional only case
return (first,)
elif len(lx) == 2:
elif nx == 2:
# the explicit form is provided
return lx[0], lx[1]
else:
Expand All @@ -125,12 +137,35 @@ def is_short(xs) -> int:
)


def __add_boolean_arg_negate_to_parser(
parser: CustomArgumentParser,
field_id: str,
cli_custom: CustomOptsType,
default_value: bool,
type_doc: str,
description: T.Optional[str],
) -> CustomArgumentParser:
dx = {True: "store_true", False: "store_false"}
desc = description or ""
help_doc = f"{desc} ({type_doc})"
parser.add_argument(
*cli_custom,
action=dx[not default_value],
dest=field_id,
default=default_value,
help=help_doc,
)
return parser


def __add_boolean_arg_to_parser(
parser: CustomArgumentParser,
field_id: str,
cli_custom: CustomOptsType,
default_value: bool,
is_required: bool,
type_doc: str,
description: T.Optional[str],
) -> CustomArgumentParser:
# Overall this is a bit messy to add a boolean flag.

Expand All @@ -157,10 +192,11 @@ def __add_boolean_arg_to_parser(

for k, (bool_, store_bool) in zip(cli_custom, bool_datum):
if bool_ != default_value:
help_ = f"Set {field_id} to {bool_}"
desc = description or f"Set {field_id} to {bool_}."
help_doc = f"{desc} ({type_doc})"
group.add_argument(
k,
help=help_,
help=help_doc,
action=store_bool,
default=default_value,
dest=field_id,
Expand Down Expand Up @@ -202,6 +238,24 @@ def _add_pydantic_field_to_parser(
default_value = override_value
is_required = False

# The bool cases require some attention
# Cases
# 1. x:bool = False|True
# 2. x:bool
# 3 x:Optional[bool]
# 4 x:Optional[bool] = None
# 5 x:Optional[bool] = Field(...)
# 6 x:Optional[bool] = True|False

# cases 2-6 are handled in the same way with (--enable-X, --disable-X) semantics
# case 5 has limitations because you can't set None from the commandline
# case 1 is a very common cases and the provided CLI custom flags have a different semantic meaning
# to negate the default value. E.g., debug:bool = False, will generate a CLI flag of
# --enable-debug to set the value to True. Very common to set this to (-d, --debug) to True
is_bool_with_non_null_default = all(
(not is_required, not field.allow_none, default_value in {True, False})
)

try:
# cli_custom Should be a tuple2[Str, Str]
cli_custom: CustomOptsType = __process_tuple(
Expand All @@ -210,18 +264,25 @@ def _add_pydantic_field_to_parser(
except KeyError:
if override_cli is None:
if field.type_ == bool:
cli_custom = (
f"{bool_prefix[0]}{field_id}",
f"{bool_prefix[1]}{field_id}",
)
if is_bool_with_non_null_default:
# flipped to negate
prefix = {True: bool_prefix[1], False: bool_prefix[0]}
cli_custom = (f"{prefix[default_value]}{field_id}",)
else:
cli_custom = (
f"{bool_prefix[0]}{field_id}",
f"{bool_prefix[1]}{field_id}",
)
else:
cli_custom = (default_long_arg,)
else:
cli_custom = __process_tuple(override_cli, default_long_arg)

# log.debug(f"Creating Argument Field={field_id} opts:{cli_custom}, default={default_value} type={field.type_} required={is_required} dest={field_id}")
# log.debug(f"Creating Argument Field={field_id} opts:{cli_custom}, allow_none={field.allow_none} default={default_value} type={field.type_} required={is_required} dest={field_id} desc={description}")

help_doc = __to_field_description(default_value, field.type_, description)
type_desc = __to_type_description(
default_value, field.type_, field.allow_none, is_required
)

if field.shape in {pydantic.fields.SHAPE_LIST, pydantic.fields.SHAPE_SET}:
shape_kwargs = {"nargs": "+"}
Expand All @@ -236,15 +297,30 @@ def _add_pydantic_field_to_parser(
pass

if field.type_ == bool:
__add_boolean_arg_to_parser(
parser, field_id, cli_custom, default_value, is_required
)
# see comments above
# case #1 and has different semantic meaning with how the tuple[str,str] is
# interpreted and added to the parser
if is_bool_with_non_null_default:
__add_boolean_arg_negate_to_parser(
parser, field_id, cli_custom, default_value, type_desc, description
)
else:
__add_boolean_arg_to_parser(
parser,
field_id,
cli_custom,
default_value,
is_required,
type_desc,
description,
)
else:
# MK. I don't think there's any point trying to fight with argparse to get
# the types correct here. It's just a mess from a type standpoint.
desc = description or ""
parser.add_argument(
*cli_custom,
help=help_doc,
help=f"{desc} ({type_desc})",
default=default_value,
dest=field_id,
required=is_required,
Expand Down
2 changes: 1 addition & 1 deletion pydantic_cli/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "3.4.1"
__version__ = "4.0.0"
1 change: 1 addition & 0 deletions pydantic_cli/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class DefaultConfig:
# Can be used to override custom fields
# e.g., {"max_records": ('-m', '--max-records')}
# or {"max_records": ('-m', )}
# ****** THIS SHOULD NO LONGER BE USED **** Use pydantic.Field.
CLI_EXTRA_OPTIONS: T.Dict[str, CustomOptsType] = {}

# Customize the default prefix that is generated
Expand Down
94 changes: 70 additions & 24 deletions pydantic_cli/examples/simple_with_boolean_custom.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,84 @@
import logging
from typing import Optional, Tuple, Union
from enum import Enum
from typing import Optional, Union, Set
from pydantic import BaseModel
from pydantic.fields import Field

from pydantic_cli import run_and_exit, DefaultConfig, default_minimal_exception_handler
from pydantic_cli.examples import setup_logger


def _to_opt(sx: str) -> Tuple[str, str]:
def f(x):
return f"--{x}-{sx}"
class State(str, Enum):
"""Note, this is case sensitive when providing it from the commandline"""

return f("enable"), f("disable")
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"


class Options(BaseModel):
class Config(DefaultConfig):
pass

# Simple Arg/Option can be added and a reasonable commandline "long" flag will be created.
input_file: str
enable_alpha: bool
enable_beta: bool = False
enable_dragon: Union[str, int]
# https: // pydantic - docs.helpmanual.io / usage / models / # required-optional-fields
enable_gamma: Optional[
bool
] # ... Don't use ellipsis in Pydantic with mypy. This is a really fundamental problem.
enable_delta: Optional[bool] = None
enable_epsilon: Optional[
bool
] = True # this a bit of a contradiction from the commandline perspective.

class Config(DefaultConfig):
CLI_EXTRA_OPTIONS = {
"enable_alpha": _to_opt("alpha"),
"enable_beta": ("--yes-beta", "--no-beta"),
"enable_gamma": _to_opt("gamma"),
"enable_delta": _to_opt("delta"),
"enable_epsilon": _to_opt("epsilon"),
}
# The description can be customized by using pydantic's Field.
# Pydantic using `...` to semantically mean "required"
input_file2: str = Field(..., description="Path to input HDF5 file")

# Or customizing the CLI flag with a Tuple[str, str] of (short, long), or Tuple[str] of (long, )
input_file3: str = Field(
..., description="Path to input H5 file", extras={"cli": ("-f", "--hdf5")}
)

# https://pydantic-docs.helpmanual.io/usage/models/#required-optional-fields
# Pydantic has a bit of an odd model on how it treats Optional[T]
# These end up being indistinguishable.
outfile: Optional[str]
fasta: Optional[str] = None
# This is a "required" value that can be set to None, or str
report_json: Optional[str] = Field(...)

# Required Boolean options/flag can be added by
alpha: bool

# When specifying custom descriptions or flags as a pydantic Field, the flags should be specified as:
# (short, long) or (long, ) and be the OPPOSITE of the default value provide
beta_filter: bool = Field(
False,
description="Enable beta filter mode",
extras={"cli": ("-b", "--beta-filter")},
)

# Again, note Pydantic will treat these as indistinguishable
gamma: Optional[bool]
delta: Optional[bool] = None

# You need to set this to ... to declare it as "Required". The pydantic docs recommend using
# Field(...) instead of ... to avoid issues with mypy.
# pydantic-cli doesn't have a good mechanism for declaring this 3-state value of None, True, False.
# using a boolean commandline flag (e.g., --enable-logging, or --disable-logging)
zeta_mode: Optional[bool] = Field(
..., description="Enable/Disable Zeta mode to experimental filtering mode."
)

# this a bit of a contradiction from the commandline perspective. A "optional" value
# with a default value. From a pydantic-cli view, the type should just be 'bool' because this 3-state
# True, False, None is not well represented (i.e., can't set the value to None from the commandline)
# Similar to the other Optional[bool] cases, the custom flag must be provided as a (--enable, --disable) format.
epsilon: Optional[bool] = Field(
False,
description="Enable epsilon meta-analysis.",
extras={"cli": ("--epsilon", "--disable-epsilon")},
)

states: Set[State]

# The order of a Union is important. This doesn't really make any sense due to pydantic's core casting approach
# This should be Union[int, str], but even with case,
# there's an ambiguity. "1" will be cast to an int, which might not be the desired/expected results
filter_mode: Union[str, int]


def example_runner(opts: Options) -> int:
Expand All @@ -47,6 +91,8 @@ def example_runner(opts: Options) -> int:
run_and_exit(
Options,
example_runner,
version="2.0.0",
description="Example Commandline tool for demonstrating how custom fields/flags are communicated",
exception_handler=default_minimal_exception_handler,
prologue_handler=setup_logger,
)
34 changes: 6 additions & 28 deletions pydantic_cli/examples/simple_with_custom.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,8 @@
"""
Example using pydantic-cli to generate a custom CLI fields on a per field basis
using the "quick" method.
The Pydantic Config can be used to override custom fields
For example, `CLI_EXTRA_OPTIONS` dict can be set to the
short and/or long argument form.
Set the value in the dict `-m` to add a "short" arg (the long default form will also be
automatically added).
CLI_EXTRA_OPTIONS = {"max_records": ('-m', )}
Or
CLI_EXTRA_OPTIONS = {"max_records": ('-m', '--max-records')}
"""
import sys
import logging
from typing import Union

from pydantic import BaseModel
from pydantic import BaseModel, Field

from pydantic_cli import __version__
from pydantic_cli import run_and_exit, DefaultConfig
Expand All @@ -31,15 +13,11 @@

class Options(BaseModel):
class Config(ExampleConfigDefaults, DefaultConfig):
CLI_EXTRA_OPTIONS = {
"max_records": ("-m",),
"min_filter_score": ("-f",),
"input_file": ("-i",),
}

input_file: str
min_filter_score: float
max_records: int = 10
pass

input_file: str = Field(..., extras={"cli": ("-i", "--input")})
max_records: int = Field(10, extras={"cli": ("-m", "--max-records")})
min_filter_score: float = Field(..., extras={"cli": ("-f", "--filter-score")})
alpha: Union[int, str] = 1


Expand Down
Loading

0 comments on commit 2ca496f

Please sign in to comment.