Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 21 additions & 49 deletions pydantic_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pydantic_cli/examples/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
and can be called

```bash
my-tool --input_file file.fasta --max_records 10
my-tool file.fasta 10
```
"""

Expand Down
2 changes: 1 addition & 1 deletion pydantic_cli/examples/simple_with_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion pydantic_cli/examples/simple_with_json_config_not_found.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions pydantic_cli/examples/simple_with_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
2 changes: 1 addition & 1 deletion pydantic_cli/tests/test_examples_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
6 changes: 3 additions & 3 deletions pydantic_cli/tests/test_examples_simple_boolean_and_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)
10 changes: 10 additions & 0 deletions pydantic_cli/tests/test_examples_simple_required.py
Original file line number Diff line number Diff line change
@@ -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"])
4 changes: 2 additions & 2 deletions pydantic_cli/tests/test_examples_simple_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ 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):
args = "-f /path/to/file.txt -m 1234 -s 1.234 -S 10.234 --filter-name alphax"
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"])
5 changes: 2 additions & 3 deletions pydantic_cli/tests/test_examples_simple_with_boolean.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
)
8 changes: 3 additions & 5 deletions pydantic_cli/tests/test_examples_simple_with_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 4 additions & 4 deletions pydantic_cli/tests/test_examples_simple_with_enum_by_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -28,7 +28,7 @@ def test_bad_enum_value(self):
args = [
"--states",
"RUNNING",
"--mode",
"--",
"DRAGON",
]
self.run_config(args, exit_code=1)
2 changes: 1 addition & 1 deletion pydantic_cli/tests/test_examples_simple_with_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pydantic_cli/tests/test_examples_with_json_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"])