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
45 changes: 28 additions & 17 deletions docs/_src/inputs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,20 +118,28 @@ In addition to plain text or binary files, custom input handlers also exist for
and a generic handler (:class:`.Serialized`) exists for any other serialization format. They all extend
:ref:`inputs:File`, so the same options are accepted.

.. _serialized_init_params:
.. version-changed:: 2026-04-TBD

A breaking change was made to the generic :class:`.Serialized` class to remove support for a single ``converter``
callable that handled either serialization xor deserialization. Instead, it now requires a ``serializer`` that
provides an interface with ``load`` / ``dump`` and/or ``loads`` / ``dumps``, similar to the *json* and *pickle*
modules.

Additionally, the ``pass_file`` parameter was removed from :class:`.Json`, :class:`.Pickle`, and
:class:`.Serialized`. When the provided ``serializer`` has a ``load`` or ``dump`` attribute, it will always be
preferred over the ``loads`` / ``dumps`` variants.

**Additional Serialized initialization parameters:**

:converter: The function to call to serialize or deserialize the content in the specified file
:pass_file: True to call the given function with the file, False to handle (de)serialization and read/write as
separate steps. If True, when reading, the converter will be called with the file as the only argument; when writing,
the converter will be called as ``converter(data, f)``. If False, when reading, the converter will be called with
the content from the file; when writing, the converter will be called before writing the data to the file.
.. _serialized_init_params:

**Additional Serialized initialization parameters:**

The JSON and Pickle handlers do not accept the above 2 parameters. The converter is automatically picked to be
``dump`` or ``load`` based on whether the provided ``mode`` is for reading or writing, and the ``pass_file``
option will be overridden if provided.
:serializer: Class or module that provides ``load``/``dump`` and/or ``loads``/``dumps`` methods/functions for
deserialization and serialization, respectively. Expects them to follow the same interface as the *json* or
*pickle* modules, with :func:`python:json.loads`, :func:`python:json.dumps`, :func:`python:pickle.load`, etc.
:lazy: If True, a :class:`.SerializedFileWrapper` will be stored in the Parameter using this file, otherwise the file
will be eagerly read immediately upon parsing of the path argument. When planning to write serialized data to a file,
only the default ``lazy=True`` is supported - eager writes are not supported.


Adding another snippet to the above :gh_examples:`example <custom_inputs.py>`::
Expand All @@ -156,16 +164,19 @@ We can see that the JSON content from stdin was automatically deserialized when
[1] ('b', 2)


When using the generic :class:`.Serialized` directly, the specific (de)serialization function needs to be provided::
When using the generic :class:`.Serialized` directly, the module/object needs to be provided::

Serialized(pickle, mode='rb', lazy=False) # Read pickled data eagerly upon accessing the attribute

Serialized(json, lazy=False) # Read JSON data eagerly upon accessing the attribute

Serialized(pickle.loads, mode='rb', lazy=False)
Serialized(pickle.load, pass_file=True, mode='rb', lazy=False)
Serialized(json, mode='w') # Provides a file wrapper with a `write` method

Serialized(json.loads, lazy=False)
Serialized(json.load, pass_file=True, lazy=False)

Serialized(json.dumps, mode='w')
Serialized(json.dump, pass_file=True, mode='w')
Any module or object that provides an interface similar to the :mod:`python:json` and :mod:`python:pickle` stdlib
modules is accepted as a *serializer*. It must implement a subset of ``load`` / ``dump`` and/or ``loads`` / ``dumps``
methods or functions. When reading, ``load`` is always used if it is present, and ``loads`` is used as a fallback
option. Similarly for writing, ``dump`` is preferred over ``dumps``.



Expand Down
4 changes: 2 additions & 2 deletions lib/cli_command_parser/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ class OptionNameMode(FixedFlag):
# fmt: on

@classmethod
def _missing_(cls, value: str | int | None) -> OptionNameMode: # type: ignore[override]
def _missing_(cls, value: str | int | None) -> Self: # type: ignore[override]
try:
return OPT_NAME_MODE_ALIASES[value] # type: ignore[index]
except KeyError:
Expand Down Expand Up @@ -212,7 +212,7 @@ class AllowLeadingDash(Enum):
# fmt: on

