diff --git a/pydantic_cli/__init__.py b/pydantic_cli/__init__.py index e30967b..ae2a065 100644 --- a/pydantic_cli/__init__.py +++ b/pydantic_cli/__init__.py @@ -8,12 +8,13 @@ import typing from copy import deepcopy from typing import overload -from typing import Any, Mapping, Callable +from typing import Any, Mapping, Callable, get_origin import pydantic -from pydantic import BaseModel, PydanticDeprecatedSince20 +from pydantic import BaseModel, TypeAdapter, PydanticDeprecatedSince20 from pydantic.fields import FieldInfo +from pydantic.config import JsonDict # This is not great. Pydantic >= 3 changing Field will require backward incompatible # changes to the API of Field(int, cli=('-m', '--max-records')) and pydantic-cli will @@ -23,7 +24,6 @@ from ._version import __version__ -from .core import M, Tuple1or2Type, Tuple1Type, Tuple2Type from .core import EpilogueHandlerType, PrologueHandlerType, ExceptionHandlerType from .core import ( CliConfig, @@ -91,33 +91,7 @@ def _is_sequence(annotation: Any) -> bool: SET_TYPES: list[type] = [set, typing.Set, collections.abc.MutableSet] FROZEN_SET_TYPES: list[type] = [frozenset, typing.FrozenSet, collections.abc.Set] ALL_SEQ = set(LIST_TYPES + SET_TYPES + FROZEN_SET_TYPES) - - # what is exactly going on here? - return getattr(annotation, "__origin__", "NOTFOUND") in ALL_SEQ - - -@pydantic.validate_call -def __process_tuple(tuple_one_or_two: Tuple1or2Type, long_arg: str) -> Tuple1or2Type: - """ - If the custom args are provided as only short, then - add the long version. Or just use the - """ - lx: list[str] = list(tuple_one_or_two) - - nx = len(lx) - if nx == 1: - if len(lx[0]) == 2: # xs = '-s' - return lx[0], long_arg - else: - # this is the positional only case - return (lx[0],) - elif nx == 2: - # the explicit form is provided - return lx[0], lx[1] - else: - raise ValueError( - f"Unsupported format for `{tuple_one_or_two}` type={type(tuple_one_or_two)}. Expected 1 or 2 tuple." - ) + return get_origin(annotation) in ALL_SEQ def _add_pydantic_field_to_parser( @@ -151,15 +125,6 @@ def _add_pydantic_field_to_parser( it's not well-defined or supported. This should be List[T] """ - default_long_arg = "".join([long_prefix, field_id]) - # there's mypy type issues here - cli_custom_: Tuple1or2Type = ( - (default_long_arg,) - if field_info.json_schema_extra is None # type: ignore - else field_info.json_schema_extra.get("cli", (default_long_arg,)) # type: ignore - ) - cli_short_long: Tuple1or2Type = __process_tuple(cli_custom_, default_long_arg) - is_required = field_info.is_required() default_value = field_info.default is_sequence = _is_sequence(field_info.annotation) @@ -170,25 +135,32 @@ def _add_pydantic_field_to_parser( default_value = override_value is_required = False + if cli := isinstance(field_info.json_schema_extra, dict) and field_info.json_schema_extra.get("cli"): + # use custom cli annotation if provided + args = TypeAdapter(tuple[str, ...]).validate_python(cli) + else: + # positional if required, else named optional + args = () if is_required else (f"{long_prefix}{field_id}",) + # Delete cli and json_schema_extras metadata isn't in FieldInfo and won't be displayed # Not sure if this is the correct, or expected behavior. cfield_info = deepcopy(field_info) cfield_info.json_schema_extra = None # write this to keep backward compat with 3.10 - help_ = "".join(["Field(", cfield_info.__repr_str__(", "), ")"]) - - # log.debug(f"Creating Argument Field={field_id} opts:{cli_short_long}, allow_none={field.allow_none} default={default_value} type={field.type_} required={is_required} dest={field_id} desc={description}") - - # 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. - shape_kw = {"nargs": "+"} if is_sequence else {} + help_ = field_info.description or "".join(["Field(", field_info.__repr_str__(", "), ")"]) + + kwargs: dict[str, Any] = {} + if is_sequence: + kwargs["nargs"] = "+" + if len(args) > 0: + # only provide required to non-positional options + kwargs["required"] = is_required parser.add_argument( - *cli_short_long, + *args, help=help_, default=default_value, dest=field_id, - required=is_required, - **shape_kw, # type: ignore + **kwargs, ) return parser diff --git a/pydantic_cli/examples/simple.py b/pydantic_cli/examples/simple.py index 580b7ef..232700c 100644 --- a/pydantic_cli/examples/simple.py +++ b/pydantic_cli/examples/simple.py @@ -5,7 +5,7 @@ and can be called ```bash -my-tool --input_file file.fasta --max_records 10 +my-tool file.fasta 10 ``` """ diff --git a/pydantic_cli/examples/simple_with_enum.py b/pydantic_cli/examples/simple_with_enum.py index 906b3ba..2c0ea06 100644 --- a/pydantic_cli/examples/simple_with_enum.py +++ b/pydantic_cli/examples/simple_with_enum.py @@ -23,8 +23,8 @@ class State(str, Enum): class Options(Cmd): - states: Set[State] mode: Mode + states: Set[State] # sequence positionals must be after other positionals max_records: int = 100 def run(self) -> None: diff --git a/pydantic_cli/examples/simple_with_json_config_not_found.py b/pydantic_cli/examples/simple_with_json_config_not_found.py index 2993e0d..909e6bb 100644 --- a/pydantic_cli/examples/simple_with_json_config_not_found.py +++ b/pydantic_cli/examples/simple_with_json_config_not_found.py @@ -1,6 +1,8 @@ import sys import logging +from pydantic import Field + from pydantic_cli import run_and_exit, CliConfig, Cmd log = logging.getLogger(__name__) @@ -19,7 +21,7 @@ class Options(Cmd): cli_json_validate_path=False, ) - input_file: str + input_file: str = Field(cli=("--input_file",)) max_records: int = 10 def run(self) -> None: diff --git a/pydantic_cli/examples/simple_with_list.py b/pydantic_cli/examples/simple_with_list.py index b06950c..dd22e8b 100644 --- a/pydantic_cli/examples/simple_with_list.py +++ b/pydantic_cli/examples/simple_with_list.py @@ -9,13 +9,14 @@ ``` """ +from pydantic import Field from pydantic_cli import run_and_exit, Cmd class Options(Cmd): - input_file: list[str] - filters: set[str] - max_records: int + input_file: list[str] = Field(cli=("--input_file",)) + filters: set[str] = Field(cli=("--filters",)) + max_records: int = Field(cli=("--max_records",)) def run(self) -> None: print(f"Mock example running with {self}") diff --git a/pydantic_cli/tests/test_examples_simple.py b/pydantic_cli/tests/test_examples_simple.py index 775c172..c21e5cf 100644 --- a/pydantic_cli/tests/test_examples_simple.py +++ b/pydantic_cli/tests/test_examples_simple.py @@ -8,4 +8,4 @@ class TestExamples(_TestHarness[Options]): CONFIG = HarnessConfig(Options) def test_simple_01(self): - self.run_config(["--input_file", "/path/to/file.txt", "--max_record", "1234"]) + self.run_config(["/path/to/file.txt", "1234"]) diff --git a/pydantic_cli/tests/test_examples_simple_boolean_and_config.py b/pydantic_cli/tests/test_examples_simple_boolean_and_config.py index c167e99..24b5a97 100644 --- a/pydantic_cli/tests/test_examples_simple_boolean_and_config.py +++ b/pydantic_cli/tests/test_examples_simple_boolean_and_config.py @@ -7,14 +7,14 @@ class TestExamples(_TestHarness[Options]): CONFIG = HarnessConfig(Options) def test_simple_01(self): - self.run_config(["--input_file", "/path/to/file.txt"]) + self.run_config(["/path/to/file.txt"]) def test_simple_02(self): self.run_config( - ["--input_file", "/path/to/file.txt", "--run-training", "false"] + ["/path/to/file.txt", "--run-training", "false"] ) def test_simple_03(self): self.run_config( - ["--input_file", "/path/to/file.txt", "-r", "false", "--dry-run", "false"] + ["/path/to/file.txt", "-r", "false", "--dry-run", "false"] ) diff --git a/pydantic_cli/tests/test_examples_simple_required.py b/pydantic_cli/tests/test_examples_simple_required.py new file mode 100644 index 0000000..5fe3178 --- /dev/null +++ b/pydantic_cli/tests/test_examples_simple_required.py @@ -0,0 +1,10 @@ +from . import _TestHarness, HarnessConfig + +from pydantic_cli.examples.simple import Options + +class TestExamples(_TestHarness[Options]): + + CONFIG = HarnessConfig(Options) + + def test_simple_01(self): + self.run_config(["/path/to/file.txt", "1234"]) diff --git a/pydantic_cli/tests/test_examples_simple_schema.py b/pydantic_cli/tests/test_examples_simple_schema.py index 4b973d1..4a39c4d 100644 --- a/pydantic_cli/tests/test_examples_simple_schema.py +++ b/pydantic_cli/tests/test_examples_simple_schema.py @@ -7,7 +7,7 @@ class TestExamples(_TestHarness): CONFIG = HarnessConfig(Options) def test_01(self): - args = "-f /path/to/file.txt --max_records 1234 -s 1.234 --max-filter-score 10.234 -n none" + args = "-f /path/to/file.txt -m 1234 -s 1.234 --max-filter-score 10.234 -n none" self.run_config(args.split()) def test_02(self): @@ -15,4 +15,4 @@ def test_02(self): self.run_config(args.split()) def test_03(self): - self.run_config(["-f", "/path/to/file.txt", "-s", "1.234", "-n", "beta.v2"]) + self.run_config(["-f", "/path/to/file.txt", "-m", "1234", "-s", "1.234", "-n", "beta.v2"]) diff --git a/pydantic_cli/tests/test_examples_simple_with_boolean.py b/pydantic_cli/tests/test_examples_simple_with_boolean.py index ea5454a..e7b5f15 100644 --- a/pydantic_cli/tests/test_examples_simple_with_boolean.py +++ b/pydantic_cli/tests/test_examples_simple_with_boolean.py @@ -7,15 +7,14 @@ class TestExamples(_TestHarness[Options]): CONFIG = HarnessConfig(Options) def test_simple_01(self): - self.run_config(["--input_file", "/path/to/file.txt"]) + self.run_config(["/path/to/file.txt"]) def test_simple_02(self): - self.run_config(["--input_file", "/path/to/file.txt", "--run_training", "y"]) + self.run_config(["/path/to/file.txt", "--run_training", "y"]) def test_simple_03(self): self.run_config( [ - "--input_file", "/path/to/file.txt", "--run_training", "true", diff --git a/pydantic_cli/tests/test_examples_simple_with_boolean_custom.py b/pydantic_cli/tests/test_examples_simple_with_boolean_custom.py index 397f26f..09992fa 100644 --- a/pydantic_cli/tests/test_examples_simple_with_boolean_custom.py +++ b/pydantic_cli/tests/test_examples_simple_with_boolean_custom.py @@ -9,28 +9,20 @@ class TestExamples(_TestHarness[Options]): def test_simple_01(self): self.run_config( [ - "--input_file", "/path/to/file.txt", - "--input_file2", "/path/2.txt", "-f", "/path/to/file.h5", - "--report_json", "output.json", "--fasta", "output.fasta", - "--gamma", "true", - "--alpha", "true", - "--zeta_mode", "true", "--epsilon", "true", - "--states", "RUNNING", "FAILED", - "--filter_mode", "1", ] ) diff --git a/pydantic_cli/tests/test_examples_simple_with_enum.py b/pydantic_cli/tests/test_examples_simple_with_enum.py index 83d84bf..758a14a 100644 --- a/pydantic_cli/tests/test_examples_simple_with_enum.py +++ b/pydantic_cli/tests/test_examples_simple_with_enum.py @@ -8,17 +8,15 @@ class TestExamples(_TestHarness[Options]): CONFIG = HarnessConfig(Options) def test_simple_01(self): - args = ["--states", "RUNNING", "FAILED", "--max_records", "1234", "--mode", "1"] + args = ["--max_records", "1234", "1", "RUNNING", "FAILED"] self.run_config(args) def test_bad_enum_value(self): args = [ - "--states", - "RUNNING", - "BAD_STATE", "--max_records", "1234", - "--mode", "1", + "RUNNING", + "BAD_STATE", ] self.run_config(args, exit_code=1) diff --git a/pydantic_cli/tests/test_examples_simple_with_enum_by_name.py b/pydantic_cli/tests/test_examples_simple_with_enum_by_name.py index 60bc89a..63964fe 100644 --- a/pydantic_cli/tests/test_examples_simple_with_enum_by_name.py +++ b/pydantic_cli/tests/test_examples_simple_with_enum_by_name.py @@ -8,18 +8,18 @@ class TestExamples(_TestHarness[Options]): CONFIG = HarnessConfig(Options) def test_simple_01(self): - args = ["--states", "RUNNING", "FAILED", "--mode", "alpha"] + args = ["--states", "RUNNING", "FAILED", "--", "alpha"] self.run_config(args) def test_case_insensitive(self): - args = ["--states", "successful", "failed", "--mode", "ALPHA"] + args = ["--states", "successful", "failed", "--", "ALPHA"] self.run_config(args) def test_bad_enum_by_value(self): args = [ "--states", "RUNNING", - "--mode", + "--", "1", ] self.run_config(args, exit_code=1) @@ -28,7 +28,7 @@ def test_bad_enum_value(self): args = [ "--states", "RUNNING", - "--mode", + "--", "DRAGON", ] self.run_config(args, exit_code=1) diff --git a/pydantic_cli/tests/test_examples_simple_with_list.py b/pydantic_cli/tests/test_examples_simple_with_list.py index 811f266..b2c5b78 100644 --- a/pydantic_cli/tests/test_examples_simple_with_list.py +++ b/pydantic_cli/tests/test_examples_simple_with_list.py @@ -12,7 +12,7 @@ def test_simple_01(self): "--input_file", "/path/to/file.txt", "/and/another/file.txt", - "--max_record", + "--max_records", "1234", "--filters", "alpha", diff --git a/pydantic_cli/tests/test_examples_with_json_config.py b/pydantic_cli/tests/test_examples_with_json_config.py index fe638ae..313573c 100644 --- a/pydantic_cli/tests/test_examples_with_json_config.py +++ b/pydantic_cli/tests/test_examples_with_json_config.py @@ -9,9 +9,9 @@ class TestExample(_TestHarness[Opts]): CONFIG = HarnessConfig(Opts) - def _util(self, d, more_args): + def _run_cmd_with_config(self, config, more_args): with NamedTemporaryFile(mode="w", delete=True) as f: - json.dump(d, f) + json.dump(config, f) f.flush() args = ["--json-training", str(f.name)] + more_args self.run_config(args) @@ -24,9 +24,9 @@ def test_simple_json(self): alpha=1.234, beta=9.854, ) - self._util(opt.model_dump(), []) + self._run_cmd_with_config(opt.model_dump(), []) def test_simple_partial_json(self): - d = dict(max_records=12, min_filter_score=1.024, alpha=1.234, beta=9.854) + config = dict(max_records=12, min_filter_score=1.024, alpha=1.234, beta=9.854) - self._util(d, ["--hdf_file", "/path/to/file.hdf5"]) + self._run_cmd_with_config(config, ["/path/to/file.hdf5"])