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)