@classmethod
def _missing_(cls, value) -> Self:
def _missing_(cls, value: str | bool) -> Self: # type: ignore[override]
if isinstance(value, str):
try:
return cls._member_map_[value.upper()] # type: ignore[return-value]
Expand Down
4 changes: 1 addition & 3 deletions lib/cli_command_parser/conversion/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,7 @@ def _visit_for_smart(self, node: For, loop_var: str, ele_names: list[str]):
"""
log.debug(f'Attempting smart for loop visit for {loop_var=} in {ele_names=}')
refs: list[AstArgumentParser] = [
ref # type: ignore[misc] # mypy doesn't seem to recognize the isinstance part of the condition
for name in ele_names
if (ref := self.scopes.get(name)) and isinstance(ref, AstArgumentParser)
ref for name in ele_names if (ref := self.scopes.get(name)) and isinstance(ref, AstArgumentParser)
]
# log.debug(f' > Found {len(refs)=}, {len(ele_names)=}')

Expand Down
24 changes: 13 additions & 11 deletions lib/cli_command_parser/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def _maybe_register_sub_cmd(mcs, cls, choice: Choice, choices: OptChoices, help:
if parent := mcs.parent(cls, False):
if sub_cmd := mcs.params(parent).sub_command:
for choice, choice_help in _choice_items(choice, choices):
sub_cmd.register_command(choice, cls, choice_help or help) # type: ignore[attr-defined]
sub_cmd.register_command(choice, cls, choice_help or help)
elif choices or (choice is not None and choice is not _NotSet):
_no_choices_registered_warning(choice, choices, cls, f'its {parent=} has no SubCommand parameter')
elif choices or (choice is not None and choice is not _NotSet):
Expand Down Expand Up @@ -167,18 +167,20 @@ def _prepare_config(mcs, bases: Bases, config: AnyConfig, kwargs: dict[str, Any]

return None

@overload
@classmethod
def config(mcs, cls: CommandAny, default: None = None) -> CommandConfig | None: ...
if TYPE_CHECKING:

@overload
@classmethod
def config(mcs, cls: CommandAny, default: T) -> CommandConfig | T: ...
@overload
@classmethod
def config(mcs, cls: CommandAny, default: None = None) -> CommandConfig | None: ...

@overload
@classmethod
def config(mcs, cls: CommandAny, default: T) -> CommandConfig | T: ...

@classmethod
def config(mcs, cls: CommandAny, default: T | None = None) -> CommandConfig | T | None:
try:
return cls.__config # type: ignore[union-attr] # This attr is not overwritten for every subclass
return cls.__config # This attr is not overwritten for every subclass
except AttributeError: # This means that the Command and all of its parents have no custom config
return default

Expand All @@ -197,7 +199,7 @@ def parent(mcs, cls: CommandAny, include_abc: bool = True) -> CommandMeta | None
``include_abc``).
"""
try:
first, parent = cls.__parents # type: ignore[union-attr] # Works for both Command objects and classes
first, parent = cls.__parents # Works for both Command objects and classes
except TypeError:
pass
else:
Expand All @@ -220,7 +222,7 @@ def parent(mcs, cls: CommandAny, include_abc: bool = True) -> CommandMeta | None
def params(mcs, cls: CommandAny) -> CommandParameters:
# Late initialization is necessary to allow late assignment of Parameters for now
try:
params = cls.__params # type: ignore[union-attr]
params = cls.__params
except AttributeError:
raise TypeError('CommandParameters are only available for Command subclasses') from None

Expand All @@ -245,7 +247,7 @@ def meta(mcs, cls: CommandMeta) -> ProgramMetadata:
def _mro(cmd_or_cls: CommandAny) -> tuple[CommandMeta, list[type]]:
# In the return value of type.mro(...), 0 is always the class itself, -1 is always object
try:
return cmd_or_cls, type.mro(cmd_or_cls)[1:-1] # type: ignore[arg-type,return-value]
return cmd_or_cls, type.mro(cmd_or_cls)[1:-1] # type: ignore[return-value]
except TypeError: # a Command object was provided instead of a Command class
cmd_cls: CommandMeta = cmd_or_cls.__class__ # type: ignore[assignment]
return cmd_cls, type.mro(cmd_cls)[1:-1]
Expand Down
22 changes: 12 additions & 10 deletions lib/cli_command_parser/error_handling/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@

