diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a6976..4d6384b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ # CHANGELOG +## Version 6.0.0 + +- Backwards incompatible change. Use `Cmd` model. +- Remove "*sp*" specific functions. No longer necessary because of Cmd interface. +- To migrate forward, inherit from `Cmd` and put your "main" function as `Cmd.run` method. +- `Cmd.run()` should return None (on success) or raise an exception on error. + ## Version 5.0.0 (Pydantic 2 support) +(Not published) + - Support for Pydantic >= 2.8 - Pydantic 2 has a different "optional" definition - Use `CliConfig` instead of `DefaultConfig` diff --git a/README.md b/README.md index 3974da6..53fb282 100644 --- a/README.md +++ b/README.md @@ -55,23 +55,21 @@ Explicit example show below. ```python import sys +from pydantic_cli import run_and_exit, to_runner, Cmd -from pydantic import BaseModel -from pydantic_cli import run_and_exit, to_runner -class MinOptions(BaseModel): +class MinOptions(Cmd): input_file: str max_records: int + def run(self) -> None: + print(f"Mock example running with {self}") -def example_runner(opts: MinOptions) -> int: - print(f"Mock example running with options {opts}") - return 0 if __name__ == '__main__': # to_runner will return a function that takes the args list to run and # will return an integer exit code - sys.exit(to_runner(MinOptions, example_runner, version='0.1.0')(sys.argv[1:])) + sys.exit(to_runner(MinOptions, version='0.1.0')(sys.argv[1:])) ``` @@ -79,7 +77,7 @@ Or to implicitly use `sys.argv[1:]`, leverage `run_and_exit` (`to_runner` is als ```python if __name__ == '__main__': - run_and_exit(MinOptions, example_runner, description="My Tool Description", version='0.1.0') + run_and_exit(MinOptions, description="My Tool Description", version='0.1.0') ``` @@ -96,23 +94,21 @@ Custom 'short' or 'long' forms of the commandline args can be provided by using https://docs.pydantic.dev/latest/concepts/models/#required-fields ```python -from pydantic import BaseModel, Field -from pydantic_cli import run_and_exit +from pydantic import Field +from pydantic_cli import run_and_exit, Cmd -class MinOptions(BaseModel): +class MinOptions(Cmd): input_file: str = Field(..., description="Path to Input H5 file", cli=('-i', '--input-file')) max_records: int = Field(..., description="Max records to process", cli=('-m', '--max-records')) debug: bool = Field(False, description="Enable debugging mode", cli= ('-d', '--debug')) - -def example_runner(opts: MinOptions) -> int: - print(f"Mock example running with options {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with options {self}") if __name__ == '__main__': - run_and_exit(MinOptions, example_runner, description="My Tool Description", version='0.1.0') + run_and_exit(MinOptions, description="My Tool Description", version='0.1.0') ``` Running @@ -126,12 +122,16 @@ Mock example running with options MinOptions(input_file="input.hdf5", max_record Leveraging `Field` is also useful for validating inputs using the standard Pydantic for validation. ```python -from pydantic import BaseModel, Field +from pydantic import Field +from pydantic_cli import Cmd -class MinOptions(BaseModel): +class MinOptions(Cmd): input_file: str = Field(..., description="Path to Input H5 file", cli=('-i', '--input-file')) max_records: int = Field(..., gt=0, lte=1000, description="Max records to process", cli=('-m', '--max-records')) + + def run(self) -> None: + print(f"Mock example running with options {self}") ``` See [Pydantic docs](https://docs.pydantic.dev/latest/concepts/validators/) for more details. @@ -146,10 +146,9 @@ For example, given the following Pydantic data model with the `cli_json_enable = The `cli_json_key` will define the commandline argument (e.g., `config` will translate to `--config`). The default value is `json-config` (`--json-config`). ```python -from pydantic import BaseModel -from pydantic_cli import CliConfig, run_and_exit +from pydantic_cli import CliConfig, run_and_exit, Cmd -class Opts(BaseModel): +class Opts(Cmd): model_config = CliConfig( frozen=True, cli_json_key="json-training", cli_json_enable=True ) @@ -159,13 +158,12 @@ class Opts(BaseModel): min_filter_score: float alpha: float beta: float - -def runner(opts: Opts): - print(f"Running with opts:{opts}") - return 0 + + def run(self) -> None: + print(f"Running with opts:{self}") if __name__ == '__main__': - run_and_exit(Opts, runner, description="My Tool Description", version='0.1.0') + run_and_exit(Opts, description="My Tool Description", version='0.1.0') ``` @@ -258,37 +256,23 @@ With `pydantic-cli`, it's possible to catch these errors by running `mypy`. This For example, ```python -from pydantic import BaseModel - -from pydantic_cli import run_and_exit +from pydantic_cli import run_and_exit, Cmd -class Options(BaseModel): +class Options(Cmd): input_file: str max_records: int - -def bad_func(n: int) -> int: - return 2 * n - - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self.max_score}") if __name__ == "__main__": - run_and_exit(Options, bad_func, version="0.1.0") + run_and_exit(Options, version="0.1.0") ``` -With `mypy`, it's possible to proactively catch this types of errors. +With `mypy`, it's possible to proactively catch these types of errors. -```bash - mypy pydantic_cli/examples/simple.py ✘ 1 -pydantic_cli/examples/simple.py:36: error: Argument 2 to "run_and_exit" has incompatible type "Callable[[int], int]"; expected "Callable[[Options], int]" -Found 1 error in 1 file (checked 1 source file) - -``` ## Using Boolean Flags @@ -305,23 +289,21 @@ Consider a basic model: ```python from typing import Optional -from pydantic import BaseModel, Field -from pydantic_cli import run_and_exit +from pydantic import Field +from pydantic_cli import run_and_exit, Cmd -class Options(BaseModel): +class Options(Cmd): input_file: str max_records: int = Field(100, cli=('-m', '--max-records')) dry_run: bool = Field(default=False, description="Enable dry run mode", cli=('-d', '--dry-run')) filtering: Optional[bool] - - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 - + + def run(self) -> None: + print(f"Mock example running with {self}") + if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") ``` In this case, @@ -355,17 +337,17 @@ For example: ```python import sys -from pydantic import BaseModel, Field -from pydantic_cli import run_and_exit +from pydantic import Field +from pydantic_cli import run_and_exit, Cmd -class MinOptions(BaseModel): +class MinOptions(Cmd): input_file: str = Field(..., cli=('-i',)) max_records: int = Field(10, cli=('-m', '--max-records')) - -def example_runner(opts: MinOptions) -> int: - return 0 + def run(self) -> None: + # example/mock error raised. Will be mapped to exit code 3 + raise ValueError(f"No records found in input file {self.input_file}") def custom_exception_handler(ex: Exception) -> int: @@ -376,7 +358,7 @@ def custom_exception_handler(ex: Exception) -> int: if __name__ == '__main__': - run_and_exit(MinOptions, example_runner, exception_handler=custom_exception_handler) + run_and_exit(MinOptions, exception_handler=custom_exception_handler) ``` A general pre-execution hook can be called using the `prologue_handler`. This function is `Callable[[T], None]`, where `T` is an instance of your Pydantic data model. @@ -392,7 +374,7 @@ def custom_prologue_handler(opts) -> None: logging.basicConfig(level="DEBUG", stream=sys.stdout) if __name__ == '__main__': - run_and_exit(MinOptions, example_runner, prolgue_handler=custom_prologue_handler) + run_and_exit(MinOptions, prolgue_handler=custom_prologue_handler) ``` @@ -410,54 +392,40 @@ def custom_epilogue_handler(exit_code: int, run_time_sec:float) -> None: if __name__ == '__main__': - run_and_exit(MinOptions, example_runner, epilogue_handler=custom_epilogue_handler) + run_and_exit(MinOptions, epilogue_handler=custom_epilogue_handler) ``` ## SubParsers -Defining a subparser to your commandline tool is enabled by creating a container `SubParser` dict and calling `run_sp_and_exit` +Defining a subcommand to your commandline tool is enabled by creating a container of `dict[str, Cmd]` (with `str` is the subcommand name) into `run_and_exit` (or `to_runner`). ```python -import typing as T -from pydantic import BaseModel, AnyUrl, Field - +"""Example Subcommand Tool""" +from pydantic import AnyUrl, Field +from pydantic_cli import run_and_exit, Cmd -from pydantic_cli import run_sp_and_exit, SubParser - -class AlphaOptions(BaseModel): +class AlphaOptions(Cmd): input_file: str = Field(..., cli=('-i',)) max_records: int = Field(10, cli=('-m', '--max-records')) + + def run(self) -> None: + print(f"Running alpha with {self}") -class BetaOptions(BaseModel): +class BetaOptions(Cmd): + """Beta command for testing. Description of tool""" url: AnyUrl = Field(..., cli=('-u', '--url')) num_retries: int = Field(3, cli=('-n', '--num-retries')) - - -def printer_runner(opts: T.Any): - print(f"Mock example running with {opts}") - return 0 - - -def to_runner(sx): - def example_runner(opts) -> int: - print(f"Mock {sx} example running with {opts}") - return 0 - return example_runner - - -def to_subparser_example(): - - return { - 'alpha': SubParser(AlphaOptions, to_runner("Alpha"), "Alpha SP Description"), - 'beta': SubParser(BetaOptions, to_runner("Beta"), "Beta SP Description")} + + def run(self) -> None: + print(f"Running beta with {self}") if __name__ == "__main__": - run_sp_and_exit(to_subparser_example(), description=__doc__, version='0.1.0') + run_and_exit({"alpha": AlphaOptions, "beta": BetaOptions}, description=__doc__, version='0.1.0') ``` # Configuration Details and Advanced Features @@ -526,7 +494,7 @@ Note, that due to the (typically) global zsh completions directory, this can cre # General Suggested Testing Model At a high level, `pydantic_cli` is (hopefully) a thin bridge between your `Options` defined as a Pydantic model and your -main `runner(opts: Options)` func that has hooks into the startup, shutdown and error handling of the command line tool. +main `Cmd.run() -> None` method that has hooks into the startup, shutdown and error handling of the command line tool. It also supports loading config files defined as JSON. By design, `pydantic_cli` explicitly **does not expose, or leak the argparse instance** or implementation details. Argparse is a bit thorny and was written in a different era of Python. Exposing these implementation details would add too much surface area and would enable users' to start mucking with the argparse instance in all kinds of unexpected ways. @@ -536,38 +504,39 @@ Testing can be done by leveraging the `to_runner` interface. 1. It's recommend trying to do the majority of testing via unit tests (independent of `pydantic_cli`) with your main function and different instances of your pydantic data model. 2. Once this test coverage is reasonable, it can be useful to add a few smoke tests at the integration level leveraging `to_runner` to make sure the tool is functional. Any bugs at this level are probably at the `pydantic_cli` level, not your library code. -Note, that `to_runner(Opts, my_main)` returns a `Callable[[List[str]], int]` that can be used with `argv` to return an integer exit code of your program. The `to_runner` layer will also catch any exceptions. +Note, that `to_runner(Opts)` returns a `Callable[[List[str]], int]` that can be used with `sys.argv[1:]` to return an integer exit code of your program. The `to_runner` layer will also catch any exceptions. ```python import unittest -from pydantic import BaseModel -from pydantic_cli import to_runner +from pydantic_cli import to_runner, Cmd -class Options(BaseModel): +class Options(Cmd): alpha: int + + def run(self) -> None: + if self.alpha < 0: + raise Exception(f"Got options {self}. Forced raise for testing.") -def main(opts: Options) -> int: - if opts.alpha < 0: - raise Exception(f"Got options {opts}. Forced raise for testing.") - return 0 - class TestExample(unittest.TestCase): def test_core(self): # Note, this has nothing to do with pydantic_cli # If possible, this is where the bulk of the testing should be - self.assertEqual(0, main(Options(alpha=1))) + # You code should raise exceptions here or return None on success + self.assertTrue(Options(alpha=1).run() is None) def test_example(self): - f = to_runner(Options, main) - self.assertEqual(0, f(["--alpha", "100"])) + # This is intended to mimic end-to-end testing + # from argv[1:]. The exception handler will map exceptions to int exit codes. + f = to_runner(Options) + self.assertEqual(1, f(["--alpha", "100"])) def test_expected_error(self): - f = to_runner(Options, main) + f = to_runner(Options) self.assertEqual(1, f(["--alpha", "-10"])) ``` @@ -576,14 +545,15 @@ class TestExample(unittest.TestCase): For more scrappy, interactive local development, it can be useful to add `ipdb` or `pdb` and create a custom `exception_handler`. ```python -import sys -from pydantic import BaseModel -from pydantic_cli import default_exception_handler, run_and_exit +from pydantic_cli import default_exception_handler, run_and_exit, Cmd -class Options(BaseModel): +class Options(Cmd): alpha: int - + + def run(self) -> None: + if self.alpha < 0: + raise Exception(f"Got options {self}. Forced raise for testing.") def exception_handler(ex: BaseException) -> int: exit_code = default_exception_handler(ex) @@ -591,43 +561,10 @@ def exception_handler(ex: BaseException) -> int: return exit_code -def main(opts: Options) -> int: - if opts.alpha < 0: - raise Exception(f"Got options {opts}. Forced raise for testing.") - return 0 - - if __name__ == "__main__": - run_and_exit(Options, main, exception_handler=exception_handler) + run_and_exit(Options, exception_handler=exception_handler) ``` -Alternatively, wrap your main function to call `ipdb`. - -```python -import sys - -from pydantic import BaseModel -from pydantic_cli import run_and_exit - - -class Options(BaseModel): - alpha: int - - -def main(opts: Options) -> int: - if opts.alpha < 0: - raise Exception(f"Got options {opts}. Forced raise for testing.") - return 0 - - -def main_with_ipd(opts: Options) -> int: - import ipdb; ipdb.set_trace() - return main(opts) - - -if __name__ == "__main__": - run_and_exit(Options, main_with_ipd) -``` The core design choice in `pydantic_cli` is leveraging composable functions `f(g(x))` style providing a straight-forward mechanism to plug into. @@ -657,15 +594,18 @@ Positional arguments create friction points when combined with loading model val For example: ```python -from pydantic import BaseModel -from pydantic_cli import CliConfig +from pydantic_cli import CliConfig, Cmd + -class MinOptions(BaseModel): +class MinOptions(Cmd): model_config = CliConfig(cli_json_enable=True) input_file: str input_hdf: str max_records: int = 100 + + def run(self) -> None: + print(f"Running with mock {self}") ``` And the vanilla case running from the command line works as expected. @@ -699,15 +639,18 @@ In my experience, **the changing of the semantic meaning of the command line too The simplest fix is to remove the positional arguments in favor of `-i` or similar which removed the issue. ```python -from pydantic import BaseModel, Field -from pydantic_cli import run_and_exit, to_runner, CliConfig +from pydantic import Field +from pydantic_cli import CliConfig, Cmd -class MinOptions(BaseModel): +class MinOptions(Cmd): model_config = CliConfig(cli_json_enable=True) input_file: str = Field(..., cli=('-i', )) input_hdf: str = Field(..., cli=('-d', '--hdf')) max_records: int = Field(100, cli=('-m', '--max-records')) + + def run(self) -> None: + print(f"Running {self}") ``` Running with the `preset.json` defined above, works as expected. diff --git a/pydantic_cli/__init__.py b/pydantic_cli/__init__.py index 6027323..8bc1cdc 100644 --- a/pydantic_cli/__init__.py +++ b/pydantic_cli/__init__.py @@ -1,13 +1,16 @@ +import abc import collections import datetime import sys import traceback import logging import typing -import typing as T -from typing import Callable as F +from typing import overload +from typing import Any, Mapping, Callable + import pydantic +from pydantic import BaseModel from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined @@ -32,14 +35,11 @@ log = logging.getLogger(__name__) -NOT_PROVIDED = ... -NONE_TYPE = type(None) __all__ = [ + "Cmd", "to_runner", "run_and_exit", - "to_runner_sp", - "run_sp_and_exit", "default_exception_handler", "default_minimal_exception_handler", "default_prologue_handler", @@ -52,25 +52,19 @@ ] -class SubParser(T.Generic[M]): - def __init__( - self, - model_class: T.Type[M], - runner_func: F[[M], int], - description: T.Optional[str], - ): - self.model_class = model_class - self.runner_func = runner_func - self.description = description +class Cmd(BaseModel): + @abc.abstractmethod + def run(self) -> None: ... - def __repr__(self): - # not every func instance has __name__, e.g., functools.partial - name = getattr(self.runner_func, "__name__", str(self.runner_func)) - d = dict(k=str(self.model_class), f=name) - return "<{k} func:{f} >".format(**d) + +CmdKlassT = type[Cmd] +SubCmdKlassT = Mapping[str, CmdKlassT] +CmdOrSubCmdKlassT = CmdKlassT | SubCmdKlassT +NOT_PROVIDED = ... +NONE_TYPE = type(None) -def _is_sequence(annotation: T.Any) -> bool: +def _is_sequence(annotation: Any) -> bool: # FIXME There's probably a better and robust way to do this. # Lifted from pydantic LIST_TYPES: list[type] = [list, typing.List, collections.abc.MutableSequence] @@ -82,7 +76,7 @@ def _is_sequence(annotation: T.Any) -> bool: return getattr(annotation, "__origin__", "NOTFOUND") in ALL_SEQ -def __try_to_pretty_type(field_type) -> str: +def __try_to_pretty_type(field_type: Any) -> str: """ This is a marginal improvement to get the types to be displayed in slightly better format. @@ -106,16 +100,15 @@ def __try_to_pretty_type(field_type) -> str: def __to_type_description( - default_value=NOT_PROVIDED, - field_type=NOT_PROVIDED, + default_value: Any = NOT_PROVIDED, + field_type: Any = NOT_PROVIDED, allow_none: bool = False, is_required: bool = False, -): +) -> str: t = "" if field_type is NOT_PROVIDED else __try_to_pretty_type(field_type) - # FIXME Pydantic has a very odd default of None, which makes often can make the - # the "default" is actually None, or is not None + # avoid using in with a Set to avoid assumptions that default_value is hashable - allowed_defaults: T.List[T.Any] = ( + allowed_defaults: list[Any] = ( [NOT_PROVIDED, PydanticUndefined] if allow_none else [NOT_PROVIDED, PydanticUndefined, None, type(None)] @@ -137,7 +130,7 @@ def __process_tuple(tuple_one_or_two: Tuple1or2Type, long_arg: str) -> Tuple1or2 If the custom args are provided as only short, then add the long version. Or just use the """ - lx: T.List[str] = list(tuple_one_or_two) + lx: list[str] = list(tuple_one_or_two) nx = len(lx) if nx == 1: @@ -159,7 +152,7 @@ def _add_pydantic_field_to_parser( parser: CustomArgumentParser, field_id: str, field_info: FieldInfo, - override_value: T.Any = ..., + override_value: Any = ..., long_prefix: str = "--", ) -> CustomArgumentParser: """ @@ -230,10 +223,10 @@ def _add_pydantic_field_to_parser( def _add_pydantic_class_to_parser( - p: CustomArgumentParser, cls: T.Type[M], default_overrides: T.Dict[str, T.Any] + p: CustomArgumentParser, cmd: CmdKlassT, default_overrides: dict[str, Any] ) -> CustomArgumentParser: - for ix, field in cls.model_fields.items(): + for ix, field in cmd.model_fields.items(): override_value = default_overrides.get(ix, ...) _add_pydantic_field_to_parser(p, ix, field, override_value=override_value) @@ -241,10 +234,10 @@ def _add_pydantic_class_to_parser( def pydantic_class_to_parser( - cls: T.Type[M], - description: T.Optional[str] = None, - version: T.Optional[str] = None, - default_value_override=..., + cls: CmdKlassT, + description: str | None = None, + version: str | None = None, + default_value_override: Any = NOT_PROVIDED, ) -> CustomArgumentParser: """ Convert a pydantic data model class to an argparse instance @@ -310,7 +303,7 @@ def default_epilogue_handler(exit_code: int, run_time_sec: float) -> None: pass -def default_prologue_handler(opts: T.Any) -> None: +def default_prologue_handler(opts: Any) -> None: """ General Hook to call before executing your runner func (e.g., f(opt)). @@ -325,9 +318,9 @@ def default_prologue_handler(opts: T.Any) -> None: def _runner( - args: T.List[str], - setup_hook: F[[T.List[str]], T.Dict[str, T.Any]], - to_parser_with_overrides: F[[T.Dict[str, T.Any]], CustomArgumentParser], + args: list[str], + setup_hook: Callable[[list[str]], dict[str, Any]], + to_parser_with_overrides: Callable[[dict[str, Any]], CustomArgumentParser], exception_handler: ExceptionHandlerType, prologue_handler: PrologueHandlerType, epilogue_handler: EpilogueHandlerType, @@ -337,7 +330,7 @@ def _runner( supplied commandline args. """ - def now(): + def now() -> datetime.datetime: return datetime.datetime.now() # These initial steps are difficult to debug at times @@ -347,7 +340,7 @@ def now(): started_at = now() try: # this SHOULD NOT have an "Eager" command defined - custom_default_values: dict = setup_hook(args) + custom_default_values: dict[str, Any] = setup_hook(args) # this must already have a closure over the model(s) parser: CustomArgumentParser = to_parser_with_overrides(custom_default_values) @@ -357,30 +350,32 @@ def now(): # this is really only motivated by the subparser case # for the simple parser, the Pydantic class could just be passed in - cls = pargs.cls + cmd_cls: type[Cmd] = pargs.cmd # There's some slop in here using set_default(func=) hack/trick - # hence we have to explicitly define the expected type - runner_func: F[[T.Any], int] = pargs.func # log.debug(pargs.__dict__) d = pargs.__dict__ # This is a bit sloppy. There's some fields that are added # to the argparse namespace to get around some of argparse's thorny design - pure_keys = cls.model_json_schema()["properties"].keys() + pure_keys = cmd_cls.model_json_schema()["properties"].keys() # Remove the items that may have # polluted the namespace (e.g., func, cls, json_config) # to avoid leaking into the Pydantic data model. pure_d = {k: v for k, v in d.items() if k in pure_keys} - opts = cls(**pure_d) + cmd = cmd_cls(**pure_d) # this validation interface is a bit odd # and the errors aren't particularly pretty in the console - cls.model_validate(opts) - prologue_handler(opts) - exit_code = runner_func(opts) + cmd_cls.model_validate(cmd) + prologue_handler(cmd) + # This should raise if there's an issue + out = cmd.run() + if out is not None: + log.warning("Cmd.run() should return None or raise an exception.") + exit_code = 0 except TerminalEagerCommand: exit_code = 0 except Exception as e: @@ -392,7 +387,7 @@ def now(): return exit_code -def null_setup_hook(args: T.List[str]) -> T.Dict[str, T.Any]: +def null_setup_hook(args: list[str]) -> dict[str, Any]: return {} @@ -429,9 +424,7 @@ def create_parser_with_config_json_file_arg( return p -def setup_hook_to_load_json( - args: T.List[str], cli_config: CliConfig -) -> T.Dict[str, T.Any]: +def setup_hook_to_load_json(args: list[str], cli_config: CliConfig) -> dict[str, Any]: # This can't have HelpAction or any other "Eager" action defined parser = create_parser_with_config_json_file_arg(cli_config) @@ -441,7 +434,7 @@ def setup_hook_to_load_json( d = {} - # Arg parse will do some munging on this due to it's Namespace attribute style. + # Arg parse will do some munging on this due to its Namespace attribute style. json_config_path = getattr( pjargs, cli_config["cli_json_key"].replace("-", "_"), None ) @@ -452,35 +445,34 @@ def setup_hook_to_load_json( return d -def _runner_with_args( - args: T.List[str], - cls: T.Type[M], - runner_func: F[[M], int], - description: T.Optional[str] = None, - version: T.Optional[str] = None, +def _to_runner_with_args( + cmd: CmdKlassT, + *, + description: str | None = None, + version: str | None = None, exception_handler: ExceptionHandlerType = default_exception_handler, prologue_handler: PrologueHandlerType = default_prologue_handler, epilogue_handler: EpilogueHandlerType = default_epilogue_handler, -) -> int: - def to_p(default_override_dict: T.Dict[str, T.Any]) -> CustomArgumentParser: +) -> Callable[[list[str]], int]: + def to_p(default_override_dict: dict[str, Any]) -> CustomArgumentParser: # Raw errors at the argparse level aren't always # communicated in an obvious way at this level parser = pydantic_class_to_parser( - cls, + cmd, description=description, version=version, default_value_override=default_override_dict, ) - # this is a bit of hackery - parser.set_defaults(func=runner_func, cls=cls) + # call opts.run() downstream + parser.set_defaults(cmd=cmd) return parser - cli_config = _get_cli_config_from_model(cls) + cli_config = _get_cli_config_from_model(cmd) if cli_config["cli_json_enable"]: - def __setup(args: list[str]) -> T.Dict[str, T.Any]: + def __setup(args: list[str]) -> dict[str, Any]: c = cli_config.copy() c["cli_json_validate_path"] = False return setup_hook_to_load_json(args, c) @@ -488,78 +480,20 @@ def __setup(args: list[str]) -> T.Dict[str, T.Any]: else: __setup = null_setup_hook - return _runner( - args, __setup, to_p, exception_handler, prologue_handler, epilogue_handler - ) - - -class to_runner(T.Generic[M]): - """ - This is written as a class instead of simple function to get the Parametric Polymorphism to work correctly. - """ - - def __init__( - self, - cls: T.Type[M], - runner_func: F[[M], int], - description: T.Optional[str] = None, - version: T.Optional[str] = None, - exception_handler: ExceptionHandlerType = default_exception_handler, - prologue_handler: PrologueHandlerType = default_prologue_handler, - epilogue_handler: EpilogueHandlerType = default_epilogue_handler, - ): - self.cls = cls - self.runner_func = runner_func - self.description = description - self.version = version - self.exception_handler = exception_handler - self.prologue_handler = prologue_handler - self.epilogue_handler = epilogue_handler - - def __call__(self, args: T.List[str]) -> int: - return _runner_with_args( - args, - self.cls, - self.runner_func, - description=self.description, - version=self.version, - exception_handler=self.exception_handler, - prologue_handler=self.prologue_handler, - epilogue_handler=self.epilogue_handler, + def f(args: list[str]) -> int: + return _runner( + args, __setup, to_p, exception_handler, prologue_handler, epilogue_handler ) - -def run_and_exit( - cls: T.Type[M], - runner_func: F[[M], int], - description: T.Optional[str] = None, - version: T.Optional[str] = None, - exception_handler: ExceptionHandlerType = default_exception_handler, - prologue_handler: PrologueHandlerType = default_prologue_handler, - epilogue_handler: EpilogueHandlerType = default_epilogue_handler, - args: T.Optional[T.List[str]] = None, -) -> T.NoReturn: - - _args: T.List[str] = sys.argv[1:] if args is None else args - - sys.exit( - to_runner[M]( - cls, - runner_func, - description=description, - version=version, - exception_handler=exception_handler, - prologue_handler=prologue_handler, - epilogue_handler=epilogue_handler, - )(_args) - ) + return f -def to_subparser( - models: T.Dict[str, SubParser], - description: T.Optional[str] = None, - version: T.Optional[str] = None, - overrides: T.Optional[T.Dict[str, T.Any]] = None, +def _to_subparser( + cmds: SubCmdKlassT, + *, + description: str | None = None, + version: str | None = None, + overrides: dict[str, Any] | None = None, ) -> CustomArgumentParser: p = CustomArgumentParser( @@ -576,25 +510,23 @@ def to_subparser( sp.required = True overrides_defaults = {} if overrides is None else overrides - for subparser_id, sbm in models.items(): - log.debug(f"Adding subparser id={subparser_id} with {sbm}") + for subparser_id, cmd in cmds.items(): + log.debug(f"Adding subparser id={subparser_id} with {cmd}") spx: CustomArgumentParser = sp.add_parser( - subparser_id, help=sbm.description, add_help=False + subparser_id, help=cmd.__doc__, add_help=False ) - _add_pydantic_class_to_parser( - spx, sbm.model_class, default_overrides=overrides_defaults - ) + _add_pydantic_class_to_parser(spx, cmd, default_overrides=overrides_defaults) - cli_config = _get_cli_config_from_model(sbm.model_class) + cli_config = _get_cli_config_from_model(cmd) if cli_config["cli_json_enable"]: _parser_add_arg_json_file(spx, cli_config) _parser_add_help(spx) - spx.set_defaults(func=sbm.runner_func, cls=sbm.model_class) + spx.set_defaults(cmd=cmd) if version is not None: _parser_add_version(p, version) @@ -602,26 +534,27 @@ def to_subparser( return p -def to_runner_sp( - subparsers: T.Dict[str, SubParser], - description: T.Optional[str] = None, - version: T.Optional[str] = None, +def _to_runner_sp_with_args( + cmds: SubCmdKlassT, + *, + description: str | None = None, + version: str | None = None, exception_handler: ExceptionHandlerType = default_exception_handler, prologue_handler: PrologueHandlerType = default_prologue_handler, epilogue_handler: EpilogueHandlerType = default_epilogue_handler, -) -> F[[T.List[str]], int]: +) -> Callable[[list[str]], int]: # This is a bit messy. The design calling _runner requires a single setup hook. # in principle, there can be different json key names for each subparser # there's not really a clean way to support different key names (which # you probably don't want for consistency’s sake. - for sbm in subparsers.values(): - cli_config = _get_cli_config_from_model(sbm.model_class) + for cmd in cmds.values(): + cli_config = _get_cli_config_from_model(cmd) if cli_config["cli_json_enable"]: - def _setup_hook(args: T.List[str]) -> T.Dict[str, T.Any]: + def _setup_hook(args: list[str]) -> dict[str, Any]: # We allow the setup to fail if the JSON config isn't found c = cli_config.copy() c["cli_json_validate_path"] = False @@ -630,12 +563,12 @@ def _setup_hook(args: T.List[str]) -> T.Dict[str, T.Any]: else: _setup_hook = null_setup_hook - def _to_parser(overrides: T.Dict[str, T.Any]) -> CustomArgumentParser: - return to_subparser( - subparsers, description=description, version=version, overrides=overrides + def _to_parser(overrides: dict[str, Any]) -> CustomArgumentParser: + return _to_subparser( + cmds, description=description, version=version, overrides=overrides ) - def f(args: T.List[str]) -> int: + def f(args: list[str]) -> int: return _runner( args, _setup_hook, @@ -648,18 +581,107 @@ def f(args: T.List[str]) -> int: return f -def run_sp_and_exit( - subparsers: T.Dict[str, SubParser[M]], - description: T.Optional[str] = None, - version: T.Optional[str] = None, +@overload +def to_runner( + xs: CmdKlassT, + *, + description: str | None = None, + version: str | None = None, + exception_handler: ExceptionHandlerType = default_exception_handler, + prologue_handler: PrologueHandlerType = default_prologue_handler, + epilogue_handler: EpilogueHandlerType = default_epilogue_handler, +) -> Callable[[list[str]], int]: ... + + +@overload +def to_runner( + xs: SubCmdKlassT, + *, + description: str | None = None, + version: str | None = None, + exception_handler: ExceptionHandlerType = default_exception_handler, + prologue_handler: PrologueHandlerType = default_prologue_handler, + epilogue_handler: EpilogueHandlerType = default_epilogue_handler, +) -> Callable[[list[str]], int]: ... + + +def to_runner( + xs: CmdOrSubCmdKlassT, + *, + description: str | None = None, + version: str | None = None, + exception_handler: ExceptionHandlerType = default_exception_handler, + prologue_handler: PrologueHandlerType = default_prologue_handler, + epilogue_handler: EpilogueHandlerType = default_epilogue_handler, +) -> Callable[[list[str]], int]: + """ + Core method to return a func(list[str]) -> int + """ + + # FIXME. This runtime type checking should be more strict + # explicitly writing these out in each if block to avoid + # friction points with mypy. + if isinstance(xs, type(Cmd)): + return _to_runner_with_args( + xs, + description=description, + version=version, + exception_handler=exception_handler, + prologue_handler=prologue_handler, + epilogue_handler=epilogue_handler, + ) + elif isinstance(xs, dict): + return _to_runner_sp_with_args( + xs, + description=description, + version=version, + exception_handler=exception_handler, + prologue_handler=prologue_handler, + epilogue_handler=epilogue_handler, + ) + else: + raise ValueError(f"Invalid cmd {xs}") + + +@overload +def run_and_exit( + xs: CmdKlassT, + *, + description: str | None = None, + version: str | None = None, + exception_handler: ExceptionHandlerType = default_exception_handler, + prologue_handler: PrologueHandlerType = default_prologue_handler, + epilogue_handler: EpilogueHandlerType = default_epilogue_handler, + args: list[str] | None = None, +) -> typing.NoReturn: ... + + +@overload +def run_and_exit( + xs: SubCmdKlassT, + *, + description: str | None = None, + version: str | None = None, + exception_handler: ExceptionHandlerType = default_exception_handler, + prologue_handler: PrologueHandlerType = default_prologue_handler, + epilogue_handler: EpilogueHandlerType = default_epilogue_handler, + args: list[str] | None = None, +) -> typing.NoReturn: ... + + +def run_and_exit( + xs: CmdOrSubCmdKlassT, + *, + description: str | None = None, + version: str | None = None, exception_handler: ExceptionHandlerType = default_exception_handler, prologue_handler: PrologueHandlerType = default_prologue_handler, epilogue_handler: EpilogueHandlerType = default_epilogue_handler, - args: T.Optional[T.List[str]] = None, -) -> T.NoReturn: + args: list[str] | None = None, +) -> typing.NoReturn: - f = to_runner_sp( - subparsers, + f = to_runner( + xs, description=description, version=version, exception_handler=exception_handler, @@ -667,5 +689,5 @@ def run_sp_and_exit( epilogue_handler=epilogue_handler, ) - _args: T.List[str] = sys.argv[1:] if args is None else args + _args: list[str] = sys.argv[1:] if args is None else args sys.exit(f(_args)) diff --git a/pydantic_cli/_version.py b/pydantic_cli/_version.py index ba7be38..0f607a5 100644 --- a/pydantic_cli/_version.py +++ b/pydantic_cli/_version.py @@ -1 +1 @@ -__version__ = "5.0.0" +__version__ = "6.0.0" diff --git a/pydantic_cli/argparse.py b/pydantic_cli/argparse.py index 3cd0efc..c68180d 100644 --- a/pydantic_cli/argparse.py +++ b/pydantic_cli/argparse.py @@ -67,7 +67,7 @@ def __call__(self, parser, namespace, values, option_string=None): class CustomArgumentParser(ArgumentParser): - def exit(self, status: int = 0, message: T.Optional[str] = None) -> T.NoReturn: # type: ignore + def exit(self, status: int = 0, message: str | None = None) -> T.NoReturn: # type: ignore # THIS IS NO longer used because of the custom Version and Help # This is a bit of an issue to return the exit code properly # log.debug(f"{self} Class:{self.__class__.__name__} called exit()") @@ -77,7 +77,7 @@ def exit(self, status: int = 0, message: T.Optional[str] = None) -> T.NoReturn: ) -def _parser_add_help(p: CustomArgumentParser): +def _parser_add_help(p: CustomArgumentParser) -> CustomArgumentParser: p.add_argument( "--help", help="Print Help and Exit", action=EagerHelpAction, default=SUPPRESS ) diff --git a/pydantic_cli/core.py b/pydantic_cli/core.py index 3718c72..a3d850d 100644 --- a/pydantic_cli/core.py +++ b/pydantic_cli/core.py @@ -1,16 +1,21 @@ +import abc import os from pydantic import ConfigDict, BaseModel -from typing import Callable as F -import typing as T +from typing import Any, TypeVar, cast, Callable -M = T.TypeVar("M", bound=BaseModel) -Tuple1Type = T.Tuple[str] -Tuple2Type = T.Tuple[str, str] -Tuple1or2Type = T.Union[Tuple1Type, Tuple2Type] -EpilogueHandlerType = F[[int, float], None] -PrologueHandlerType = F[[T.Any], None] -ExceptionHandlerType = F[[BaseException], int] +M = TypeVar("M", bound=BaseModel) +Tuple1Type = tuple[str] +Tuple2Type = tuple[str, str] +Tuple1or2Type = Tuple1Type | Tuple2Type +EpilogueHandlerType = Callable[[int, float], None] +PrologueHandlerType = Callable[[Any], None] +ExceptionHandlerType = Callable[[BaseException], int] + + +class Cmd(BaseModel): + @abc.abstractmethod + def run(self) -> None: ... class CliConfig(ConfigDict, total=False): @@ -30,7 +35,7 @@ class CliConfig(ConfigDict, total=False): # Set the default ENV var for defining the JSON config path cli_json_config_env_var: str # Set the default Path for JSON config file - cli_json_config_path: T.Optional[str] + cli_json_config_path: str | None # If a default path is provided or provided from the commandline cli_json_validate_path: bool @@ -41,32 +46,32 @@ class CliConfig(ConfigDict, total=False): cli_shell_completion_flag: str -def _get_cli_config_from_model(cls: T.Type[M]) -> CliConfig: +def _get_cli_config_from_model(cls: type[M]) -> CliConfig: - cli_json_key = T.cast(str, cls.model_config.get("cli_json_key", "json-config")) - cli_json_enable: bool = T.cast(bool, cls.model_config.get("cli_json_enable", False)) - cli_json_config_env_var: str = T.cast( + cli_json_key = cast(str, cls.model_config.get("cli_json_key", "json-config")) + cli_json_enable: bool = cast(bool, cls.model_config.get("cli_json_enable", False)) + cli_json_config_env_var: str = cast( str, cls.model_config.get("cli_json_config_env_var", "PCLI_JSON_CONFIG") ) - cli_json_config_path_from_model: T.Optional[str] = T.cast( - T.Optional[str], cls.model_config.get("cli_json_config_path") + cli_json_config_path_from_model: str | None = cast( + str | None, cls.model_config.get("cli_json_config_path") ) - cli_json_validate_path: bool = T.cast( + cli_json_validate_path: bool = cast( bool, cls.model_config.get("cli_json_validate_path", True) ) # there's an important prioritization to be clear about here. # The env var will override the default set in the Pydantic Model Config # and the value of the commandline will override the ENV var - cli_json_config_path: T.Optional[str] = os.environ.get( + cli_json_config_path: str | None = os.environ.get( cli_json_config_env_var, cli_json_config_path_from_model ) - cli_shell_completion_enable: bool = T.cast( + cli_shell_completion_enable: bool = cast( bool, cls.model_config.get("cli_shell_completion_enable", False) ) - cli_shell_completion_flag = T.cast( + cli_shell_completion_flag = cast( str, cls.model_config.get("cli_shell_completion_flag", "--emit-completion") ) return CliConfig( diff --git a/pydantic_cli/examples/simple.py b/pydantic_cli/examples/simple.py index 6c74f3a..580b7ef 100644 --- a/pydantic_cli/examples/simple.py +++ b/pydantic_cli/examples/simple.py @@ -9,20 +9,16 @@ ``` """ -from pydantic import BaseModel +from pydantic_cli import run_and_exit, Cmd -from pydantic_cli import run_and_exit - -class Options(BaseModel): +class Options(Cmd): input_file: str max_records: int - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self}") if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") diff --git a/pydantic_cli/examples/simple_schema.py b/pydantic_cli/examples/simple_schema.py index ace6217..c973272 100644 --- a/pydantic_cli/examples/simple_schema.py +++ b/pydantic_cli/examples/simple_schema.py @@ -7,12 +7,12 @@ """ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field -from pydantic_cli import run_and_exit, HAS_AUTOCOMPLETE_SUPPORT, CliConfig +from pydantic_cli import run_and_exit, HAS_AUTOCOMPLETE_SUPPORT, CliConfig, Cmd -class Options(BaseModel): +class Options(Cmd): model_config = CliConfig( frozen=True, cli_shell_completion_enable=HAS_AUTOCOMPLETE_SUPPORT ) @@ -55,13 +55,11 @@ class Options(BaseModel): cli=("-n", "--filter-name"), ) - -def example_runner(opts: Options) -> int: - print(f"Mock example running with options {opts}") - for x in (opts.input_file, opts.max_records, opts.min_filter_score, opts.name): - print(f"{x} type={type(x)}") - return 0 + def run(self) -> None: + print(f"Mock example running with options {self}") + for x in (self.input_file, self.max_records, self.min_filter_score, self.name): + print(f"{x} type={type(x)}") if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_boolean.py b/pydantic_cli/examples/simple_with_boolean.py index 6643529..df29681 100644 --- a/pydantic_cli/examples/simple_with_boolean.py +++ b/pydantic_cli/examples/simple_with_boolean.py @@ -6,21 +6,17 @@ Note the optional boolean value must be supplied as `--run_training False` """ -from pydantic import BaseModel +from pydantic_cli import run_and_exit, Cmd -from pydantic_cli import run_and_exit - -class Options(BaseModel): +class Options(Cmd): input_file: str run_training: bool = True dry_run: bool = False - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self}") if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_boolean_and_config.py b/pydantic_cli/examples/simple_with_boolean_and_config.py index 68fc369..b348a0d 100644 --- a/pydantic_cli/examples/simple_with_boolean_and_config.py +++ b/pydantic_cli/examples/simple_with_boolean_and_config.py @@ -6,23 +6,21 @@ Note the optional boolean value must be supplied as `--run_training False` """ -from pydantic import BaseModel, Field +from pydantic import Field -from pydantic_cli import run_and_exit, CliConfig +from pydantic_cli import run_and_exit, CliConfig, Cmd -class Options(BaseModel): +class Options(Cmd): model_config = CliConfig(frozen=True) input_file: str run_training: bool = Field(default=False, cli=("-t", "--run-training")) dry_run: bool = Field(default=False, cli=("-r", "--dry-run")) - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self}") if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_boolean_custom.py b/pydantic_cli/examples/simple_with_boolean_custom.py index 75d8317..9ac0cd5 100644 --- a/pydantic_cli/examples/simple_with_boolean_custom.py +++ b/pydantic_cli/examples/simple_with_boolean_custom.py @@ -1,22 +1,21 @@ import logging 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, default_minimal_exception_handler, CliConfig +from pydantic_cli import run_and_exit, default_minimal_exception_handler, CliConfig, Cmd from pydantic_cli.examples import setup_logger class State(str, Enum): - """Note, this is case sensitive when providing it from the commandline""" + """Note, this is case-sensitive when providing it from the commandline""" RUNNING = "RUNNING" FAILED = "FAILED" SUCCESSFUL = "SUCCESSFUL" -class Options(BaseModel): +class Options(Cmd): model_config = CliConfig(frozen=True) # Simple Arg/Option can be added and a reasonable commandline "long" flag will be created. @@ -77,17 +76,14 @@ class Options(BaseModel): # 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: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self}") if __name__ == "__main__": logging.basicConfig(level="DEBUG") 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, diff --git a/pydantic_cli/examples/simple_with_custom.py b/pydantic_cli/examples/simple_with_custom.py index ed1dcf0..65308f6 100644 --- a/pydantic_cli/examples/simple_with_custom.py +++ b/pydantic_cli/examples/simple_with_custom.py @@ -1,32 +1,29 @@ import sys import logging -from typing import Union, List +from typing import Union -from pydantic import BaseModel, Field +from pydantic import Field from pydantic_cli import __version__ -from pydantic_cli import run_and_exit, CliConfig +from pydantic_cli import run_and_exit, CliConfig, Cmd log = logging.getLogger(__name__) -class Options(BaseModel): +class Options(Cmd): model_config = CliConfig(frozen=True) input_file: str = Field(..., cli=("-i", "--input")) max_records: int = Field(10, cli=("-m", "--max-records")) min_filter_score: float = Field(..., cli=("-f", "--filter-score")) alpha: Union[int, str] = 1 - # values: List[str] = ["a", "b", "c"] - -def example_runner(opts: Options) -> int: - log.info( - f"pydantic_cli version={__version__} Mock example running with options {opts}" - ) - return 0 + def run(self) -> None: + log.info( + f"pydantic_cli version={__version__} Mock example running with options {self}" + ) if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) - run_and_exit(Options, example_runner, description="Description", version="0.1.0") + run_and_exit(Options, description="Description", version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_custom_and_setup_log.py b/pydantic_cli/examples/simple_with_custom_and_setup_log.py index 7517572..509141d 100644 --- a/pydantic_cli/examples/simple_with_custom_and_setup_log.py +++ b/pydantic_cli/examples/simple_with_custom_and_setup_log.py @@ -18,14 +18,14 @@ from pydantic import BaseModel, Field -from pydantic_cli import __version__ +from pydantic_cli import __version__, Cmd from pydantic_cli import run_and_exit, CliConfig from pydantic_cli.examples import LogLevel log = logging.getLogger(__name__) -class Options(BaseModel): +class Options(Cmd): model_config = CliConfig(frozen=True) input_file: str = Field(..., cli=("-i", "--input")) @@ -33,6 +33,11 @@ class Options(BaseModel): # this leverages Pydantic's fundamental understanding of Enums log_level: LogLevel = LogLevel.INFO + def run(self) -> None: + log.info( + f"pydantic_cli version={__version__} Mock example running with options {self}" + ) + def prologue_handler(opts: Options): """Define a general Prologue hook to setup logging for the application""" @@ -52,17 +57,9 @@ def epilogue_handler(exit_code: int, run_time_sec: float): ) -def example_runner(opts: Options) -> int: - log.info( - f"pydantic_cli version={__version__} Mock example running with options {opts}" - ) - return 0 - - if __name__ == "__main__": run_and_exit( Options, - example_runner, description=__doc__, version="0.1.0", prologue_handler=prologue_handler, diff --git a/pydantic_cli/examples/simple_with_enum.py b/pydantic_cli/examples/simple_with_enum.py index ea1c0be..906b3ba 100644 --- a/pydantic_cli/examples/simple_with_enum.py +++ b/pydantic_cli/examples/simple_with_enum.py @@ -2,7 +2,7 @@ from typing import Set from pydantic import BaseModel -from pydantic_cli import run_and_exit +from pydantic_cli import run_and_exit, Cmd class Mode(str, Enum): @@ -22,16 +22,14 @@ class State(str, Enum): SUCCESSFUL = "SUCCESSFUL" -class Options(BaseModel): +class Options(Cmd): states: Set[State] mode: Mode max_records: int = 100 - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self}") if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_enum_by_name.py b/pydantic_cli/examples/simple_with_enum_by_name.py index 3b774e9..1833a6a 100644 --- a/pydantic_cli/examples/simple_with_enum_by_name.py +++ b/pydantic_cli/examples/simple_with_enum_by_name.py @@ -16,7 +16,7 @@ ) from pydantic_core import CoreSchema, core_schema -from pydantic_cli import run_and_exit +from pydantic_cli import run_and_exit, Cmd logger = logging.getLogger(__name__) @@ -81,7 +81,7 @@ def defaults(cls): STATE = Annotated[State, BeforeValidator(State.validate)] -class Options(BaseModel): +class Options(Cmd): states: Annotated[ Set[STATE], Field( @@ -96,11 +96,9 @@ class Options(BaseModel): ] max_records: int = 100 - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self}") if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_json_config.py b/pydantic_cli/examples/simple_with_json_config.py index 3c28e85..261103b 100644 --- a/pydantic_cli/examples/simple_with_json_config.py +++ b/pydantic_cli/examples/simple_with_json_config.py @@ -19,13 +19,13 @@ import logging from pydantic import BaseModel -from pydantic_cli import run_and_exit, CliConfig +from pydantic_cli import run_and_exit, CliConfig, Cmd from pydantic_cli.examples import epilogue_handler, prologue_handler log = logging.getLogger(__name__) -class Opts(BaseModel): +class Opts(Cmd): model_config = CliConfig( frozen=True, cli_json_key="json-training", cli_json_enable=True ) @@ -36,16 +36,13 @@ class Opts(BaseModel): alpha: float beta: float - -def runner(opts: Opts) -> int: - print(f"Running with opts:{opts}") - return 0 + def run(self) -> None: + print(f"Running with opts:{self}") if __name__ == "__main__": run_and_exit( Opts, - runner, description="My Tool Description", version="0.1.0", prologue_handler=prologue_handler, 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 210de8d..2993e0d 100644 --- a/pydantic_cli/examples/simple_with_json_config_not_found.py +++ b/pydantic_cli/examples/simple_with_json_config_not_found.py @@ -1,14 +1,12 @@ import sys import logging -from pydantic import BaseModel - -from pydantic_cli import run_and_exit, CliConfig +from pydantic_cli import run_and_exit, CliConfig, Cmd log = logging.getLogger(__name__) -class Options(BaseModel): +class Options(Cmd): """For cases where you want a global configuration file that is completely ignored if not found, you can set cli_json_config_path = False. @@ -24,12 +22,10 @@ class Options(BaseModel): input_file: str max_records: int = 10 - -def example_runner(opts: Options) -> int: - log.info(f"Mock example running with options {opts}") - return 0 + def run(self) -> None: + log.info(f"Mock example running with options {self}") if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) - run_and_exit(Options, example_runner, description="Description", version="0.1.0") + run_and_exit(Options, description="Description", version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_list.py b/pydantic_cli/examples/simple_with_list.py index 4bf51f0..b06950c 100644 --- a/pydantic_cli/examples/simple_with_list.py +++ b/pydantic_cli/examples/simple_with_list.py @@ -9,22 +9,17 @@ ``` """ -from typing import List, Set -from pydantic import BaseModel +from pydantic_cli import run_and_exit, Cmd -from pydantic_cli import run_and_exit - -class Options(BaseModel): +class Options(Cmd): input_file: list[str] filters: set[str] max_records: int - -def example_runner(opts: Options) -> int: - print(f"Mock example running with {opts}") - return 0 + def run(self) -> None: + print(f"Mock example running with {self}") if __name__ == "__main__": - run_and_exit(Options, example_runner, description=__doc__, version="0.1.0") + run_and_exit(Options, description=__doc__, version="0.1.0") diff --git a/pydantic_cli/examples/simple_with_shell_autocomplete_support.py b/pydantic_cli/examples/simple_with_shell_autocomplete_support.py index c64343d..b919816 100644 --- a/pydantic_cli/examples/simple_with_shell_autocomplete_support.py +++ b/pydantic_cli/examples/simple_with_shell_autocomplete_support.py @@ -6,30 +6,28 @@ import sys import logging -from pydantic import BaseModel, Field, ConfigDict +from pydantic import Field from pydantic_cli import __version__ -from pydantic_cli import run_and_exit, CliConfig +from pydantic_cli import run_and_exit, CliConfig, Cmd from pydantic_cli.shell_completion import HAS_AUTOCOMPLETE_SUPPORT log = logging.getLogger(__name__) -class Options(BaseModel): +class Options(Cmd): model_config = CliConfig(cli_shell_completion_enable=HAS_AUTOCOMPLETE_SUPPORT) input_file: str = Field(..., cli=("-i", "--input")) min_filter_score: float = Field(..., cli=("-f", "--filter-score")) max_records: int = Field(10, cli=("-m", "--max-records")) - -def example_runner(opts: Options) -> int: - log.info( - f"pydantic_cli version={__version__} Mock example running with options {opts}" - ) - return 0 + def run(self) -> None: + log.info( + f"pydantic_cli version={__version__} Mock example running with options {self}" + ) if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) - run_and_exit(Options, example_runner, description="Description", version="0.1.0") + run_and_exit(Options, description="Description", version="0.1.0") diff --git a/pydantic_cli/examples/subparser.py b/pydantic_cli/examples/subparser.py index e183047..308ccac 100644 --- a/pydantic_cli/examples/subparser.py +++ b/pydantic_cli/examples/subparser.py @@ -10,62 +10,46 @@ my-tool beta --help """ -import sys import logging -import typing as T -from pydantic import BaseModel, AnyUrl, Field +from typing import Mapping + +from pydantic import AnyUrl, Field from pydantic_cli.examples import LogLevel, prologue_handler -from pydantic_cli import run_sp_and_exit, SubParser, CliConfig +from pydantic_cli import Cmd, run_and_exit, CliConfig log = logging.getLogger(__name__) CLI_CONFIG = CliConfig(cli_json_enable=True, frozen=True) -class AlphaOptions(BaseModel): +class AlphaOptions(Cmd): model_config = CLI_CONFIG input_file: str = Field(..., cli=("-i", "--input")) max_records: int = Field(10, cli=("-m", "--max-records")) log_level: LogLevel = LogLevel.DEBUG + def run(self) -> None: + print(f"Mock example running with {self}") + -class BetaOptions(BaseModel): +class BetaOptions(Cmd): model_config = CLI_CONFIG url: AnyUrl = Field(..., cli=("-u", "--url")) num_retries: int = Field(3, cli=("-n", "--num-retries")) log_level: LogLevel = LogLevel.INFO + def run(self) -> None: + print(f"Mock example running with {self}") -def to_func(sx): - """Util func to create to custom mocked funcs that be used be each subparser""" - - def example_runner(opts) -> int: - print(f"Mock {sx} example running with {opts}") - return 0 - - return example_runner - - -def to_subparser_example() -> T.Dict[str, SubParser]: - """Simply create a dict of SubParser and pass the dict - to `run_sp_and_exit` or `to_runner_sp` - """ - return { - "alpha": SubParser[AlphaOptions]( - AlphaOptions, to_func("Alpha"), "Alpha SP Description" - ), - "beta": SubParser[BetaOptions]( - BetaOptions, to_func("Beta"), "Beta SP Description" - ), - } +CMDS: Mapping[str, type[Cmd]] = {"alpha": AlphaOptions, "beta": BetaOptions} if __name__ == "__main__": - run_sp_and_exit( - to_subparser_example(), + run_and_exit( + CMDS, description=__doc__, version="0.1.0", prologue_handler=prologue_handler, diff --git a/pydantic_cli/tests/__init__.py b/pydantic_cli/tests/__init__.py index 312f1e8..63fca21 100644 --- a/pydantic_cli/tests/__init__.py +++ b/pydantic_cli/tests/__init__.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from pydantic_cli import ( + Cmd, to_runner, default_prologue_handler, default_epilogue_handler, @@ -13,7 +14,7 @@ EpilogueHandlerType, ) -M = TypeVar("M", bound=BaseModel) +M = TypeVar("M", bound=Cmd) # Making this name a bit odd (from TestConfig) @@ -22,13 +23,11 @@ class HarnessConfig(Generic[M]): def __init__( self, - model_class: Type[M], - runner_func: F[[M], int], + cmd: Type[M], prologue: PrologueHandlerType = default_prologue_handler, epilogue: EpilogueHandlerType = default_epilogue_handler, ): - self.model = model_class - self.runner = runner_func + self.cmd = cmd self.prologue = prologue self.epilogue = epilogue @@ -36,12 +35,17 @@ def __init__( class _TestHarness(Generic[M], unittest.TestCase): CONFIG: HarnessConfig[M] - def run_config(self, args, exit_code=0): + def _to_exit(self, xs: None | int) -> int: + # this is to handle the old model. + # to_runner should now raise exceptions if there's + # an issue + return 0 if xs is None else xs + + def run_config(self, args: list[str], exit_code: int = 0): f = to_runner( - self.CONFIG.model, - self.CONFIG.runner, + self.CONFIG.cmd, prologue_handler=self.CONFIG.prologue, epilogue_handler=self.CONFIG.epilogue, ) - _exit_code = f(args) + _exit_code = self._to_exit(f(args)) self.assertEqual(_exit_code, exit_code) diff --git a/pydantic_cli/tests/test_examples_simple.py b/pydantic_cli/tests/test_examples_simple.py index bd25a40..775c172 100644 --- a/pydantic_cli/tests/test_examples_simple.py +++ b/pydantic_cli/tests/test_examples_simple.py @@ -1,11 +1,11 @@ from . import _TestHarness, HarnessConfig -from pydantic_cli.examples.simple import Options, example_runner +from pydantic_cli.examples.simple import Options class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config(["--input_file", "/path/to/file.txt", "--max_record", "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 e56cee8..c167e99 100644 --- a/pydantic_cli/tests/test_examples_simple_boolean_and_config.py +++ b/pydantic_cli/tests/test_examples_simple_boolean_and_config.py @@ -1,10 +1,10 @@ -from pydantic_cli.examples.simple_with_boolean_and_config import Options, example_runner +from pydantic_cli.examples.simple_with_boolean_and_config import Options from . import _TestHarness, HarnessConfig class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config(["--input_file", "/path/to/file.txt"]) diff --git a/pydantic_cli/tests/test_examples_simple_schema.py b/pydantic_cli/tests/test_examples_simple_schema.py index ce297dc..4b973d1 100644 --- a/pydantic_cli/tests/test_examples_simple_schema.py +++ b/pydantic_cli/tests/test_examples_simple_schema.py @@ -1,10 +1,10 @@ -from pydantic_cli.examples.simple_schema import Options, example_runner +from pydantic_cli.examples.simple_schema import Options from . import _TestHarness, HarnessConfig class TestExamples(_TestHarness): - CONFIG = HarnessConfig(Options, example_runner) + 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" diff --git a/pydantic_cli/tests/test_examples_simple_with_boolean.py b/pydantic_cli/tests/test_examples_simple_with_boolean.py index 207b927..ea5454a 100644 --- a/pydantic_cli/tests/test_examples_simple_with_boolean.py +++ b/pydantic_cli/tests/test_examples_simple_with_boolean.py @@ -1,10 +1,10 @@ -from pydantic_cli.examples.simple_with_boolean import Options, example_runner +from pydantic_cli.examples.simple_with_boolean import Options from . import _TestHarness, HarnessConfig class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config(["--input_file", "/path/to/file.txt"]) 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 37ea236..397f26f 100644 --- a/pydantic_cli/tests/test_examples_simple_with_boolean_custom.py +++ b/pydantic_cli/tests/test_examples_simple_with_boolean_custom.py @@ -1,10 +1,10 @@ -from pydantic_cli.examples.simple_with_boolean_custom import Options, example_runner +from pydantic_cli.examples.simple_with_boolean_custom import Options from . import _TestHarness, HarnessConfig class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config( diff --git a/pydantic_cli/tests/test_examples_simple_with_custom.py b/pydantic_cli/tests/test_examples_simple_with_custom.py index 6f507dc..e5fd091 100644 --- a/pydantic_cli/tests/test_examples_simple_with_custom.py +++ b/pydantic_cli/tests/test_examples_simple_with_custom.py @@ -1,10 +1,10 @@ -from pydantic_cli.examples.simple_with_custom import Options, example_runner +from pydantic_cli.examples.simple_with_custom import Options from . import _TestHarness, HarnessConfig class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config(["-i", "/path/to/file.txt", "-f", "1.0", "-m", "2"]) diff --git a/pydantic_cli/tests/test_examples_simple_with_custom_and_setup_log.py b/pydantic_cli/tests/test_examples_simple_with_custom_and_setup_log.py index 04f3122..1c7d27e 100644 --- a/pydantic_cli/tests/test_examples_simple_with_custom_and_setup_log.py +++ b/pydantic_cli/tests/test_examples_simple_with_custom_and_setup_log.py @@ -1,6 +1,5 @@ from pydantic_cli.examples.simple_with_custom_and_setup_log import ( Options, - example_runner, epilogue_handler, prologue_handler, ) @@ -10,7 +9,6 @@ class TestExamples(_TestHarness[Options]): CONFIG = HarnessConfig( Options, - example_runner, epilogue=epilogue_handler, prologue=prologue_handler, ) diff --git a/pydantic_cli/tests/test_examples_simple_with_enum.py b/pydantic_cli/tests/test_examples_simple_with_enum.py index 4eca452..83d84bf 100644 --- a/pydantic_cli/tests/test_examples_simple_with_enum.py +++ b/pydantic_cli/tests/test_examples_simple_with_enum.py @@ -1,11 +1,11 @@ from . import _TestHarness, HarnessConfig -from pydantic_cli.examples.simple_with_enum import Options, example_runner +from pydantic_cli.examples.simple_with_enum import Options class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): args = ["--states", "RUNNING", "FAILED", "--max_records", "1234", "--mode", "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 8405fff..60bc89a 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 @@ -1,11 +1,11 @@ from . import _TestHarness, HarnessConfig -from pydantic_cli.examples.simple_with_enum_by_name import Options, example_runner +from pydantic_cli.examples.simple_with_enum_by_name import Options class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): args = ["--states", "RUNNING", "FAILED", "--mode", "alpha"] diff --git a/pydantic_cli/tests/test_examples_simple_with_json_config_and_env.py b/pydantic_cli/tests/test_examples_simple_with_json_config_and_env.py index dc5b71c..1a84e0b 100644 --- a/pydantic_cli/tests/test_examples_simple_with_json_config_and_env.py +++ b/pydantic_cli/tests/test_examples_simple_with_json_config_and_env.py @@ -6,13 +6,12 @@ from pydantic_cli.examples.simple_with_json_config_not_found import ( Options, - example_runner, ) class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config(["--input_file", "/path/to/file.txt", "--max_record", "1234"]) diff --git a/pydantic_cli/tests/test_examples_simple_with_json_config_not_found.py b/pydantic_cli/tests/test_examples_simple_with_json_config_not_found.py index 9b25b18..2dfa118 100644 --- a/pydantic_cli/tests/test_examples_simple_with_json_config_not_found.py +++ b/pydantic_cli/tests/test_examples_simple_with_json_config_not_found.py @@ -2,13 +2,12 @@ from pydantic_cli.examples.simple_with_json_config_not_found import ( Options, - example_runner, ) class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config(["--input_file", "/path/to/file.txt", "--max_record", "1234"]) diff --git a/pydantic_cli/tests/test_examples_simple_with_list.py b/pydantic_cli/tests/test_examples_simple_with_list.py index fe1b920..811f266 100644 --- a/pydantic_cli/tests/test_examples_simple_with_list.py +++ b/pydantic_cli/tests/test_examples_simple_with_list.py @@ -1,11 +1,11 @@ from . import _TestHarness, HarnessConfig -from pydantic_cli.examples.simple_with_list import Options, example_runner +from pydantic_cli.examples.simple_with_list import Options class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): args = [ diff --git a/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py b/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py index faf6b31..b1e42b5 100644 --- a/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py +++ b/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py @@ -2,7 +2,6 @@ from pydantic_cli.examples.simple_with_shell_autocomplete_support import ( Options, - example_runner, ) from pydantic_cli.shell_completion import HAS_AUTOCOMPLETE_SUPPORT @@ -10,7 +9,7 @@ class TestExamples(_TestHarness[Options]): - CONFIG = HarnessConfig(Options, example_runner) + CONFIG = HarnessConfig(Options) def test_simple_01(self): self.run_config(["-i", "/path/to/file.txt", "-f", "1.0", "-m", "2"]) diff --git a/pydantic_cli/tests/test_examples_subparser.py b/pydantic_cli/tests/test_examples_subparser.py index 004ecfa..d9580be 100644 --- a/pydantic_cli/tests/test_examples_subparser.py +++ b/pydantic_cli/tests/test_examples_subparser.py @@ -2,17 +2,20 @@ import shlex from unittest import TestCase -from pydantic_cli import to_runner_sp -from pydantic_cli.examples.subparser import to_subparser_example +from pydantic_cli import to_runner +from pydantic_cli.examples.subparser import CMDS log = logging.getLogger(__name__) class TestExamples(TestCase): + def _to_exit(self, xs: None | int) -> int: + return 0 if xs is None else xs + def _run_with_args(self, args: str): - f = to_runner_sp(to_subparser_example()) + f = to_runner(CMDS) log.info(f"Running {f} with args {args}") - exit_code = f(shlex.split(args)) + exit_code = self._to_exit(f(shlex.split(args))) self.assertEqual(exit_code, 0) def test_alpha(self): diff --git a/pydantic_cli/tests/test_examples_with_json_config.py b/pydantic_cli/tests/test_examples_with_json_config.py index ba00b9b..fe638ae 100644 --- a/pydantic_cli/tests/test_examples_with_json_config.py +++ b/pydantic_cli/tests/test_examples_with_json_config.py @@ -2,18 +2,17 @@ import json from tempfile import NamedTemporaryFile -from pydantic_cli.examples.simple_with_json_config import Opts, runner +from pydantic_cli.examples.simple_with_json_config import Opts class TestExample(_TestHarness[Opts]): - CONFIG = HarnessConfig(Opts, runner) + CONFIG = HarnessConfig(Opts) def _util(self, d, more_args): with NamedTemporaryFile(mode="w", delete=True) as f: json.dump(d, f) f.flush() - f.name args = ["--json-training", str(f.name)] + more_args self.run_config(args) diff --git a/pydantic_cli/utils.py b/pydantic_cli/utils.py index 8fb615f..f95635c 100644 --- a/pydantic_cli/utils.py +++ b/pydantic_cli/utils.py @@ -11,14 +11,14 @@ def _load_json_file(json_path: str) -> T.Dict[str, T.Any]: return d -def _resolve_path_or_none(path: str) -> T.Optional[str]: +def _resolve_path_or_none(path: str) -> str | None: p = os.path.abspath(path) if os.path.exists(p): return p return None -def _resolve_file_or_none_and_warn(path: str) -> T.Optional[str]: +def _resolve_file_or_none_and_warn(path: str) -> str | None: p = _resolve_path_or_none(path) if p is None: warnings.warn(f"Unable to find {path}")