cli-command-parser 2024.9.7__tar.gz → 2024.11.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {cli_command_parser-2024.9.7/lib/cli_command_parser.egg-info → cli_command_parser-2024.11.2}/PKG-INFO +6 -18
  2. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/__version__.py +1 -1
  3. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/annotations.py +1 -10
  4. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/compat.py +0 -10
  5. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/utils.py +2 -19
  6. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/documentation.py +1 -1
  7. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/formatting/commands.py +11 -6
  8. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/formatting/params.py +17 -10
  9. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/formatting/restructured_text.py +94 -55
  10. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/metadata.py +69 -4
  11. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/choice_map.py +2 -0
  12. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/groups.py +7 -0
  13. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2/lib/cli_command_parser.egg-info}/PKG-INFO +6 -18
  14. cli_command_parser-2024.11.2/lib/cli_command_parser.egg-info/requires.txt +3 -0
  15. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/readme.rst +3 -13
  16. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/requirements-dev.txt +1 -1
  17. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/setup.cfg +2 -4
  18. cli_command_parser-2024.9.7/lib/cli_command_parser.egg-info/requires.txt +0 -8
  19. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/LICENSE +0 -0
  20. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/MANIFEST.in +0 -0
  21. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/entry_points.txt +0 -0
  22. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/__init__.py +0 -0
  23. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/__main__.py +0 -0
  24. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/command_parameters.py +0 -0
  25. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/commands.py +0 -0
  26. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/config.py +0 -0
  27. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/context.py +0 -0
  28. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/__init__.py +0 -0
  29. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/__main__.py +0 -0
  30. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/argparse_ast.py +0 -0
  31. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/argparse_utils.py +0 -0
  32. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/cli.py +0 -0
  33. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/command_builder.py +0 -0
  34. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/conversion/visitor.py +0 -0
  35. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/core.py +0 -0
  36. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/error_handling.py +0 -0
  37. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/exceptions.py +0 -0
  38. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/formatting/__init__.py +0 -0
  39. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/formatting/utils.py +0 -0
  40. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/__init__.py +0 -0
  41. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/base.py +0 -0
  42. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/choices.py +0 -0
  43. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/exceptions.py +0 -0
  44. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/files.py +0 -0
  45. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/numeric.py +0 -0
  46. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/patterns.py +0 -0
  47. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/time.py +0 -0
  48. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/inputs/utils.py +0 -0
  49. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/nargs.py +0 -0
  50. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/__init__.py +0 -0
  51. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/actions.py +0 -0
  52. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/base.py +0 -0
  53. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/option_strings.py +0 -0
  54. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/options.py +0 -0
  55. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/pass_thru.py +0 -0
  56. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parameters/positionals.py +0 -0
  57. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parse_tree.py +0 -0
  58. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/parser.py +0 -0
  59. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/testing.py +0 -0
  60. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/typing.py +0 -0
  61. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser/utils.py +0 -0
  62. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser.egg-info/SOURCES.txt +0 -0
  63. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser.egg-info/dependency_links.txt +0 -0
  64. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser.egg-info/entry_points.txt +0 -0
  65. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/lib/cli_command_parser.egg-info/top_level.txt +0 -0
  66. {cli_command_parser-2024.9.7 → cli_command_parser-2024.11.2}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli_command_parser
3
- Version: 2024.9.7
3
+ Version: 2024.11.2
4
4
  Summary: CLI Command Parser
5
5
  Home-page: https://github.com/dskrypa/cli_command_parser
6
6
  Author: Doug Skrypa
@@ -16,27 +16,25 @@ Classifier: License :: OSI Approved :: Apache Software License
16
16
  Classifier: Operating System :: OS Independent
17
17
  Classifier: Programming Language :: Python
18
18
  Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.8
20
19
  Classifier: Programming Language :: Python :: 3.9
21
20
  Classifier: Programming Language :: Python :: 3.10
22
21
  Classifier: Programming Language :: Python :: 3.11
23
22
  Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Software Development :: User Interfaces
25
25
  Classifier: Topic :: Text Processing
26
- Requires-Python: >=3.8
26
+ Requires-Python: >=3.9
27
27
  Description-Content-Type: text/x-rst
28
28
  License-File: LICENSE
29
29
  Provides-Extra: wcwidth
30
30
  Requires-Dist: wcwidth; extra == "wcwidth"
31
- Provides-Extra: conversion
32
- Requires-Dist: astunparse; python_version < "3.9" and extra == "conversion"
33
31
 
34
32
  CLI Command Parser
35
33
  ##################
36
34
 
37
35
  |downloads| |py_version| |coverage_badge| |build_status| |Ruff|
38
36
 
39
- .. |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
37
+ .. |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
40
38
  :target: https://pypi.org/project/cli-command-parser/
41
39
 
42
40
  .. |coverage_badge| image:: https://codecov.io/gh/dskrypa/cli_command_parser/branch/main/graph/badge.svg
