Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,19 @@ 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

- Quick scrapy commandline tools for local development (e.g., webscraper CLI tool, or CLI application that runs a training algo)
- 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

Expand Down Expand Up @@ -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
Expand Down
89 changes: 64 additions & 25 deletions pydantic_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import datetime
import sys
import traceback
import warnings
import logging
import typing
from copy import deepcopy
Expand All @@ -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
Expand All @@ -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__)


Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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)
Expand All @@ -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)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion pydantic_cli/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "9.1.0"
__version__ = "10.0.0"
8 changes: 7 additions & 1 deletion pydantic_cli/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:

Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
16 changes: 14 additions & 2 deletions pydantic_cli/examples/simple_with_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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,
)
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down