import sys
from collections import ChainMap
from typing import Callable, Iterator, Type, TypeVar
from typing import TYPE_CHECKING, Callable, Iterator, Type, TypeVar

from ..exceptions import CommandParserException

if TYPE_CHECKING:
from ..typing import Self

__all__ = ['ErrorHandler', 'error_handler', 'extended_error_handler', 'no_exit_handler', 'NullErrorHandler']

E = TypeVar('E', bound=BaseException)
HandlerFunc = Callable[[E], bool | int | None]
HandlerDecorator = Callable[[HandlerFunc], HandlerFunc]


class ErrorHandler:
Expand All @@ -38,16 +42,16 @@ def unregister(self, *exceptions: Type[BaseException]):
except KeyError:
pass

def __call__(self, *exceptions: Type[BaseException]):
def _handler(handler: HandlerFunc | staticmethod):
def __call__(self, *exceptions: Type[BaseException]) -> HandlerDecorator:
def _handler(handler: HandlerFunc) -> HandlerFunc:
self.register(handler, *exceptions)
return handler

return _handler

@classmethod
def cls_handler(cls, *exceptions: Type[E]):
def _cls_handler(handler: HandlerFunc | staticmethod):
def cls_handler(cls, *exceptions: Type[E]) -> HandlerDecorator:
def _cls_handler(handler: HandlerFunc) -> HandlerFunc:
for exc in exceptions:
cls._exc_handler_map[exc] = Handler(exc, handler)
return handler
Expand All @@ -66,7 +70,7 @@ def iter_handlers(self, exc_type: Type[BaseException], exc: BaseException) -> It
for candidate in candidates:
yield candidate.handler

def __enter__(self):
def __enter__(self) -> Self:
return self

def __exit__(self, exc_type: Type[BaseException], exc_val: BaseException, exc_tb) -> bool:
Expand All @@ -90,7 +94,7 @@ def copy(self) -> ErrorHandler:


class NullErrorHandler:
def __enter__(self):
def __enter__(self) -> Self:
return self

def __exit__(self, exc_type, exc_val, exc_tb):
Expand All @@ -110,9 +114,7 @@ def __init__(self, exc_cls: Type[BaseException], handler: HandlerFunc):
self.handler = handler

def __eq__(self, other) -> bool:
if not isinstance(other, Handler):
return False
return other.exc_cls == self.exc_cls and other.handler == self.handler
return isinstance(other, Handler) and other.exc_cls == self.exc_cls and other.handler == self.handler

def __lt__(self, other: Handler) -> bool:
return issubclass(self.exc_cls, other.exc_cls)
Expand Down
2 changes: 1 addition & 1 deletion lib/cli_command_parser/error_handling/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def handle_kb_interrupt(exc: KeyboardInterrupt) -> int:


@extended_error_handler(OSError)
def handle_win_os_pipe_error(exc: OSError):
def handle_win_os_pipe_error(exc: OSError) -> bool:
"""
This is a workaround for `[Windows] I/O on a broken pipe may raise an EINVAL OSError instead of BrokenPipeError
<https://github.com/python/cpython/issues/79935>`_, which is a bug in the way that the
Expand Down
3 changes: 2 additions & 1 deletion lib/cli_command_parser/formatting/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ def format_usage(
allow_sys_argv: Bool = True,
cont_indent: int = 4,
) -> str:
if (wrap_usage_str := ctx.config.wrap_usage_str) is True:
if (wrap_usage_str := ctx.config.wrap_usage_str) is True: # noqa
# `is True` is used because it supports True -> term width or an explicit width
wrap_usage_str = ctx.terminal_width

if usage := self._meta.usage:
Expand Down
23 changes: 12 additions & 11 deletions lib/cli_command_parser/formatting/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def format_metavar(self) -> str:
config = ctx.config
if (t := param.type) is not None:
try:
metavar = t.format_metavar( # type: ignore[union-attr,attr-defined]
metavar = t.format_metavar( # type: ignore[union-attr]
config.choice_delim, config.sort_choices
)
except Exception: # noqa # pylint: disable=W0703
Expand All @@ -134,7 +134,7 @@ def format_metavar(self) -> str:

if config.use_type_metavar and t is not None:
try:
name = t.__name__ # type: ignore[union-attr,attr-defined]
name = t.__name__ # type: ignore[union-attr]
except AttributeError:
pass
else:
Expand Down Expand Up @@ -315,6 +315,9 @@ class ChoiceGroup:

__slots__ = ('choice_strs', 'choices')

choices: list[Choice[Any]]
choice_strs: list[str]

def __init__(self, choice: Choice):
self.choices = [choice]
self.choice_strs = [choice.choice] if choice.choice else []
Expand Down Expand Up @@ -351,10 +354,9 @@ def format(self, default_mode: CmdAliasMode, prefix: str = '') -> Iterator[str]:
:return: Generator that yields formatted help text entries (strings) for the Choices in this group.
"""
for choice, usage, description in self.prepare(default_mode):
if usage is not None:
yield format_help_entry((usage,), description, lpad=4, prefix=prefix)
yield format_help_entry((usage,), description, lpad=4, prefix=prefix)

def prepare(self, default_mode: CmdAliasMode) -> Iterator[tuple[Choice, OptStr, OptStr]]:
def prepare(self, default_mode: CmdAliasMode) -> Iterator[tuple[Choice, str, OptStr]]:
"""
Prepares the choice values and descriptions to use for each Choice in this group based on the configured alias
mode.
Expand All @@ -364,9 +366,8 @@ def prepare(self, default_mode: CmdAliasMode) -> Iterator[tuple[Choice, OptStr,
:return: Generator that yields 3-tuples containing the :class:`.Choice` object, the choice string value, and
the help text / description for that choice / alias.
"""
# If it's not a Command, get_config will return None. If it is a Command, then it will use its config. If the
# alias mode is not set on that target Command, but it is set on its parent, then this will use that parent's
# setting.
# If the target is a Command, its config will be used, otherwise, get_config will return None. If the alias
# mode is not set on that target Command, but it is set on its parent, then this will use that parent's setting.
if config := get_config(self.choices[0].target):
mode = config.cmd_alias_mode or default_mode
else:
Expand All @@ -383,7 +384,7 @@ def prepare(self, default_mode: CmdAliasMode) -> Iterator[tuple[Choice, OptStr,
# Treat it as a format string
yield from self.prepare_aliases(mode)

def prepare_combined(self) -> tuple[Choice, OptStr, OptStr]:
def prepare_combined(self) -> tuple[Choice, str, OptStr]:
"""
Prepare this group's Choices for inclusion in help text / documentation by combining all aliases into a single
entry.
Expand All @@ -399,7 +400,7 @@ def prepare_combined(self) -> tuple[Choice, OptStr, OptStr]:

return first, usage, first.help

def prepare_aliases(self, format_str: str = 'Alias of: {choice}') -> Iterator[tuple[Choice, OptStr, OptStr]]:
def prepare_aliases(self, format_str: str = 'Alias of: {choice}') -> Iterator[tuple[Choice, str, OptStr]]:
"""
Prepare this group's Choices for inclusion in help text / documentation using an alternate description for
aliases.
Expand Down Expand Up @@ -431,7 +432,7 @@ def prepare_aliases(self, format_str: str = 'Alias of: {choice}') -> Iterator[tu
for choice_str in choice_strs:
yield first, choice_str, format_str.format(choice=first_str, alias=choice_str, help=help_str)

def prepare_repeated(self) -> Iterator[tuple[Choice, OptStr, OptStr]]:
def prepare_repeated(self) -> Iterator[tuple[Choice, str, OptStr]]:
"""
Prepare this group's Choices for inclusion in help text / documentation with no modifications. Choices that
are considered aliases are simply repeated as if they were not aliases.
Expand Down
Loading
Loading