@@ -118,18 +116,8 @@ with optional dependencies::
118
116
  Python Version Compatibility
119
117
  ============================
120
118
 
121
- Python versions 3.8 and above are currently supported. The last release of CLI Command Parser that supported 3.7 was
122
- 2023-04-30. Support for Python 3.7 `officially ended on 2023-06-27 <https://devguide.python.org/versions/>`__.
123
-
124
- When using Python 3.8, some additional packages that backport functionality that was added in later Python versions
125
- are required for compatibility.
126
-
127
- To use the argparse to cli-command-parser conversion script with Python 3.8, there is a dependency on
128
- `astunparse <https://astunparse.readthedocs.io>`__. If you are using Python 3.9 or above, then ``astunparse`` is not
129
- necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
130
- cli-command-parser with the following command to automatically handle whether that extra dependency is needed or not::
131
-
132
- $ pip install -U cli-command-parser[conversion]
119
+ Python versions 3.9 and above are currently supported. The last release of CLI Command Parser that supported 3.8 was
120
+ 2024-09-07. Support for Python 3.8 `officially ended on 2024-10-07 <https://devguide.python.org/versions/>`__.
133
121
 
134
122
 
135
123
  Links
@@ -1,7 +1,7 @@
1
1
  __title__ = 'cli_command_parser'
2
2
  __description__ = 'CLI Command Parser'
3
3
  __url__ = 'https://github.com/dskrypa/cli_command_parser'
4
- __version__ = '2024.09.07'
4
+ __version__ = '2024.11.02'
5
5
  __author__ = 'Doug Skrypa'
6
6
  __author_email__ = 'dskrypa@gmail.com'
7
7
  __license__ = 'Apache 2.0'
@@ -7,7 +7,7 @@ Utilities for extracting types from annotations.
7
7
  from collections.abc import Collection, Iterable
8
8
  from functools import lru_cache
9
9
  from inspect import isclass
10
- from typing import Union, Optional, get_type_hints as _get_type_hints, get_origin, get_args as _get_args
10
+ from typing import Optional, Union, get_args, get_origin, get_type_hints as _get_type_hints
11
11
 
12
12
  try:
13
13
  from types import NoneType
