diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 7dd37bec..62b5e0ac 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -33,7 +33,7 @@ jobs:
fail-fast: false
matrix:
language: ["python"]
- python-version: ["3.11"]
+ python-version: ["3.12"]
steps:
- name: Checkout repository
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index de195280..79a7280e 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.11"]
+ python-version: ["3.12"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index a47218a9..55a9fe1d 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- name: Add test locales
run: |
diff --git a/docs/_src/index.rst b/docs/_src/index.rst
index 678ebbda..26cdd4c1 100644
--- a/docs/_src/index.rst
+++ b/docs/_src/index.rst
@@ -3,7 +3,7 @@ CLI Command Parser
|downloads| |py_version| |coverage_badge| |build_status| |Ruff|
-.. |py_version| image:: https://img.shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20-blue
+.. |py_version| image:: https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20-blue
:target: https://pypi.org/project/cli-command-parser/
.. |coverage_badge| image:: https://codecov.io/gh/dskrypa/cli_command_parser/branch/main/graph/badge.svg
@@ -85,18 +85,8 @@ with optional dependencies::
Python Version Compatibility
============================
-Python versions 3.8 and above are currently supported. The last release of CLI Command Parser that supported 3.7 was
-2023-04-30. Support for Python 3.7 `officially ended on 2023-06-27 `__.
-
-When using Python 3.8, some additional packages that backport functionality that was added in later Python versions
-are required for compatibility.
-
-To use the argparse to cli-command-parser conversion script with Python 3.8, there is a dependency on
-`astunparse `__. If you are using Python 3.9 or above, then ``astunparse`` is not
-necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
-cli-command-parser with the following command to automatically handle whether that extra dependency is needed or not::
-
- $ pip install -U cli-command-parser[conversion]
+Python versions 3.9 and above are currently supported. The last release of CLI Command Parser that supported 3.8 was
+2024-09-07. Support for Python 3.8 `officially ended on 2024-10-07 `__.
User Guide
diff --git a/lib/cli_command_parser/annotations.py b/lib/cli_command_parser/annotations.py
index d8eb5724..cba05f39 100644
--- a/lib/cli_command_parser/annotations.py
+++ b/lib/cli_command_parser/annotations.py
@@ -7,7 +7,7 @@
from collections.abc import Collection, Iterable
from functools import lru_cache
from inspect import isclass
-from typing import Union, Optional, get_type_hints as _get_type_hints, get_origin, get_args as _get_args
+from typing import Optional, Union, get_args, get_origin, get_type_hints as _get_type_hints
try:
from types import NoneType
@@ -42,15 +42,6 @@ def get_annotation_value_type(annotation, from_union: bool = True, from_collecti
return None
-def get_args(annotation) -> tuple:
- """
- Wrapper around :func:`python:typing.get_args` for 3.7~8 compatibility, to make it behave more like it does in 3.9+
- """
- if getattr(annotation, '_special', False): # 3.7-3.8 generic collection alias with no content types
- return ()
- return _get_args(annotation)
-
-
def _type_from_union(annotation) -> Optional[type]:
args = get_args(annotation)
# Note: Unions of a single argument return the argument; i.e., Union[T] returns T, so the len can never be 1
diff --git a/lib/cli_command_parser/compat.py b/lib/cli_command_parser/compat.py
index da666089..4156f732 100644
--- a/lib/cli_command_parser/compat.py
+++ b/lib/cli_command_parser/compat.py
@@ -1,9 +1,4 @@
"""
-Compatibility / Patch module - used to back-port features to Python 3.7 and to avoid breaking changes in Enum/Flag in
-3.11.
-
-Contains stdlib CPython functions / classes from Python 3.8 and 3.10.
-
The :class:`WCTextWrapper` in this module extends the stdlib :class:`python:textwrap.TextWrapper` to support wide
characters.
"""
@@ -16,8 +11,6 @@
__all__ = ['WCTextWrapper']
-# region textwrap
-
class WCTextWrapper(TextWrapper):
"""
@@ -119,6 +112,3 @@ def _wrap_chunks(self, chunks: list[str]) -> list[str]:
break
return lines
-
-
-# endregion
diff --git a/lib/cli_command_parser/conversion/utils.py b/lib/cli_command_parser/conversion/utils.py
index b891be5c..8f1b0cb1 100644
--- a/lib/cli_command_parser/conversion/utils.py
+++ b/lib/cli_command_parser/conversion/utils.py
@@ -1,24 +1,7 @@
from __future__ import annotations
-from ast import AST, expr, Call, Attribute, Name, Dict, List, Set, Tuple
-
-try:
- from ast import unparse
-except ImportError: # added in 3.9
- try:
- from astunparse import unparse as _unparse
- except ImportError as e:
- raise RuntimeError(
- 'Missing required dependency: astunparse (only required in Python 3.8 and below'
- ' - upgrade to 3.9 or above to avoid this dependency)'
- )
- else:
-
- def unparse(node):
- return ''.join(_unparse(node).splitlines())
-
-
-from typing import Union, Iterator, List as _List
+from ast import AST, Attribute, Call, Dict, List, Name, Set, Tuple, expr, unparse
+from typing import Iterator, List as _List, Union
__all__ = ['get_name_repr', 'iter_module_parents', 'collection_contents']
diff --git a/lib/cli_command_parser/documentation.py b/lib/cli_command_parser/documentation.py
index 853aca68..729475af 100644
--- a/lib/cli_command_parser/documentation.py
+++ b/lib/cli_command_parser/documentation.py
@@ -372,6 +372,6 @@ def write_rst(self, name: str, content: str, subdir: str = None):
path = target_dir.joinpath(name + self.ext)
log.debug(f'{prefix} {path.as_posix()}')
if not self.dry_run:
- # Path.write_text on 3.8 does not support `newline`
+ # Path.write_text on 3.9 does not support `newline`
with path.open('w', encoding=self.encoding, newline=self.newline) as f:
f.write(content)
diff --git a/lib/cli_command_parser/formatting/commands.py b/lib/cli_command_parser/formatting/commands.py
index e46bb18a..0b708ba5 100644
--- a/lib/cli_command_parser/formatting/commands.py
+++ b/lib/cli_command_parser/formatting/commands.py
@@ -12,9 +12,10 @@
from ..context import NoActiveContext, ctx
from ..core import get_metadata, get_params
+from ..parameters.choice_map import ChoiceMap
from ..parameters.groups import ParamGroup
from ..utils import _NotSet, camel_to_snake_case
-from .restructured_text import RstTable, spaced_rst_header
+from .restructured_text import spaced_rst_header
from .utils import PartWrapper
if TYPE_CHECKING:
@@ -47,6 +48,7 @@ def maybe_add_groups(self, groups: Iterable[ParamGroup]):
for group in groups:
if group.group: # prevent duplicates
continue
+
if group.contains_positional:
self.pos_group.add(group)
else:
@@ -162,14 +164,17 @@ def _cmd_rst_lines(
yield description
yield ''
- # TODO: The subcommand names in the group containing subcommand targets should link to their respective
- # subcommand sections
- for group in self.groups:
+ if self.pos_group.show_in_help:
# TODO: Nested subcommands' local choices should not repeat the `subcommands` positional arguments section
# that includes the nested subcommand choice being documented
+ if len(members := self.pos_group.members) == 1 and isinstance(members[0], ChoiceMap):
+ yield from members[0].formatter.rst_table().iter_build() # noqa
+ else:
+ yield from self.pos_group.formatter.rst_table().iter_build()
+
+ for group in self.groups[1:]:
if group.show_in_help:
- table: RstTable = group.formatter.rst_table() # noqa
- yield from table.iter_build()
+ yield from group.formatter.rst_table().iter_build()
if include_epilog and (epilog := self._meta.format_epilog(config.extended_epilog, allow_sys_argv)):
yield epilog
diff --git a/lib/cli_command_parser/formatting/params.py b/lib/cli_command_parser/formatting/params.py
index 52188798..bd8886ea 100644
--- a/lib/cli_command_parser/formatting/params.py
+++ b/lib/cli_command_parser/formatting/params.py
@@ -16,7 +16,7 @@
from ..parameters import ParamGroup, PassThru, TriFlag
from ..parameters.base import BaseOption, BasePositional
from ..parameters.choice_map import Choice, ChoiceMap
-from .restructured_text import RstTable
+from .restructured_text import Cell, Row, RstTable
from .utils import _should_add_default, format_help_entry
if TYPE_CHECKING:
@@ -223,6 +223,8 @@ def rst_rows(self) -> Iterator[tuple[str, str]]:
class ChoiceMapHelpFormatter(ParamHelpFormatter, param_cls=ChoiceMap):
+ """Formatter for :class:`SubCommand` and :class:`Action` parameters (and any other params that extend ChoiceMap)"""
+
param: ChoiceMap
@cached_property
@@ -269,6 +271,7 @@ def rst_table(self) -> RstTable:
def _format_rst_rows(self) -> Iterator[tuple[str, OptStr]]:
mode = ctx.config.cmd_alias_mode or SubcommandAliasHelpMode.ALIAS
+ # TODO: The subcommand names should link to their respective subcommand sections
for choice_group in self.choice_groups:
for choice, usage, description in choice_group.prepare(mode):
yield f'``{usage}``', description
@@ -492,15 +495,19 @@ def rst_table(self) -> RstTable:
table = RstTable(self.format_description())
# TODO: non-nested when config.show_group_tree is False; maybe separate options for rst vs help
for member in self.param.members:
- if member.show_in_help:
- formatter = member.formatter
- try:
- sub_table: RstTable = formatter.rst_table() # noqa
- except AttributeError:
- table.add_rows(formatter.rst_rows())
- else:
- sub_table.show_title = False
- table.add_row(sub_table.title, str(sub_table))
+ if not member.show_in_help:
+ continue
+
+ formatter = member.formatter
+ try:
+ sub_table: RstTable = formatter.rst_table() # noqa
+ except AttributeError:
+ table.add_rows(formatter.rst_rows())
+ else:
+ table._add_row(Row([Cell(str(sub_table), ext_right=True), Cell()]))
+ # If a config option to switch to the old way is added later, the old approach:
+ # sub_table.show_title = False
+ # table.add_row(sub_table.title, str(sub_table))
return table
diff --git a/lib/cli_command_parser/formatting/restructured_text.py b/lib/cli_command_parser/formatting/restructured_text.py
index 41703386..2a90fed7 100644
--- a/lib/cli_command_parser/formatting/restructured_text.py
+++ b/lib/cli_command_parser/formatting/restructured_text.py
@@ -6,11 +6,8 @@
from __future__ import annotations
-from itertools import starmap
from typing import TYPE_CHECKING, Any, Iterable, Iterator, Mapping, Sequence, TypeVar, Union
-from .utils import line_iter
-
if TYPE_CHECKING:
from ..typing import Bool, OptStr, Strings
@@ -119,7 +116,7 @@ class RstTable:
body of this table.
"""
- __slots__ = ('title', 'subtitle', 'show_title', 'use_table_directive', 'rows', 'widths')
+ __slots__ = ('title', 'subtitle', 'show_title', 'use_table_directive', '_rows', '_widths', '_updated')
def __init__(
self,
@@ -134,8 +131,9 @@ def __init__(
self.subtitle = subtitle
self.show_title = show_title
self.use_table_directive = use_table_directive
- self.rows = []
- self.widths = []
+ self._rows = []
+ self._widths = ()
+ self._updated = False
if headers:
self.add_row(*headers, header=True)
@@ -163,6 +161,13 @@ def from_dict(cls, data: Mapping[OptStr, OptStr], **kwargs) -> RstTable:
table.add_kv_rows(data)
return table
+ @property
+ def widths(self) -> tuple[int, ...]:
+ if self._updated:
+ self._widths = tuple(max(col) for col in zip(*(row.widths() for row in self._rows)))
+ self._updated = False
+ return self._widths
+
def add_dict_rows(self, rows: RowMaps, columns: Sequence[T] = None, add_header: Bool = False):
"""Add a row for each dict in the given sequence of rows, where the keys represent the columns."""
if not columns:
@@ -180,8 +185,11 @@ def add_kv_rows(self, data: Mapping[OptStr, OptStr]):
self.add_rows(data.items())
def add_rows(self, rows: Iterable[Iterable[OptStr]]):
- for row in rows:
- self.add_row(*row)
+ self._add_rows(Row([Cell(c or '') for c in columns]) for columns in rows)
+
+ def _add_rows(self, rows: Iterable[Row]):
+ self._rows.extend(rows)
+ self._updated = True
def add_row(self, *columns: OptStr, index: int = None, header: bool = False):
"""
@@ -192,34 +200,18 @@ def add_row(self, *columns: OptStr, index: int = None, header: bool = False):
the list of rows.
:param header: If True, this row will be treated as a header row. Does not affect insertion order.
"""
- any_new_line, widths = _widths(columns)
- if self.widths:
- self.widths = tuple(starmap(max, zip(self.widths, widths)))
- else:
- self.widths = tuple(widths)
+ self._add_row(Row([Cell(c or '') for c in columns], header), index)
- columns = tuple(c or '' for c in columns)
+ def _add_row(self, row: Row, index: int = None):
if index is None:
- self.rows.append((header, any_new_line, columns))
+ self._rows.append(row)
else:
- self.rows.insert(index, (header, any_new_line, columns))
-
- def bar(self, char: str = '-') -> str:
- """
- :param char: The character to use for the bar. Defaults to ``-`` (for normal rows). Use ``=`` below a header
- row. See :du_rst:`Grid Tables` for more info.
- :return: The formatted bar string
- """
- pre = ' ' if self.use_table_directive else ''
- return '+'.join([pre, *(char * (w + 2) for w in self.widths), ''])
-
- def _get_row_format(self) -> str:
- pre = ' ' if self.use_table_directive else ''
- return '|'.join([pre, *(f' {{:<{w}s}} ' for w in self.widths), ''])
+ self._rows.insert(index, row)
+ self._updated = True
def __repr__(self) -> str:
return (
- f''
)
@@ -230,38 +222,85 @@ def iter_build(self) -> Iterator[str]:
yield ''
if self.use_table_directive:
- options = {'subtitle': self.subtitle, 'widths': 'auto'}
- yield from _rst_directive('table', options=options, check=True)
+ yield from _rst_directive('table', options={'subtitle': self.subtitle, 'widths': 'auto'}, check=True)
yield ''
-
- bar, header_bar = self.bar(), self.bar('=')
- format_row = self._get_row_format().format
- yield bar
- for header, any_new_line, row in self.rows:
- if any_new_line:
- for line in line_iter(*row):
- yield format_row(*line)
- else:
- yield format_row(*row)
-
- yield header_bar if header else bar
+ for line in self._iter_render():
+ yield ' ' + line
+ else:
+ yield from self._iter_render()
yield ''
+ def _iter_render(self) -> Iterator[str]:
+ col_widths = self.widths
+ yield self._rows[0].render_upper_bar(col_widths)
+ for row in self._rows:
+ yield from row.render_lines(col_widths)
+
def __str__(self) -> str:
return '\n'.join(self.iter_build())
-def _widths(columns: Iterable[OptStr]) -> tuple[bool, list[int]]:
- widths = []
- any_new_line = False
- for column in columns:
- if not column:
- widths.append(0)
- elif '\n' in column:
- any_new_line = True
- widths.append(max(map(len, column.splitlines())))
+class Cell:
+ __slots__ = ('text', 'lines', 'width', 'height', 'brd_bottom', 'brd_right')
+
+ def __init__(self, text: str = '', *, ext_right: bool = False, ext_below: bool = False):
+ self.text = text
+ if text:
+ self.lines = text.splitlines()
+ self.width = max(map(len, self.lines))
+ self.height = len(self.lines)
else:
- widths.append(len(column))
+ self.lines = ['']
+ self.width = 0
+ self.height = 1
+
+ self.brd_bottom = not ext_below
+ self.brd_right = not ext_right
+
+ def render_upper_bar(self, width: int) -> str:
+ return ('-' * (width + 2)) + ('+' if self.brd_bottom else '|')
+
+ def render_lower_bar(self, width: int, char: str = '-') -> str:
+ if not self.brd_bottom:
+ char = ' '
+ return (char * (width + 2)) + ('+' if self.brd_bottom or self.brd_right else ' ')
+
+ def render_lines(self, width: int, max_lines: int) -> Iterator[str]:
+ format_line = f' {{:<{width}s}} {"|" if self.brd_right else " "}'.format
+ for line in self.lines:
+ yield format_line(line)
+
+ for _ in range(self.height, max_lines):
+ yield format_line('')
+
+ def __repr__(self) -> str:
+ return f'<{self.__class__.__name__}[{self.text!r}, width={self.width}, height={self.height}]>'
+
+
+class Row:
+ __slots__ = ('cells', 'header')
+
+ def __init__(self, cells: list[Cell], header: bool = False):
+ self.cells = cells
+ self.header = header
+
+ def widths(self) -> Iterator[int]:
+ for cell in self.cells:
+ yield cell.width
+
+ def render_upper_bar(self, widths: Sequence[int]) -> str:
+ return '+' + ''.join(cell.render_upper_bar(w) for cell, w in zip(self.cells, widths))
+
+ def render_lower_bar(self, widths: Sequence[int]) -> str:
+ char = '=' if self.header else '-'
+ first = '+' if self.cells[0].brd_bottom else '|'
+ return first + ''.join(cell.render_lower_bar(w, char) for cell, w in zip(self.cells, widths))
+
+ def render_lines(self, widths: Sequence[int]) -> Iterator[str]:
+ max_lines = max(cell.height for cell in self.cells)
+ renderers = [cell.render_lines(w, max_lines) for cell, w in zip(self.cells, widths)]
+ for cell_strs in zip(*renderers):
+ yield '|' + ''.join(cell_strs)
- return any_new_line, widths
+ yield self.render_lower_bar(widths)
diff --git a/lib/cli_command_parser/metadata.py b/lib/cli_command_parser/metadata.py
index b1a0050d..73c0ec78 100644
--- a/lib/cli_command_parser/metadata.py
+++ b/lib/cli_command_parser/metadata.py
@@ -7,6 +7,8 @@
from __future__ import annotations
+import json
+import platform
from collections import defaultdict
from functools import cached_property
from importlib.metadata import Distribution, EntryPoint, entry_points
@@ -24,6 +26,7 @@
__all__ = ['ProgramMetadata']
+WINDOWS = platform.system().lower() == 'windows'
DEFAULT_FILE_NAME: str = 'UNKNOWN'
@@ -247,12 +250,14 @@ def docs_url(self) -> OptStr:
def format_epilog(self, extended: Bool = True, allow_sys_argv: Bool = None) -> str:
parts = [self.epilog] if self.epilog else []
+ # TODO: Add support for epilog_format format string?
if parts and not extended:
return parts[0]
if version := self.version:
version = f' [ver. {version}]'
if self.email:
+ # TODO: add support_url metadata entry and use that instead of email, if present?
parts.append(f'Report {self.get_prog(allow_sys_argv)}{version} bugs to {self.email}')
if url := self.docs_url or self.url:
parts.append(f'Online documentation: {url}')
@@ -267,6 +272,7 @@ def get_doc_str(self, strip: Bool = True) -> OptStr:
return doc_str
def get_description(self, allow_inherited: Bool = True) -> OptStr:
+ # TODO: Description template for subcommands?
if description := self.description:
if not allow_inherited and (parent := self.parent) and (parent_description := parent.description): # noqa
return description if parent_description != description else None
@@ -301,7 +307,7 @@ def mod_obj_prog_map(self) -> dict[str, dict[str, str]]:
def _get_console_scripts(cls) -> tuple[EntryPoint, ...]:
try:
return entry_points(group='console_scripts') # noqa
- except TypeError: # Python 3.8 or 3.9
+ except TypeError: # Python 3.9
return entry_points()['console_scripts'] # noqa
def normalize(
@@ -382,10 +388,33 @@ def __init__(self):
self._dist_top_levels = {}
self._dist_urls = {}
+ @cached_property
+ def _all_distributions(self) -> tuple[dict[str, Distribution], dict[str, tuple[Distribution, Path]]]:
+ normal, editable = {}, {}
+ for dist in Distribution.discover():
+ # Note: Distribution.name was not added until 3.10, and it returns `self.metadata['Name']`
+ if not (name := dist.metadata.get('Name')):
+ continue
+ elif existing := normal.get(name):
+ if path := _get_editable_path(existing):
+ editable[name] = (dist, path)
+ elif path := _get_editable_path(dist):
+ editable[name] = (existing, path)
+ normal[name] = dist
+ else: # A third case is not really expected here...
+ normal[name] = dist
+ else:
+ normal[name] = dist
+
+ return normal, editable
+
@cached_property
def _distributions(self) -> dict[str, Distribution]:
- # Note: Distribution.name was not added until 3.10, and it returns `self.metadata['Name']`
- return {dist.metadata['Name']: dist for dist in Distribution.discover()}
+ return self._all_distributions[0]
+
+ @cached_property
+ def _editable_distributions(self) -> dict[str, tuple[Distribution, Path]]:
+ return self._all_distributions[1]
def _get_top_levels(self, dist_name: str, dist: Distribution) -> set[str]:
# dist_name = dist.metadata['Name'] # Distribution.name was not added until 3.10, and it returns this
@@ -419,13 +448,30 @@ def dist_for_obj(self, obj) -> Distribution | None:
def _dist_for_obj_main(self, obj) -> Distribution | None:
# Note: getmodule returns the module object (obj.__module__ only provides the name)
- if (module := getmodule(obj)) is None or not module.__package__:
+ if (module := getmodule(obj)) is None:
return None
+ elif not module.__package__:
+ # This may occur for top-level scripts that are in a bin/ directory or similar, with a Command that was
+ # defined in that file instead of in the package that represents the library code for that project
+ try:
+ path = module.__loader__.path # noqa
+ except AttributeError:
+ return None
+ else:
+ return self._dist_for_main_loader_path(path)
# The package name may have a prefix like `lib` not included in top_level when interactive
for part in module.__package__.split('.'):
if (dist := self.dist_for_pkg(part)) is not None:
return dist
+
+ return None
+
+ def _dist_for_main_loader_path(self, path_str: str) -> Distribution | None:
+ path = Path(path_str).resolve()
+ for name, (dist, src_path) in self._editable_distributions.items():
+ if path.is_relative_to(src_path):
+ return dist
return None
def get_urls(self, dist: Distribution) -> dict[str, str]:
@@ -445,6 +491,25 @@ def get_urls(self, dist: Distribution) -> dict[str, str]:
return urls
+def _get_editable_path(dist: Distribution) -> Path | None:
+ if not (direct_url := dist.read_text('direct_url.json')): # read_text suppresses errors
+ return None
+
+ data = json.loads(direct_url)
+ # direct_url content: '{"dir_info": {"editable": true}, "url": "file:///C:/Users/..."}'
+ if not (url := data.get('url')) or not data.get('dir_info', {}).get('editable'):
+ return None # This is not expected
+
+ parsed = urlparse(url)
+ if parsed.scheme != 'file': # This is not expected
+ return None
+
+ path = parsed.path
+ if WINDOWS and path.startswith('/') and ':/' in path[:4]: # The uri path has a leading / before the drive letter
+ path = path[1:]
+ return Path(path).resolve()
+
+
_dist_finder = DistributionFinder()
diff --git a/lib/cli_command_parser/parameters/choice_map.py b/lib/cli_command_parser/parameters/choice_map.py
index 9532b5b7..f51fa04e 100644
--- a/lib/cli_command_parser/parameters/choice_map.py
+++ b/lib/cli_command_parser/parameters/choice_map.py
@@ -21,6 +21,7 @@
from .base import BasePositional
if TYPE_CHECKING:
+ from ..formatting.params import ChoiceMapHelpFormatter
from ..metadata import ProgramMetadata
__all__ = ['SubCommand', 'Action', 'Choice', 'ChoiceMap']
@@ -84,6 +85,7 @@ class ChoiceMap(BasePositional[str], Generic[T], actions=(Concatenate,)):
choices: dict[str, Choice[T]]
title: OptStr
description: OptStr
+ formatter: ChoiceMapHelpFormatter
def __init_subclass__( # pylint: disable=W0222
cls, title: str = None, choice_validation_exc: Type[Exception] = None, **kwargs
diff --git a/lib/cli_command_parser/parameters/groups.py b/lib/cli_command_parser/parameters/groups.py
index 86d528ba..66636553 100644
--- a/lib/cli_command_parser/parameters/groups.py
+++ b/lib/cli_command_parser/parameters/groups.py
@@ -15,6 +15,7 @@
from .pass_thru import PassThru
if TYPE_CHECKING:
+ from ..formatting.params import GroupHelpFormatter
from ..typing import Bool, ParamList, ParamOrGroup
__all__ = ['ParamGroup']
@@ -48,6 +49,7 @@ class ParamGroup(ParamBase):
members: list[ParamOrGroup]
mutually_exclusive: Bool = False
mutually_dependent: Bool = False
+ formatter: GroupHelpFormatter
def __init__(
self,
@@ -202,6 +204,11 @@ def _check_conflicts(self, provided: ParamList, missing: ParamList):
if not (self.mutually_dependent or self.mutually_exclusive):
return
+ # TODO: Use case: partially mutually dependent group - outer mutually dependent group where within the group,
+ # some params are required if any members are provided, but not all members (which may be groups themselves)
+ # are always required. If those optional members are provided, then the required members must be required,
+ # but not the inverse.
+
# log.debug(f'{self}: Checking group conflicts in {provided=}, {missing=}')
# log.debug(f'{self}: Checking group conflicts in provided={len(provided)}, missing={len(missing)}')
if self.mutually_dependent and provided and missing:
diff --git a/readme.rst b/readme.rst
index dd15fd19..b4045cbd 100644
--- a/readme.rst
+++ b/readme.rst
@@ -3,7 +3,7 @@ CLI Command Parser
|downloads| |py_version| |coverage_badge| |build_status| |Ruff|
-.. |py_version| image:: https://img.shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20-blue
+.. |py_version| image:: https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20-blue
:target: https://pypi.org/project/cli-command-parser/
.. |coverage_badge| image:: https://codecov.io/gh/dskrypa/cli_command_parser/branch/main/graph/badge.svg
@@ -85,18 +85,8 @@ with optional dependencies::
Python Version Compatibility
============================
-Python versions 3.8 and above are currently supported. The last release of CLI Command Parser that supported 3.7 was
-2023-04-30. Support for Python 3.7 `officially ended on 2023-06-27 `__.
-
-When using Python 3.8, some additional packages that backport functionality that was added in later Python versions
-are required for compatibility.
-
-To use the argparse to cli-command-parser conversion script with Python 3.8, there is a dependency on
-`astunparse `__. If you are using Python 3.9 or above, then ``astunparse`` is not
-necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
-cli-command-parser with the following command to automatically handle whether that extra dependency is needed or not::
-
- $ pip install -U cli-command-parser[conversion]
+Python versions 3.9 and above are currently supported. The last release of CLI Command Parser that supported 3.8 was
+2024-09-07. Support for Python 3.8 `officially ended on 2024-10-07 `__.
Links
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f9c23e0a..5071e085 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,4 @@
--e .[wcwidth,conversion]
+-e .[wcwidth]
-r docs/requirements.txt
pre-commit
ruff
diff --git a/setup.cfg b/setup.cfg
index f8887966..294b47c1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -21,11 +21,11 @@ classifiers =
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3
- Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
+ Programming Language :: Python :: 3.13
Topic :: Software Development :: User Interfaces
Topic :: Text Processing
@@ -35,7 +35,7 @@ include_package_data = True
entry_points = file: entry_points.txt
packages = find:
package_dir = = lib
-python_requires = >=3.8
+python_requires = >=3.9
tests_require = testtools; coverage
[options.packages.find]
@@ -44,5 +44,3 @@ where = lib
[options.extras_require]
wcwidth =
wcwidth
-conversion =
- astunparse; python_version<"3.9"
diff --git a/tests/data/test_examples_documentation/action_with_args.rst b/tests/data/test_examples_documentation/action_with_args.rst
index b2614fb8..344201d9 100644
--- a/tests/data/test_examples_documentation/action_with_args.rst
+++ b/tests/data/test_examples_documentation/action_with_args.rst
@@ -13,22 +13,25 @@ Action With Args
.. table::
:widths: auto
- +---------------------+----------------------------------------------------------------------------------+
- | Actions | .. table:: |
- | | :widths: auto |
- | | |
- | | +-------------+------------------------------------------------------------+ |
- | | | ``echo`` | Echo the provided text | |
- | | +-------------+------------------------------------------------------------+ |
- | | | ``split`` | Split the provided text so that each word is on a new line | |
- | | +-------------+------------------------------------------------------------+ |
- | | | ``double`` | Print the provided text twice | |
- | | +-------------+------------------------------------------------------------+ |
- | | | ``reverse`` | Reverse the provided text | |
- | | +-------------+------------------------------------------------------------+ |
- +---------------------+----------------------------------------------------------------------------------+
- | ``TEXT [TEXT ...]`` | The text to print |
- +---------------------+----------------------------------------------------------------------------------+
+ +----------------------------------------------------------------------------------+-------------------+
+ | |
+ | .. rubric:: Actions |
+ | |
+ | .. table:: |
+ | :widths: auto |
+ | |
+ | +-------------+------------------------------------------------------------+ |
+ | | ``echo`` | Echo the provided text | |
+ | +-------------+------------------------------------------------------------+ |
+ | | ``split`` | Split the provided text so that each word is on a new line | |
+ | +-------------+------------------------------------------------------------+ |
+ | | ``double`` | Print the provided text twice | |
+ | +-------------+------------------------------------------------------------+ |
+ | | ``reverse`` | Reverse the provided text | |
+ | +-------------+------------------------------------------------------------+ |
+ +----------------------------------------------------------------------------------+-------------------+
+ | ``TEXT [TEXT ...]`` | The text to print |
+ +----------------------------------------------------------------------------------+-------------------+
.. rubric:: Optional arguments
diff --git a/tests/data/test_examples_documentation/advanced_subcommand.rst b/tests/data/test_examples_documentation/advanced_subcommand.rst
index e1453298..762eaa93 100644
--- a/tests/data/test_examples_documentation/advanced_subcommand.rst
+++ b/tests/data/test_examples_documentation/advanced_subcommand.rst
@@ -8,25 +8,20 @@ Advanced Subcommand
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+---------------------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +-------------+-----------+ |
- | | | ``foo`` | Print foo | |
- | | +-------------+-----------+ |
- | | | ``run foo`` | Run foo | |
- | | +-------------+-----------+ |
- | | | ``run bar`` | Print bar | |
- | | +-------------+-----------+ |
- | | | ``baz`` | Print baz | |
- | | +-------------+-----------+ |
- +-------------+---------------------------------+
+ +-------------+-----------+
+ | ``foo`` | Print foo |
+ +-------------+-----------+
+ | ``run foo`` | Run foo |
+ +-------------+-----------+
+ | ``run bar`` | Print bar |
+ +-------------+-----------+
+ | ``baz`` | Print baz |
+ +-------------+-----------+
.. rubric:: Optional arguments
diff --git a/tests/data/test_examples_documentation/complex__all.rst b/tests/data/test_examples_documentation/complex__all.rst
index ef182c12..535d593c 100644
--- a/tests/data/test_examples_documentation/complex__all.rst
+++ b/tests/data/test_examples_documentation/complex__all.rst
@@ -20,23 +20,18 @@ base Command so that the base Command is made aware of the presence of the subco
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+-----------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +------------+--+ |
- | | | ``hello`` | | |
- | | +------------+--+ |
- | | | ``logs`` | | |
- | | +------------+--+ |
- | | | ``update`` | | |
- | | +------------+--+ |
- +-------------+-----------------------+
+ +------------+--+
+ | ``hello`` | |
+ +------------+--+
+ | ``logs`` | |
+ +------------+--+
+ | ``update`` | |
+ +------------+--+
.. rubric:: Optional arguments
@@ -108,25 +103,20 @@ Subcommand: update
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+----------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +-----------+--+ |
- | | | ``foo`` | | |
- | | +-----------+--+ |
- | | | ``bar`` | | |
- | | +-----------+--+ |
- | | | ``user`` | | |
- | | +-----------+--+ |
- | | | ``group`` | | |
- | | +-----------+--+ |
- +-------------+----------------------+
+ +-----------+--+
+ | ``foo`` | |
+ +-----------+--+
+ | ``bar`` | |
+ +-----------+--+
+ | ``user`` | |
+ +-----------+--+
+ | ``group`` | |
+ +-----------+--+
.. rubric:: Optional arguments
@@ -176,25 +166,20 @@ Subcommand: update foo
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+----------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +-----------+--+ |
- | | | ``foo`` | | |
- | | +-----------+--+ |
- | | | ``bar`` | | |
- | | +-----------+--+ |
- | | | ``user`` | | |
- | | +-----------+--+ |
- | | | ``group`` | | |
- | | +-----------+--+ |
- +-------------+----------------------+
+ +-----------+--+
+ | ``foo`` | |
+ +-----------+--+
+ | ``bar`` | |
+ +-----------+--+
+ | ``user`` | |
+ +-----------+--+
+ | ``group`` | |
+ +-----------+--+
.. rubric:: Optional arguments
@@ -244,25 +229,20 @@ Subcommand: update bar
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+----------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +-----------+--+ |
- | | | ``foo`` | | |
- | | +-----------+--+ |
- | | | ``bar`` | | |
- | | +-----------+--+ |
- | | | ``user`` | | |
- | | +-----------+--+ |
- | | | ``group`` | | |
- | | +-----------+--+ |
- +-------------+----------------------+
+ +-----------+--+
+ | ``foo`` | |
+ +-----------+--+
+ | ``bar`` | |
+ +-----------+--+
+ | ``user`` | |
+ +-----------+--+
+ | ``group`` | |
+ +-----------+--+
.. rubric:: Optional arguments
diff --git a/tests/data/test_examples_documentation/complex__depth_0.rst b/tests/data/test_examples_documentation/complex__depth_0.rst
index 8a1b6a1b..8ddf84d9 100644
--- a/tests/data/test_examples_documentation/complex__depth_0.rst
+++ b/tests/data/test_examples_documentation/complex__depth_0.rst
@@ -20,23 +20,18 @@ base Command so that the base Command is made aware of the presence of the subco
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+-----------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +------------+--+ |
- | | | ``hello`` | | |
- | | +------------+--+ |
- | | | ``logs`` | | |
- | | +------------+--+ |
- | | | ``update`` | | |
- | | +------------+--+ |
- +-------------+-----------------------+
+ +------------+--+
+ | ``hello`` | |
+ +------------+--+
+ | ``logs`` | |
+ +------------+--+
+ | ``update`` | |
+ +------------+--+
.. rubric:: Optional arguments
diff --git a/tests/data/test_examples_documentation/complex__depth_1.rst b/tests/data/test_examples_documentation/complex__depth_1.rst
index d143c6b7..cbb1a9ee 100644
--- a/tests/data/test_examples_documentation/complex__depth_1.rst
+++ b/tests/data/test_examples_documentation/complex__depth_1.rst
@@ -20,23 +20,18 @@ base Command so that the base Command is made aware of the presence of the subco
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+-----------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +------------+--+ |
- | | | ``hello`` | | |
- | | +------------+--+ |
- | | | ``logs`` | | |
- | | +------------+--+ |
- | | | ``update`` | | |
- | | +------------+--+ |
- +-------------+-----------------------+
+ +------------+--+
+ | ``hello`` | |
+ +------------+--+
+ | ``logs`` | |
+ +------------+--+
+ | ``update`` | |
+ +------------+--+
.. rubric:: Optional arguments
@@ -108,25 +103,20 @@ Subcommand: update
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+----------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +-----------+--+ |
- | | | ``foo`` | | |
- | | +-----------+--+ |
- | | | ``bar`` | | |
- | | +-----------+--+ |
- | | | ``user`` | | |
- | | +-----------+--+ |
- | | | ``group`` | | |
- | | +-----------+--+ |
- +-------------+----------------------+
+ +-----------+--+
+ | ``foo`` | |
+ +-----------+--+
+ | ``bar`` | |
+ +-----------+--+
+ | ``user`` | |
+ +-----------+--+
+ | ``group`` | |
+ +-----------+--+
.. rubric:: Optional arguments
diff --git a/tests/data/test_examples_documentation/shared_logging_init.rst b/tests/data/test_examples_documentation/shared_logging_init.rst
index dfb710b4..861b3d9d 100644
--- a/tests/data/test_examples_documentation/shared_logging_init.rst
+++ b/tests/data/test_examples_documentation/shared_logging_init.rst
@@ -8,19 +8,14 @@ Shared Logging Init
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+--------------------------------------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +----------+-------------------------------+ |
- | | | ``show`` | Show the results of an action | |
- | | +----------+-------------------------------+ |
- +-------------+--------------------------------------------------+
+ +----------+-------------------------------+
+ | ``show`` | Show the results of an action |
+ +----------+-------------------------------+
.. rubric:: Optional arguments
@@ -50,25 +45,20 @@ Show the results of an action
-.. rubric:: Positional arguments
+.. rubric:: Actions
.. table::
:widths: auto
- +---------+-------------------------+
- | Actions | .. table:: |
- | | :widths: auto |
- | | |
- | | +--------------+--+ |
- | | | ``attrs`` | | |
- | | +--------------+--+ |
- | | | ``hello`` | | |
- | | +--------------+--+ |
- | | | ``log_test`` | | |
- | | +--------------+--+ |
- | | | ``rst`` | | |
- | | +--------------+--+ |
- +---------+-------------------------+
+ +--------------+--+
+ | ``attrs`` | |
+ +--------------+--+
+ | ``hello`` | |
+ +--------------+--+
+ | ``log_test`` | |
+ +--------------+--+
+ | ``rst`` | |
+ +--------------+--+
.. rubric:: Optional arguments
diff --git a/tests/data/test_rst/basic_subcommand_no_help.rst b/tests/data/test_rst/basic_subcommand_no_help.rst
index 73cc8dcf..8d39b0ac 100644
--- a/tests/data/test_rst/basic_subcommand_no_help.rst
+++ b/tests/data/test_rst/basic_subcommand_no_help.rst
@@ -8,19 +8,14 @@ basic_subcommand_no_help
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+--------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +---------+--+ |
- | | | ``foo`` | | |
- | | +---------+--+ |
- +-------------+--------------------+
+ +---------+--+
+ | ``foo`` | |
+ +---------+--+
Subcommands
diff --git a/tests/data/test_rst/sub_cmd_with_mid_abc.rst b/tests/data/test_rst/sub_cmd_with_mid_abc.rst
index 11bbe491..a7684366 100644
--- a/tests/data/test_rst/sub_cmd_with_mid_abc.rst
+++ b/tests/data/test_rst/sub_cmd_with_mid_abc.rst
@@ -12,19 +12,14 @@ Test case for RST documentation generation.
test
-.. rubric:: Positional arguments
+.. rubric:: Subcommands
.. table::
:widths: auto
- +-------------+--------------------------+
- | Subcommands | .. table:: |
- | | :widths: auto |
- | | |
- | | +---------+--------+ |
- | | | ``sub`` | do foo | |
- | | +---------+--------+ |
- +-------------+--------------------------+
+ +---------+--------+
+ | ``sub`` | do foo |
+ +---------+--------+
.. rubric:: Optional arguments
diff --git a/tests/test_core/test_metadata.py b/tests/test_core/test_metadata.py
index b245582c..831990f4 100644
--- a/tests/test_core/test_metadata.py
+++ b/tests/test_core/test_metadata.py
@@ -162,7 +162,7 @@ def test_prog_from_sys_argv(self):
def test_entry_points_old(self):
entry_points = {'console_scripts': ep_scripts(('bar.py', 'foo:bar'), ('baz.py', 'foo:baz'))}
expected = {'foo': {'bar': 'bar.py', 'baz': 'baz.py'}}
- with patch(f'{MODULE}.entry_points', side_effect=[TypeError, entry_points]): # Simulate py 3.8/3.9
+ with patch(f'{MODULE}.entry_points', side_effect=[TypeError, entry_points]): # Simulate py 3.9
self.assertDictEqual(expected, ProgFinder().mod_obj_prog_map)
def test_entry_points_new(self):
diff --git a/tests/test_documentation/test_help_text.py b/tests/test_documentation/test_help_text.py
index 5868c14e..21f8598d 100644
--- a/tests/test_documentation/test_help_text.py
+++ b/tests/test_documentation/test_help_text.py
@@ -494,7 +494,7 @@ def assert_help_and_rst_match(
with self.subTest(mode=mode, **cmd_kwargs):
expected_help = prep_expected_help_text(help_header, param_help_map)
- expected_rst = prep_expected_rst(' | | {:<13s} |\n', param_help_map)
+ expected_rst = prep_expected_rst(' {:<13s}', param_help_map)
class Foo(Command, **cmd_kwargs):
sub_cmd = SubCommand(**sc_kwargs)
@@ -658,7 +658,7 @@ def prep_expected_help_text(help_header: str, param_help_map: dict[str, str], in
def prep_expected_rst(table_fmt_str: str, param_help_map: dict[str, str]) -> str:
rf = table_fmt_str.format
table = RstTable.from_dict({f'``{k}``': v for k, v in param_help_map.items()}, use_table_directive=False)
- return ''.join(rf(line) for line in table.iter_build() if line)
+ return '\n'.join(rf(line).rstrip() for line in table.iter_build() if line)
class ShowDefaultsTest(TestCase):
diff --git a/tests/test_documentation/test_rst_basics.py b/tests/test_documentation/test_rst_basics.py
index 63e9c977..b9e44ea4 100644
--- a/tests/test_documentation/test_rst_basics.py
+++ b/tests/test_documentation/test_rst_basics.py
@@ -2,7 +2,15 @@
from unittest import main
-from cli_command_parser.formatting.restructured_text import rst_bar, rst_header, rst_list_table, rst_directive, RstTable
+from cli_command_parser.formatting.restructured_text import (
+ Cell,
+ Row,
+ RstTable,
+ rst_bar,
+ rst_directive,
+ rst_header,
+ rst_list_table,
+)
from cli_command_parser.testing import ParserTest
@@ -42,6 +50,9 @@ class RstTableTest(ParserTest):
def test_table_repr(self):
self.assertTrue(repr(RstTable()).startswith('", repr(Cell('one\ntwo')))
+
def test_table_insert(self):
table = RstTable(use_table_directive=False)
table.add_row('x', 'y', 'z')
@@ -73,6 +84,35 @@ def test_table_with_columns(self):
table = RstTable.from_dicts(rows, columns=('foo',), use_table_directive=False)
self.assert_strings_equal('+-----+\n| 123 |\n+-----+\n| 345 |\n+-----+\n', str(table))
+ def test_table_with_extended_cells(self):
+ rows = [
+ Row([Cell('a'), Cell('b'), Cell('c'), Cell('d')], True),
+ Row([Cell('one\ntwo', ext_below=True), Cell('three and four', ext_right=True), Cell(), Cell('abc')]),
+ Row([Cell(), Cell('five'), Cell('six', ext_right=True), Cell()]),
+ Row([Cell('def'), Cell('ghi'), Cell('jkl'), Cell('mno')]),
+ Row([Cell('1'), Cell('2'), Cell('3', ext_right=True, ext_below=True), Cell(ext_below=True)]),
+ Row([Cell('4'), Cell('5'), Cell(ext_right=True), Cell()]),
+ ]
+ table = RstTable(use_table_directive=False)
+ table._add_rows(rows)
+ expected = """
++-----+----------------+-----+-----+
+| a | b | c | d |
++=====+================+=====+=====+
+| one | three and four | abc |
+| two | | |
+| +----------------+-----+-----+
+| | five | six |
++-----+----------------+-----+-----+
+| def | ghi | jkl | mno |
++-----+----------------+-----+-----+
+| 1 | 2 | 3 |
++-----+----------------+ +
+| 4 | 5 | |
++-----+----------------+-----+-----+
+ """.strip()
+ self.assert_strings_equal(expected, str(table), trim=True)
+
if __name__ == '__main__':
# import logging
diff --git a/tests/test_parameters/test_annotations.py b/tests/test_parameters/test_annotations.py
index 8e670ecd..680334da 100644
--- a/tests/test_parameters/test_annotations.py
+++ b/tests/test_parameters/test_annotations.py
@@ -2,12 +2,10 @@
import sys
from pathlib import Path
-from typing import Optional, Collection, Sequence, Iterable, Union
+from typing import Collection, Iterable, Optional, Sequence, Union
from unittest import main, skipIf
-from unittest.mock import Mock
-from cli_command_parser import Command, Positional, Option, inputs
-from cli_command_parser.annotations import get_args
+from cli_command_parser import Command, Option, Positional, inputs
from cli_command_parser.testing import ParserTest, load_command
THIS_FILE = Path(__file__).resolve()
@@ -15,10 +13,6 @@
class AnnotationsTest(ParserTest):
- def test_get_args(self):
- # This is for coverage in 3.9+ for the get_args compatibility wrapper, to mock the attr present in 3.8 & below
- self.assertEqual((), get_args(Mock(_special=True)))
-
def test_annotation_using_forward_ref(self):
with load_command(TEST_DATA_DIR, 'annotation_using_forward_ref.py', 'AnnotatedCommand') as AnnotatedCmd:
self.assertIs(None, AnnotatedCmd.paths_a.type)