diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ef9bad0..3f0e5f8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eaf832..dee21ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## Version 10.0.0 + +- Support for Python 3.14 +- color support for `--help` and error messages (requires Python >= 3.14) +- Improvement of `Field` display +- a bit of heavy-handed disabling of Pydantic warnings. + ## Version 7.0.0 - Drop support for python > 3.10 diff --git a/README.md b/README.md index 77c0d65..15226e0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ pip install pydantic-cli 1. Clear interface between the CLI and your application code 1. Leverage the static analyzing tool [**mypy**](http://mypy.readthedocs.io) to catch type errors in your commandline tool 1. Easy to test (due to reasons defined above) +1. Color support for `--help` and for displaying errors (with Python >= 3.14) ### Motivating Use cases @@ -30,11 +31,11 @@ pip install pydantic-cli - Internal tools driven by a Pydantic data model/schema - Configuration heavy tools that are driven by either partial (i.e, "presets") or complete configuration files defined using JSON -Note: Newer version of `Pydantic-settings` has support for commandline functionality. It allows mixing of "sources", such as ENV, YAML, JSON and might satisfy your requirements. +Note: Newer version of `pydantic-settings` has support for commandline functionality. It allows mixing of "sources", such as ENV, YAML, JSON and might satisfy your requirements. https://docs.pydantic.dev/2.8/concepts/pydantic_settings/#settings-management -`Pydantic-cli` predates the CLI component of `pydantic-settings` and has a few different requirements and design approach. +`pydantic-cli` predates the CLI component of `pydantic-settings` and has a few different requirements and design approach. ## Quick Start @@ -455,6 +456,9 @@ class CliConfig(ConfigDict, total=False): # https://github.com/iterative/shtab cli_shell_completion_enable: bool cli_shell_completion_flag: str + + # defaults to true, only supported for python >= 3.14 + cli_color: bool ``` ## AutoComplete leveraging shtab diff --git a/pydantic_cli/__init__.py b/pydantic_cli/__init__.py index a80f66b..e30967b 100644 --- a/pydantic_cli/__init__.py +++ b/pydantic_cli/__init__.py @@ -3,6 +3,7 @@ import datetime import sys import traceback +import warnings import logging import typing from copy import deepcopy @@ -11,9 +12,15 @@ import pydantic -from pydantic import BaseModel +from pydantic import BaseModel, PydanticDeprecatedSince20 from pydantic.fields import FieldInfo +# 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 +# have to figure out a better approach. Field(int, json_schema_extra=dict(cli=('-m', '--max-records')) +# is too verbose +warnings.filterwarnings("ignore", category=PydanticDeprecatedSince20) + from ._version import __version__ from .core import M, Tuple1or2Type, Tuple1Type, Tuple2Type @@ -26,13 +33,27 @@ from .argparse import CustomArgumentParser, EagerHelpAction from .argparse import _parser_add_help, _parser_add_version from .argparse import FailedExecutionException, TerminalEagerCommand -from argparse import ArgumentDefaultsHelpFormatter from .shell_completion import ( EmitShellCompletionAction, add_shell_completion_arg, HAS_AUTOCOMPLETE_SUPPORT, ) + +def _default_no_color(*args, **kwargs) -> bool: + return False + + +# Because these are ENV vars, this needs to be configure +# lazily and called close to the call site +try: + import _colorize + + CAN_COLORIZE = _colorize.can_colorize +except ImportError: + CAN_COLORIZE = _default_no_color + + log = logging.getLogger(__name__) @@ -154,7 +175,7 @@ def _add_pydantic_field_to_parser( cfield_info = deepcopy(field_info) cfield_info.json_schema_extra = None # write this to keep backward compat with 3.10 - help_ = "".join(["Field(", field_info.__repr_str__(", "), ")"]) + 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}") @@ -197,11 +218,16 @@ def pydantic_class_to_parser( in the Pydantic data model class. """ - p0 = CustomArgumentParser(description=description, add_help=False) + cli_config = _get_cli_config_from_model(cls) - p = _add_pydantic_class_to_parser(p0, cls, default_value_override) + if sys.version_info >= (3, 14): + p0 = CustomArgumentParser( + description=description, add_help=False, color=cli_config["cli_color"] + ) + else: + p0 = CustomArgumentParser(description=description, add_help=False) - cli_config = _get_cli_config_from_model(cls) + p = _add_pydantic_class_to_parser(p0, cls, default_value_override) if cli_config["cli_json_enable"]: _parser_add_arg_json_file(p, cli_config) @@ -220,29 +246,41 @@ def pydantic_class_to_parser( def _get_error_exit_code(ex: BaseException, default_exit_code: int = 1) -> int: if isinstance(ex, FailedExecutionException): exit_code = ex.exit_code + elif isinstance(ex, OSError): + exit_code = ex.errno else: exit_code = default_exit_code return exit_code -def default_exception_handler(ex: BaseException) -> int: +def _colorize_exception(ex: BaseException, file=sys.stderr) -> None: + if sys.version_info >= (3, 14): + # As of 3.14, colorize is still not a public API + traceback.print_exception(ex, colorize=CAN_COLORIZE(file=file), file=file) + else: + traceback.print_exception(ex, file=file) + + +def default_minimal_exception_handler(ex: BaseException) -> int: """ - Maps/Transforms the Exception type to an integer exit code + Only write a terse error message. Don't output the entire stacktrace """ - # this might need the opts instance, however - # this isn't really well-defined if there's an - # error at that level - sys.stderr.write(str(ex)) - exc_type, exc_value, exc_traceback = sys.exc_info() - traceback.print_tb(exc_traceback, file=sys.stderr) + _colorize_exception(ex, file=sys.stderr) return _get_error_exit_code(ex, 1) -def default_minimal_exception_handler(ex: BaseException) -> int: +# for backward compatibility. Also, wish the naming was consistent. +default_exception_handler = default_minimal_exception_handler +default_exception_handler_minimal = default_minimal_exception_handler + + +def default_exception_handler_verbose(ex: BaseException) -> int: """ - Only write a terse error message. Don't output the entire stacktrace + Verbose Exception + Entire Traceback """ - sys.stderr.write(str(ex)) + _colorize_exception(ex, file=sys.stderr) + exc_type, exc_value, exc_traceback = sys.exc_info() + traceback.print_tb(exc_traceback, file=sys.stderr) return _get_error_exit_code(ex, 1) @@ -324,8 +362,9 @@ def now() -> datetime.datetime: prologue_handler(cmd) # This should raise if there's an issue out = cmd.run() + # this is a check to make sure the caller has returned the correct type. if out is not None: - log.warning("Cmd.run() should return None or raise an exception.") + log.warning("Cmd.run() should return None or raise an exception.") # type: ignore[unreachable] exit_code = 0 except TerminalEagerCommand: exit_code = 0 @@ -447,18 +486,16 @@ def _to_subparser( overrides: dict[str, Any] | None = None, ) -> CustomArgumentParser: - p = CustomArgumentParser( - description=description, formatter_class=ArgumentDefaultsHelpFormatter - ) + if sys.version_info >= (3, 14): + p = CustomArgumentParser(description=description, add_help=False, color=True) + else: + p = CustomArgumentParser(description=description, add_help=False) # log.debug(f"Creating parser from models {models}") sp = p.add_subparsers( - dest="commands", help="Subparser Commands", parser_class=CustomArgumentParser + dest="commands", title="Subparser Commands", parser_class=CustomArgumentParser ) - # This fixes an unexpected case where the help isn't called? - # is this a Py2 to Py3 change? - sp.required = True overrides_defaults = {} if overrides is None else overrides for subparser_id, cmd in cmds.items(): @@ -479,6 +516,8 @@ def _to_subparser( spx.set_defaults(cmd=cmd) + _parser_add_help(p) + if version is not None: _parser_add_version(p, version) diff --git a/pydantic_cli/_version.py b/pydantic_cli/_version.py index ba28073..9158871 100644 --- a/pydantic_cli/_version.py +++ b/pydantic_cli/_version.py @@ -1 +1 @@ -__version__ = "9.1.0" +__version__ = "10.0.0" diff --git a/pydantic_cli/core.py b/pydantic_cli/core.py index a3d850d..adeb8e2 100644 --- a/pydantic_cli/core.py +++ b/pydantic_cli/core.py @@ -18,7 +18,7 @@ class Cmd(BaseModel): def run(self) -> None: ... -class CliConfig(ConfigDict, total=False): +class CliConfig(ConfigDict): """ See `_get_cli_config_from_model` for defaults. @@ -45,6 +45,9 @@ class CliConfig(ConfigDict, total=False): cli_shell_completion_enable: bool cli_shell_completion_flag: str + # Only supported for python >= 3.14 + cli_color: bool + def _get_cli_config_from_model(cls: type[M]) -> CliConfig: @@ -74,6 +77,8 @@ def _get_cli_config_from_model(cls: type[M]) -> CliConfig: cli_shell_completion_flag = cast( str, cls.model_config.get("cli_shell_completion_flag", "--emit-completion") ) + cli_color: bool = cast(bool, cls.model_config.get("cli_color", True)) + return CliConfig( cli_json_key=cli_json_key, cli_json_enable=cli_json_enable, @@ -82,4 +87,5 @@ def _get_cli_config_from_model(cls: type[M]) -> CliConfig: cli_json_validate_path=cli_json_validate_path, cli_shell_completion_enable=cli_shell_completion_enable, cli_shell_completion_flag=cli_shell_completion_flag, + cli_color=cli_color, ) diff --git a/pydantic_cli/examples/simple_with_custom.py b/pydantic_cli/examples/simple_with_custom.py index 7fdfcc6..62a1f25 100644 --- a/pydantic_cli/examples/simple_with_custom.py +++ b/pydantic_cli/examples/simple_with_custom.py @@ -4,7 +4,11 @@ from pydantic import Field -from pydantic_cli import __version__ +from pydantic_cli import ( + __version__, + default_exception_handler_verbose, + default_exception_handler, +) from pydantic_cli import run_and_exit, CliConfig, Cmd log = logging.getLogger(__name__) @@ -23,8 +27,16 @@ def run(self) -> None: log.info( f"pydantic_cli version={__version__} Mock example running with options {self}" ) + # for testing purposes + if self.max_records <= 0: + raise ValueError("max_records must be a positive integer") if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) - run_and_exit(Options, description="Description", version="0.1.0") + run_and_exit( + Options, + description="Description", + version="0.1.0", + exception_handler=default_exception_handler, + ) diff --git a/pyproject.toml b/pyproject.toml index 1482b1a..ca90734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Utilities",