@@ -42,15 +42,6 @@ def get_annotation_value_type(annotation, from_union: bool = True, from_collecti
42
42
  return None
43
43
 
44
44
 
45
- def get_args(annotation) -> tuple:
46
- """
47
- Wrapper around :func:`python:typing.get_args` for 3.7~8 compatibility, to make it behave more like it does in 3.9+
48
- """
49
- if getattr(annotation, '_special', False): # 3.7-3.8 generic collection alias with no content types
50
- return ()
51
- return _get_args(annotation)
52
-
53
-
54
45
  def _type_from_union(annotation) -> Optional[type]:
55
46
  args = get_args(annotation)
56
47
  # Note: Unions of a single argument return the argument; i.e., Union[T] returns T, so the len can never be 1
@@ -1,9 +1,4 @@
1
1
  """
2
- Compatibility / Patch module - used to back-port features to Python 3.7 and to avoid breaking changes in Enum/Flag in
3
- 3.11.
4
-
5
- Contains stdlib CPython functions / classes from Python 3.8 and 3.10.
6
-
7
2
  The :class:`WCTextWrapper` in this module extends the stdlib :class:`python:textwrap.TextWrapper` to support wide
8
3
  characters.
9
4
  """
@@ -16,8 +11,6 @@ from .utils import wcswidth
16
11
 
17
12
  __all__ = ['WCTextWrapper']
18
13
 
19
- # region textwrap
20
-
21
14
 
22
15
  class WCTextWrapper(TextWrapper):
23
16
  """
@@ -119,6 +112,3 @@ class WCTextWrapper(TextWrapper):
119
112
  break
120
113
 
121
114
  return lines
122
-
123
-
124
- # endregion
@@ -1,24 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from ast import AST, expr, Call, Attribute, Name, Dict, List, Set, Tuple
4
-
5
- try:
6
- from ast import unparse
7
- except ImportError: # added in 3.9
8
- try:
9
- from astunparse import unparse as _unparse
10
- except ImportError as e:
11
- raise RuntimeError(
12
- 'Missing required dependency: astunparse (only required in Python 3.8 and below'
13
- ' - upgrade to 3.9 or above to avoid this dependency)'
14
- )
15
- else:
16
-
17
- def unparse(node):
18
- return ''.join(_unparse(node).splitlines())
19
-
20
-
21
- from typing import Union, Iterator, List as _List
3
+ from ast import AST, Attribute, Call, Dict, List, Name, Set, Tuple, expr, unparse
4
+ from typing import Iterator, List as _List, Union
22
5
 
23
6
  __all__ = ['get_name_repr', 'iter_module_parents', 'collection_contents']
24
7
 
@@ -372,6 +372,6 @@ class RstWriter:
372
372
  path = target_dir.joinpath(name + self.ext)
373
373
  log.debug(f'{prefix} {path.as_posix()}')
374
374
  if not self.dry_run:
375
- # Path.write_text on 3.8 does not support `newline`
375
+ # Path.write_text on 3.9 does not support `newline`
376
376
  with path.open('w', encoding=self.encoding, newline=self.newline) as f:
377
377
  f.write(content)
@@ -12,9 +12,10 @@ from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Optional, Type,
12
12
 
13
13
  from ..context import NoActiveContext, ctx
14
14
  from ..core import get_metadata, get_params
15
+ from ..parameters.choice_map import ChoiceMap
15
16
  from ..parameters.groups import ParamGroup
16
17
  from ..utils import _NotSet, camel_to_snake_case
17
- from .restructured_text import RstTable, spaced_rst_header
18
+ from .restructured_text import spaced_rst_header
18
19
  from .utils import PartWrapper
19
20
 
20
21
  if TYPE_CHECKING:
@@ -47,6 +48,7 @@ class CommandHelpFormatter:
47
48
  for group in groups:
48
49
  if group.group: # prevent duplicates
49
50
  continue
51
+
50
52
  if group.contains_positional:
51
53
  self.pos_group.add(group)
52
54
  else:
@@ -162,14 +164,17 @@ class CommandHelpFormatter:
162
164
  yield description
163
165
  yield ''
164
166
 
165
- # TODO: The subcommand names in the group containing subcommand targets should link to their respective
166
- # subcommand sections
167
- for group in self.groups:
167
+ if self.pos_group.show_in_help:
168
168
  # TODO: Nested subcommands' local choices should not repeat the `subcommands` positional arguments section
169
169
  # that includes the nested subcommand choice being documented
170
+ if len(members := self.pos_group.members) == 1 and isinstance(members[0], ChoiceMap):
171
+ yield from members[0].formatter.rst_table().iter_build() # noqa
172
+ else:
173
+ yield from self.pos_group.formatter.rst_table().iter_build()
174
+
175
+ for group in self.groups[1:]:
170
176
  if group.show_in_help:
171
- table: RstTable = group.formatter.rst_table() # noqa
172
- yield from table.iter_build()
177
+ yield from group.formatter.rst_table().iter_build()
173
178
 
174
179
  if include_epilog and (epilog := self._meta.format_epilog(config.extended_epilog, allow_sys_argv)):
175
180
  yield epilog
@@ -16,7 +16,7 @@ from ..core import get_config
16
16
  from ..parameters import ParamGroup, PassThru, TriFlag
17
17
  from ..parameters.base import BaseOption, BasePositional
18
18
  from ..parameters.choice_map import Choice, ChoiceMap
19
- from .restructured_text import RstTable
19
+ from .restructured_text import Cell, Row, RstTable
20
20
  from .utils import _should_add_default, format_help_entry
21
21
 
22
22
  if TYPE_CHECKING:
@@ -223,6 +223,8 @@ class TriFlagHelpFormatter(OptionHelpFormatter, param_cls=TriFlag):
223
223
 
224
224
 
225
225
  class ChoiceMapHelpFormatter(ParamHelpFormatter, param_cls=ChoiceMap):
226
+ """Formatter for :class:`SubCommand` and :class:`Action` parameters (and any other params that extend ChoiceMap)"""
227
+
226
228
  param: ChoiceMap
227
229
 
228
230
  @cached_property
@@ -269,6 +271,7 @@ class ChoiceMapHelpFormatter(ParamHelpFormatter, param_cls=ChoiceMap):
269
271
 
270
272
  def _format_rst_rows(self) -> Iterator[tuple[str, OptStr]]:
271
273
  mode = ctx.config.cmd_alias_mode or SubcommandAliasHelpMode.ALIAS
274
+ # TODO: The subcommand names should link to their respective subcommand sections
272
275
  for choice_group in self.choice_groups:
273
276
  for choice, usage, description in choice_group.prepare(mode):
274
277
  yield f'``{usage}``', description
@@ -492,15 +495,19 @@ class GroupHelpFormatter(ParamHelpFormatter, param_cls=ParamGroup): # noqa # p
492
495
  table = RstTable(self.format_description())
493
496
  # TODO: non-nested when config.show_group_tree is False; maybe separate options for rst vs help
494
497
  for member in self.param.members:
495
- if member.show_in_help:
496
- formatter = member.formatter
497
- try:
498
- sub_table: RstTable = formatter.rst_table() # noqa
499
- except AttributeError:
500
- table.add_rows(formatter.rst_rows())
501
- else:
502
- sub_table.show_title = False
503
- table.add_row(sub_table.title, str(sub_table))
498
+ if not member.show_in_help:
499
+ continue
500
+
501
+ formatter = member.formatter
502
+ try:
503
+ sub_table: RstTable = formatter.rst_table() # noqa
504
+ except AttributeError:
505
+ table.add_rows(formatter.rst_rows())
506
+ else:
507
+ table._add_row(Row([Cell(str(sub_table), ext_right=True), Cell()]))
508
+ # If a config option to switch to the old way is added later, the old approach:
509
+ # sub_table.show_title = False
510
+ # table.add_row(sub_table.title, str(sub_table))
504
511
 
505
512
  return table
506
513
 
@@ -6,11 +6,8 @@ Utilities for formatting data using RST markup
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from itertools import starmap
10
9
  from typing import TYPE_CHECKING, Any, Iterable, Iterator, Mapping, Sequence, TypeVar, Union
11
10
 
12
- from .utils import line_iter
13
-
14
11
  if TYPE_CHECKING:
15
12
  from ..typing import Bool, OptStr, Strings
16
13
 
@@ -119,7 +116,7 @@ class RstTable:
119
116
  body of this table.
120
117
  """
121
118
 
122
- __slots__ = ('title', 'subtitle', 'show_title', 'use_table_directive', 'rows', 'widths')
119
+ __slots__ = ('title', 'subtitle', 'show_title', 'use_table_directive', '_rows', '_widths', '_updated')
123
120
 
124
121
  def __init__(
125
122
  self,
@@ -134,8 +131,9 @@ class RstTable:
134
131
  self.subtitle = subtitle
135
132
  self.show_title = show_title
136
133
  self.use_table_directive = use_table_directive
137
- self.rows = []
138
- self.widths = []
134
+ self._rows = []
135
+ self._widths = ()
136
+ self._updated = False
139
137
  if headers:
140
138
  self.add_row(*headers, header=True)
141
139
 
@@ -163,6 +161,13 @@ class RstTable:
163
161
  table.add_kv_rows(data)
164
162
  return table
165
163
 
164
+ @property
165
+ def widths(self) -> tuple[int, ...]:
166
+ if self._updated:
167
+ self._widths = tuple(max(col) for col in zip(*(row.widths() for row in self._rows)))
168
+ self._updated = False
169
+ return self._widths
170
+
166
171
  def add_dict_rows(self, rows: RowMaps, columns: Sequence[T] = None, add_header: Bool = False):
167
172
  """Add a row for each dict in the given sequence of rows, where the keys represent the columns."""
168
173
  if not columns:
@@ -180,8 +185,11 @@ class RstTable:
180
185
  self.add_rows(data.items())
181
186
 
182
187
  def add_rows(self, rows: Iterable[Iterable[OptStr]]):
183
- for row in rows:
184
- self.add_row(*row)
188
+ self._add_rows(Row([Cell(c or '') for c in columns]) for columns in rows)
189
+
190
+ def _add_rows(self, rows: Iterable[Row]):
191
+ self._rows.extend(rows)
192
+ self._updated = True
185
193
 
186
194
  def add_row(self, *columns: OptStr, index: int = None, header: bool = False):
187
195
  """
@@ -192,34 +200,18 @@ class RstTable:
192
200
  the list of rows.
193
201
  :param header: If True, this row will be treated as a header row. Does not affect insertion order.
194
202
  """
195
- any_new_line, widths = _widths(columns)
196
- if self.widths:
197
- self.widths = tuple(starmap(max, zip(self.widths, widths)))
198
- else:
199
- self.widths = tuple(widths)
203
+ self._add_row(Row([Cell(c or '') for c in columns], header), index)
200
204
 
201
- columns = tuple(c or '' for c in columns)
205
+ def _add_row(self, row: Row, index: int = None):
202
206
  if index is None:
203
- self.rows.append((header, any_new_line, columns))
207
+ self._rows.append(row)
204
208
  else:
205
- self.rows.insert(index, (header, any_new_line, columns))
206
-
207
- def bar(self, char: str = '-') -> str:
208
- """
209
- :param char: The character to use for the bar. Defaults to ``-`` (for normal rows). Use ``=`` below a header
210
- row. See :du_rst:`Grid Tables<grid-tables>` for more info.
211
- :return: The formatted bar string
212
- """
213
- pre = ' ' if self.use_table_directive else ''
214
- return '+'.join([pre, *(char * (w + 2) for w in self.widths), ''])
215
-
216
- def _get_row_format(self) -> str:
217
- pre = ' ' if self.use_table_directive else ''
218
- return '|'.join([pre, *(f' {{:<{w}s}} ' for w in self.widths), ''])
209
+ self._rows.insert(index, row)
210
+ self._updated = True
219
211
 
220
212
  def __repr__(self) -> str:
221
213
  return (
222
- f'<RstTable[use_table_directive={self.use_table_directive}, rows={len(self.rows)},'
214
+ f'<RstTable[use_table_directive={self.use_table_directive}, rows={len(self._rows)},'
223
215
  f' title={self.title!r}, widths={self.widths}]>'
224
216
  )
225
217
 
@@ -230,38 +222,85 @@ class RstTable:
230
222
  yield ''
231
223
 
232
224
  if self.use_table_directive:
233
- options = {'subtitle': self.subtitle, 'widths': 'auto'}
234
- yield from _rst_directive('table', options=options, check=True)
225
+ yield from _rst_directive('table', options={'subtitle': self.subtitle, 'widths': 'auto'}, check=True)
235
226
  yield ''
236
-
237
- bar, header_bar = self.bar(), self.bar('=')
238
- format_row = self._get_row_format().format
239
- yield bar
240
- for header, any_new_line, row in self.rows:
241
- if any_new_line:
242
- for line in line_iter(*row):
243
- yield format_row(*line)
244
- else:
245
- yield format_row(*row)
246
-
247
- yield header_bar if header else bar
227
+ for line in self._iter_render():
228
+ yield ' ' + line
229
+ else:
230
+ yield from self._iter_render()
248
231
 
249
232
  yield ''
250
233
 
234
+ def _iter_render(self) -> Iterator[str]:
235
+ col_widths = self.widths
236
+ yield self._rows[0].render_upper_bar(col_widths)
237
+ for row in self._rows:
238
+ yield from row.render_lines(col_widths)
239
+
251
240
  def __str__(self) -> str:
252
241
  return '\n'.join(self.iter_build())
253
242
 
254
243
 
255
- def _widths(columns: Iterable[OptStr]) -> tuple[bool, list[int]]:
256
- widths = []
257
- any_new_line = False
258
- for column in columns:
259
- if not column:
260
- widths.append(0)
261
- elif '\n' in column:
262
- any_new_line = True
263
- widths.append(max(map(len, column.splitlines())))
244
+ class Cell:
245
+ __slots__ = ('text', 'lines', 'width', 'height', 'brd_bottom', 'brd_right')
246
+
247
+ def __init__(self, text: str = '', *, ext_right: bool = False, ext_below: bool = False):
248
+ self.text = text
249
+ if text:
250
+ self.lines = text.splitlines()
251
+ self.width = max(map(len, self.lines))
252
+ self.height = len(self.lines)
264
253
  else:
265
- widths.append(len(column))
254
+ self.lines = ['']
255
+ self.width = 0
256
+ self.height = 1
257
+
258
+ self.brd_bottom = not ext_below
259
+ self.brd_right = not ext_right
260
+
261
+ def render_upper_bar(self, width: int) -> str:
262
+ return ('-' * (width + 2)) + ('+' if self.brd_bottom else '|')
263
+
264
+ def render_lower_bar(self, width: int, char: str = '-') -> str:
265
+ if not self.brd_bottom:
266
+ char = ' '
267
+ return (char * (width + 2)) + ('+' if self.brd_bottom or self.brd_right else ' ')
268
+
269
+ def render_lines(self, width: int, max_lines: int) -> Iterator[str]:
270
+ format_line = f' {{:<{width}s}} {"|" if self.brd_right else " "}'.format
271
+ for line in self.lines:
272
+ yield format_line(line)
273
+
274
+ for _ in range(self.height, max_lines):
275
+ yield format_line('')
276
+
277
+ def __repr__(self) -> str:
278
+ return f'<{self.__class__.__name__}[{self.text!r}, width={self.width}, height={self.height}]>'
279
+
280
+
281
+ class Row:
282
+ __slots__ = ('cells', 'header')
283
+
284
+ def __init__(self, cells: list[Cell], header: bool = False):
285
+ self.cells = cells
286
+ self.header = header
287
+
288
+ def widths(self) -> Iterator[int]:
289
+ for cell in self.cells:
290
+ yield cell.width
291
+
292
+ def render_upper_bar(self, widths: Sequence[int]) -> str:
293
+ return '+' + ''.join(cell.render_upper_bar(w) for cell, w in zip(self.cells, widths))
294
+
295
+ def render_lower_bar(self, widths: Sequence[int]) -> str:
296
+ char = '=' if self.header else '-'
297
+ first = '+' if self.cells[0].brd_bottom else '|'
298
+ return first + ''.join(cell.render_lower_bar(w, char) for cell, w in zip(self.cells, widths))
299
+
300
+ def render_lines(self, widths: Sequence[int]) -> Iterator[str]:
301
+ max_lines = max(cell.height for cell in self.cells)
302
+ renderers = [cell.render_lines(w, max_lines) for cell, w in zip(self.cells, widths)]
303
+ for cell_strs in zip(*renderers):
304
+ yield '|' + ''.join(cell_strs)
266
305
 
267
- return any_new_line, widths
306
+ yield self.render_lower_bar(widths)
@@ -7,6 +7,8 @@ Program metadata introspection for use in usage, help text, and documentation.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import json
11
+ import platform
10
12
  from collections import defaultdict
11
13
  from functools import cached_property
12
14
  from importlib.metadata import Distribution, EntryPoint, entry_points
@@ -24,6 +26,7 @@ if TYPE_CHECKING:
24
26
 
25
27
  __all__ = ['ProgramMetadata']
26
28
 
29
+ WINDOWS = platform.system().lower() == 'windows'
27
30
  DEFAULT_FILE_NAME: str = 'UNKNOWN'
28
31
 
29
32
 
@@ -247,12 +250,14 @@ class ProgramMetadata:
247
250
 
248
251
  def format_epilog(self, extended: Bool = True, allow_sys_argv: Bool = None) -> str:
249
252
  parts = [self.epilog] if self.epilog else []
253
+ # TODO: Add support for epilog_format format string?
250
254
  if parts and not extended:
251
255
  return parts[0]
252
256
 
253
257
  if version := self.version:
254
258
  version = f' [ver. {version}]'
255
259
  if self.email:
260
+ # TODO: add support_url metadata entry and use that instead of email, if present?
256
261
  parts.append(f'Report {self.get_prog(allow_sys_argv)}{version} bugs to {self.email}')
257
262
  if url := self.docs_url or self.url:
258
263
  parts.append(f'Online documentation: {url}')
@@ -267,6 +272,7 @@ class ProgramMetadata:
267
272
  return doc_str
268
273
 
269
274
  def get_description(self, allow_inherited: Bool = True) -> OptStr:
275
+ # TODO: Description template for subcommands?
270
276
  if description := self.description:
271
277
  if not allow_inherited and (parent := self.parent) and (parent_description := parent.description): # noqa
272
278
  return description if parent_description != description else None
@@ -301,7 +307,7 @@ class ProgFinder:
301
307
  def _get_console_scripts(cls) -> tuple[EntryPoint, ...]:
302
308
  try:
303
309
  return entry_points(group='console_scripts') # noqa
304
- except TypeError: # Python 3.8 or 3.9
310
+ except TypeError: # Python 3.9
305
311
  return entry_points()['console_scripts'] # noqa
306
312
 
307
313
  def normalize(
@@ -382,10 +388,33 @@ class DistributionFinder:
382
388
  self._dist_top_levels = {}
383
389
  self._dist_urls = {}
384
390
 
391
+ @cached_property
392
+ def _all_distributions(self) -> tuple[dict[str, Distribution], dict[str, tuple[Distribution, Path]]]:
393
+ normal, editable = {}, {}
394
+ for dist in Distribution.discover():
395
+ # Note: Distribution.name was not added until 3.10, and it returns `self.metadata['Name']`
396
+ if not (name := dist.metadata.get('Name')):
397
+ continue
398
+ elif existing := normal.get(name):
399
+ if path := _get_editable_path(existing):
400
+ editable[name] = (dist, path)
401
+ elif path := _get_editable_path(dist):
402
+ editable[name] = (existing, path)
403
+ normal[name] = dist
404
+ else: # A third case is not really expected here...
405
+ normal[name] = dist
406
+ else:
407
+ normal[name] = dist
408
+
409
+ return normal, editable
410
+
385
411
  @cached_property
386
412
  def _distributions(self) -> dict[str, Distribution]:
387
- # Note: Distribution.name was not added until 3.10, and it returns `self.metadata['Name']`
388
- return {dist.metadata['Name']: dist for dist in Distribution.discover()}
413
+ return self._all_distributions[0]
414
+
415
+ @cached_property
416
+ def _editable_distributions(self) -> dict[str, tuple[Distribution, Path]]:
417
+ return self._all_distributions[1]
389
418
 
390
419
  def _get_top_levels(self, dist_name: str, dist: Distribution) -> set[str]:
391
420
  # dist_name = dist.metadata['Name'] # Distribution.name was not added until 3.10, and it returns this
@@ -419,13 +448,30 @@ class DistributionFinder:
419
448
 
420
449
  def _dist_for_obj_main(self, obj) -> Distribution | None:
421
450
  # Note: getmodule returns the module object (obj.__module__ only provides the name)
422
- if (module := getmodule(obj)) is None or not module.__package__:
451
+ if (module := getmodule(obj)) is None:
423
452
  return None
453
+ elif not module.__package__:
454
+ # This may occur for top-level scripts that are in a bin/ directory or similar, with a Command that was
455
+ # defined in that file instead of in the package that represents the library code for that project
456
+ try:
457
+ path = module.__loader__.path # noqa
458
+ except AttributeError:
459
+ return None
460
+ else:
461
+ return self._dist_for_main_loader_path(path)
424
462
 
425
463
  # The package name may have a prefix like `lib` not included in top_level when interactive
426
464
  for part in module.__package__.split('.'):
427
465
  if (dist := self.dist_for_pkg(part)) is not None:
428
466
  return dist
467
+
468
+ return None
469
+
470
+ def _dist_for_main_loader_path(self, path_str: str) -> Distribution | None:
471
+ path = Path(path_str).resolve()
472
+ for name, (dist, src_path) in self._editable_distributions.items():
473
+ if path.is_relative_to(src_path):
474
+ return dist
429
475
  return None
430
476
 
431
477
  def get_urls(self, dist: Distribution) -> dict[str, str]:
@@ -445,6 +491,25 @@ class DistributionFinder:
445
491
  return urls
446
492
 
447
493
 
494
+ def _get_editable_path(dist: Distribution) -> Path | None:
495
+ if not (direct_url := dist.read_text('direct_url.json')): # read_text suppresses errors
496
+ return None
497
+
498
+ data = json.loads(direct_url)
499
+ # direct_url content: '{"dir_info": {"editable": true}, "url": "file:///C:/Users/..."}'
500
+ if not (url := data.get('url')) or not data.get('dir_info', {}).get('editable'):
501
+ return None # This is not expected
502
+
503
+ parsed = urlparse(url)
504
+ if parsed.scheme != 'file': # This is not expected
505
+ return None
506
+
507
+ path = parsed.path
508
+ if WINDOWS and path.startswith('/') and ':/' in path[:4]: # The uri path has a leading / before the drive letter
509
+ path = path[1:]
510
+ return Path(path).resolve()
511
+
512
+
448
513
  _dist_finder = DistributionFinder()
449
514
 
450
515
 
@@ -21,6 +21,7 @@ from .actions import Concatenate
21
21
  from .base import BasePositional
22
22
 
23
23
  if TYPE_CHECKING:
24
+ from ..formatting.params import ChoiceMapHelpFormatter
24
25
  from ..metadata import ProgramMetadata
25
26
 
26
27
  __all__ = ['SubCommand', 'Action', 'Choice', 'ChoiceMap']
@@ -84,6 +85,7 @@ class ChoiceMap(BasePositional[str], Generic[T], actions=(Concatenate,)):
84
85
  choices: dict[str, Choice[T]]
85
86
  title: OptStr
86
87
  description: OptStr
88
+ formatter: ChoiceMapHelpFormatter
87
89
 
88
90
  def __init_subclass__( # pylint: disable=W0222
89
91
  cls, title: str = None, choice_validation_exc: Type[Exception] = None, **kwargs
@@ -15,6 +15,7 @@ from .base import BaseOption, BasePositional, ParamBase, _group_stack
15
15
  from .pass_thru import PassThru
16
16
 
17
17
  if TYPE_CHECKING:
18
+ from ..formatting.params import GroupHelpFormatter
18
19
  from ..typing import Bool, ParamList, ParamOrGroup
19
20
 
20
21
  __all__ = ['ParamGroup']
@@ -48,6 +49,7 @@ class ParamGroup(ParamBase):
48
49
  members: list[ParamOrGroup]
49
50
  mutually_exclusive: Bool = False
50
51
  mutually_dependent: Bool = False
52
+ formatter: GroupHelpFormatter
51
53
 
52
54
  def __init__(
53
55
  self,
@@ -202,6 +204,11 @@ class ParamGroup(ParamBase):
202
204
  if not (self.mutually_dependent or self.mutually_exclusive):
203
205
  return
204
206
 
207
+ # TODO: Use case: partially mutually dependent group - outer mutually dependent group where within the group,
208
+ # some params are required if any members are provided, but not all members (which may be groups themselves)
209
+ # are always required. If those optional members are provided, then the required members must be required,
210
+ # but not the inverse.
211
+
205
212
  # log.debug(f'{self}: Checking group conflicts in {provided=}, {missing=}')
206
213
  # log.debug(f'{self}: Checking group conflicts in provided={len(provided)}, missing={len(missing)}')
207
214
  if self.mutually_dependent and provided and missing:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli_command_parser
3
- Version: 2024.9.7
3
+ Version: 2024.11.2
4
4
  Summary: CLI Command Parser
5
5
  Home-page: https://github.com/dskrypa/cli_command_parser
6
6
  Author: Doug Skrypa
@@ -16,27 +16,25 @@ Classifier: License :: OSI Approved :: Apache Software License
16
16
  Classifier: Operating System :: OS Independent
17
17
  Classifier: Programming Language :: Python
18
18
  Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.8
20
19
  Classifier: Programming Language :: Python :: 3.9
21
20
  Classifier: Programming Language :: Python :: 3.10
22
21
  Classifier: Programming Language :: Python :: 3.11
23
22
  Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Software Development :: User Interfaces
25
25
  Classifier: Topic :: Text Processing
26
- Requires-Python: >=3.8
26
+ Requires-Python: >=3.9
27
27
  Description-Content-Type: text/x-rst
28
28
  License-File: LICENSE
29
29
  Provides-Extra: wcwidth
30
30
  Requires-Dist: wcwidth; extra == "wcwidth"
31
- Provides-Extra: conversion
32
- Requires-Dist: astunparse; python_version < "3.9" and extra == "conversion"
33
31
 
34
32
  CLI Command Parser
35
33
  ##################
36
34
 
37
35
  |downloads| |py_version| |coverage_badge| |build_status| |Ruff|
38
36
 
39
- .. |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
37
+ .. |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
40
38
  :target: https://pypi.org/project/cli-command-parser/
41
39
 
42
40
  .. |coverage_badge| image:: https://codecov.io/gh/dskrypa/cli_command_parser/branch/main/graph/badge.svg
@@ -118,18 +116,8 @@ with optional dependencies::
118
116
  Python Version Compatibility
119
117
  ============================
120
118
 
121
- Python versions 3.8 and above are currently supported. The last release of CLI Command Parser that supported 3.7 was
122
- 2023-04-30. Support for Python 3.7 `officially ended on 2023-06-27 <https://devguide.python.org/versions/>`__.
123
-
124
- When using Python 3.8, some additional packages that backport functionality that was added in later Python versions
125
- are required for compatibility.
126
-
127
- To use the argparse to cli-command-parser conversion script with Python 3.8, there is a dependency on
128
- `astunparse <https://astunparse.readthedocs.io>`__. If you are using Python 3.9 or above, then ``astunparse`` is not
129
- necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
130
- cli-command-parser with the following command to automatically handle whether that extra dependency is needed or not::
131
-
132
- $ pip install -U cli-command-parser[conversion]
119
+ Python versions 3.9 and above are currently supported. The last release of CLI Command Parser that supported 3.8 was
120
+ 2024-09-07. Support for Python 3.8 `officially ended on 2024-10-07 <https://devguide.python.org/versions/>`__.
133
121
 
134
122
 
135
123
  Links
@@ -3,7 +3,7 @@ CLI Command Parser
3
3
 
4
4
  |downloads| |py_version| |coverage_badge| |build_status| |Ruff|
5
5
 
6
- .. |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
6
+ .. |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
7
7
  :target: https://pypi.org/project/cli-command-parser/
8
8
 
9
9
  .. |coverage_badge| image:: https://codecov.io/gh/dskrypa/cli_command_parser/branch/main/graph/badge.svg
@@ -85,18 +85,8 @@ with optional dependencies::
85
85
  Python Version Compatibility
86
86
  ============================
87
87
 
88
- Python versions 3.8 and above are currently supported. The last release of CLI Command Parser that supported 3.7 was
89
- 2023-04-30. Support for Python 3.7 `officially ended on 2023-06-27 <https://devguide.python.org/versions/>`__.
90
-
91
- When using Python 3.8, some additional packages that backport functionality that was added in later Python versions
92
- are required for compatibility.
93
-
94
- To use the argparse to cli-command-parser conversion script with Python 3.8, there is a dependency on
95
- `astunparse <https://astunparse.readthedocs.io>`__. If you are using Python 3.9 or above, then ``astunparse`` is not
96
- necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
97
- cli-command-parser with the following command to automatically handle whether that extra dependency is needed or not::
98
-
99
- $ pip install -U cli-command-parser[conversion]
88
+ Python versions 3.9 and above are currently supported. The last release of CLI Command Parser that supported 3.8 was
89
+ 2024-09-07. Support for Python 3.8 `officially ended on 2024-10-07 <https://devguide.python.org/versions/>`__.
100
90
 
101
91
 
102
92
  Links
@@ -1,4 +1,4 @@
1
- -e .[wcwidth,conversion]
1
+ -e .[wcwidth]
2
2
  -r docs/requirements.txt
3
3
  pre-commit
4
4
  ruff
@@ -21,11 +21,11 @@ classifiers =
21
21
  Operating System :: OS Independent
22
22
  Programming Language :: Python
23
23
  Programming Language :: Python :: 3
24
- Programming Language :: Python :: 3.8
25
24
  Programming Language :: Python :: 3.9
26
25
  Programming Language :: Python :: 3.10
27
26
  Programming Language :: Python :: 3.11
28
27
  Programming Language :: Python :: 3.12
28
+ Programming Language :: Python :: 3.13
29
29
  Topic :: Software Development :: User Interfaces
30
30
  Topic :: Text Processing
31
31
 
@@ -34,7 +34,7 @@ include_package_data = True
34
34
  entry_points = file: entry_points.txt
35
35
  packages = find:
36
36
  package_dir = = lib
37
- python_requires = >=3.8
37
+ python_requires = >=3.9
38
38
  tests_require = testtools; coverage
39
39
 
40
40
  [options.packages.find]
@@ -43,8 +43,6 @@ where = lib
43
43
  [options.extras_require]
44
44
  wcwidth =
45
45
  wcwidth
46
- conversion =
47
- astunparse; python_version<"3.9"
48
46
 
49
47
  [egg_info]
50
48
  tag_build =
@@ -1,8 +0,0 @@
1
-
2
- [conversion]
3
-
4
- [conversion:python_version < "3.9"]
5
- astunparse
6
-
7
- [wcwidth]
8
- wcwidth