cli2 2.6.2__tar.gz → 3.0.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 (35) hide show
  1. {cli2-2.6.2/cli2.egg-info → cli2-3.0.2}/PKG-INFO +8 -1
  2. {cli2-2.6.2 → cli2-3.0.2}/cli2/__init__.py +1 -0
  3. {cli2-2.6.2 → cli2-3.0.2}/cli2/argument.py +22 -2
  4. {cli2-2.6.2 → cli2-3.0.2}/cli2/colors.py +16 -0
  5. cli2-3.0.2/cli2/command.py +417 -0
  6. cli2-3.0.2/cli2/display.py +92 -0
  7. {cli2-2.6.2 → cli2-3.0.2}/cli2/entry_point.py +4 -9
  8. {cli2-2.6.2 → cli2-3.0.2}/cli2/group.py +9 -2
  9. {cli2-2.6.2 → cli2-3.0.2}/cli2/test.py +35 -3
  10. {cli2-2.6.2 → cli2-3.0.2}/cli2/test_command.py +207 -30
  11. {cli2-2.6.2 → cli2-3.0.2}/cli2/test_decorators.py +2 -2
  12. cli2-3.0.2/cli2/test_display.py +50 -0
  13. {cli2-2.6.2 → cli2-3.0.2}/cli2/test_group.py +34 -1
  14. {cli2-2.6.2 → cli2-3.0.2/cli2.egg-info}/PKG-INFO +8 -1
  15. {cli2-2.6.2 → cli2-3.0.2}/cli2.egg-info/SOURCES.txt +2 -1
  16. cli2-3.0.2/cli2.egg-info/requires.txt +9 -0
  17. {cli2-2.6.2 → cli2-3.0.2}/setup.py +7 -2
  18. cli2-2.6.2/cli2/command.py +0 -184
  19. cli2-2.6.2/cli2/test_entrypoint.py +0 -17
  20. cli2-2.6.2/cli2.egg-info/requires.txt +0 -6
  21. {cli2-2.6.2 → cli2-3.0.2}/MANIFEST.in +0 -0
  22. {cli2-2.6.2 → cli2-3.0.2}/README.rst +0 -0
  23. {cli2-2.6.2 → cli2-3.0.2}/classifiers.txt +0 -0
  24. {cli2-2.6.2 → cli2-3.0.2}/cli2/cli.py +0 -0
  25. {cli2-2.6.2 → cli2-3.0.2}/cli2/decorators.py +0 -0
  26. {cli2-2.6.2 → cli2-3.0.2}/cli2/node.py +0 -0
  27. {cli2-2.6.2 → cli2-3.0.2}/cli2/table.py +0 -0
  28. {cli2-2.6.2 → cli2-3.0.2}/cli2/test_cli.py +0 -0
  29. {cli2-2.6.2 → cli2-3.0.2}/cli2/test_inject.py +0 -0
  30. {cli2-2.6.2 → cli2-3.0.2}/cli2/test_node.py +0 -0
  31. {cli2-2.6.2 → cli2-3.0.2}/cli2/test_table.py +0 -0
  32. {cli2-2.6.2 → cli2-3.0.2}/cli2.egg-info/dependency_links.txt +0 -0
  33. {cli2-2.6.2 → cli2-3.0.2}/cli2.egg-info/entry_points.txt +0 -0
  34. {cli2-2.6.2 → cli2-3.0.2}/cli2.egg-info/top_level.txt +0 -0
  35. {cli2-2.6.2 → cli2-3.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli2
3
- Version: 2.6.2
3
+ Version: 3.0.2
4
4
  Summary: image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
5
5
  Home-page: https://yourlabs.io/oss/cli2
6
6
  Author: James Pic
@@ -9,7 +9,14 @@ License: MIT
9
9
  Keywords: cli
10
10
  Requires-Python: >=3.6
11
11
  Description-Content-Type: text/x-rst
12
+ Requires-Dist: docstring_parser
13
+ Requires-Dist: pyyaml
14
+ Requires-Dist: rich
12
15
  Provides-Extra: test
16
+ Requires-Dist: freezegun; extra == "test"
17
+ Requires-Dist: pytest; extra == "test"
18
+ Requires-Dist: pytest-cov; extra == "test"
19
+ Requires-Dist: pytest-mock; extra == "test"
13
20
 
14
21
  .. image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
15
22
  :target: https://yourlabs.io/oss/cli2/pipelines
@@ -3,6 +3,7 @@ from .argument import Argument
3
3
  from .colors import colors as c
4
4
  from .command import Command
5
5
  from .decorators import arg, cmd
6
+ from .display import console, diff, print
6
7
  from .group import Group
7
8
  from .node import Node
8
9
  from .table import Table
@@ -1,3 +1,4 @@
1
+ import inspect
1
2
  import re
2
3
  import json
3
4
 
@@ -10,12 +11,14 @@ class Argument:
10
11
  """
11
12
  # TODO: why not split this into a bunch of simpler sub-classes now that
12
13
  # it's pretty featureful ?
13
- def __init__(self, cmd, param, doc=None, color=None, **kwargs):
14
+ def __init__(self, cmd, param, doc=None, color=None, factory=None,
15
+ **kwargs):
14
16
  self.cmd = cmd
15
17
  self.param = param
16
18
  self.color = color
17
19
  # Let default be set to None :)
18
20
  self.default = kwargs.pop('default', param.default)
21
+ self.factory = factory
19
22
 
20
23
  self.doc = doc or ''
21
24
  if not doc:
@@ -285,7 +288,7 @@ class Argument:
285
288
 
286
289
  def aliasmatch(self, arg):
287
290
  """Return True if the CLI arg matches an alias of this argument."""
288
- if arg == self.negate:
291
+ if arg in self.negates:
289
292
  return True
290
293
  if self.iskw and self.param.annotation == bool and arg in self.alias:
291
294
  return True
@@ -362,3 +365,20 @@ class Argument:
362
365
  if value is not None:
363
366
  self.value = self.cast(value)
364
367
  return True
368
+
369
+ def factory_value(self):
370
+ """
371
+ Run the factory function and return the value.
372
+
373
+ If the factory function takes a `cmd` argument, it will pass the
374
+ command object.
375
+
376
+ If the factory function takes an `arg` argument, it will pass self.
377
+ """
378
+ kwargs = dict()
379
+ sig = inspect.signature(self.factory)
380
+ if 'cmd' in sig.parameters:
381
+ kwargs['cmd'] = self.cmd
382
+ if 'arg' in sig.parameters:
383
+ kwargs['arg'] = self
384
+ return self.factory(**kwargs)
@@ -1,3 +1,19 @@
1
+ """
2
+ Define a bunch of arbitrary color ANSI color codes.
3
+
4
+ This module is available in the `cli2.c` namespace.
5
+
6
+ Example:
7
+
8
+ .. code-block:: python
9
+
10
+ import cli2
11
+ print(f'{cli2.c.green2bold}OK{cli2.c.reset}')
12
+
13
+ See the following for details.
14
+ """
15
+
16
+
1
17
  colors = dict(
2
18
  cyan='\u001b[38;5;51m',
3
19
  cyan1='\u001b[38;5;87m',
@@ -0,0 +1,417 @@
1
+ import asyncio
2
+ import cli2
3
+ import inspect
4
+ import sys
5
+
6
+ from docstring_parser import parse
7
+ from rich.console import Console
8
+ from rich.traceback import install
9
+
10
+ from . import display
11
+ from .argument import Argument
12
+ from .colors import colors
13
+ from .entry_point import EntryPoint
14
+
15
+
16
+ console = Console()
17
+ install(show_locals=True, suppress=[cli2])
18
+
19
+
20
+ class Command(EntryPoint, dict):
21
+ """Represents a command bound to a target callable."""
22
+
23
+ def __new__(cls, target, *args, **kwargs):
24
+ overrides = getattr(target, 'cli2', {})
25
+ cls = overrides.get('cls', cls)
26
+ return super().__new__(cls, *args, **kwargs)
27
+
28
+ def __init__(self, target, name=None, color=None, doc=None, posix=False,
29
+ help_hack=True, outfile=None, log=True):
30
+ self.target = target
31
+ self.posix = posix
32
+ self.parent = None
33
+ self.help_hack = help_hack
34
+
35
+ overrides = getattr(target, 'cli2', {})
36
+ for key, value in overrides.items():
37
+ setattr(self, key, value)
38
+
39
+ if name:
40
+ self.name = name
41
+ elif 'name' not in overrides:
42
+ self.name = getattr(target, '__name__', type(target).__name__)
43
+
44
+ self.parsed = parse(inspect.getdoc(target))
45
+ if doc:
46
+ self.doc = doc
47
+ elif 'doc' not in overrides:
48
+ self.doc = ''
49
+ if self.parsed.short_description:
50
+ self.doc += self.parsed.short_description.replace('\n', ' ')
51
+ if self.parsed.long_description:
52
+ if self.doc:
53
+ self.doc += '\n'
54
+ self.doc += self.parsed.long_description
55
+
56
+ if color:
57
+ self.color = color
58
+ elif 'color' not in overrides:
59
+ self.color = 'orange'
60
+
61
+ self.positions = dict()
62
+ self.sig = inspect.signature(target)
63
+ self.setargs()
64
+ EntryPoint.__init__(self, outfile=outfile, log=log)
65
+
66
+ def setargs(self):
67
+ """Reset arguments."""
68
+ for name, param in self.sig.parameters.items():
69
+ overrides = getattr(self.target, 'cli2_' + name, {})
70
+ cls = overrides.get('cls', Argument)
71
+ self[name] = cls(self, param)
72
+ for key, value in overrides.items():
73
+ setattr(self[name], key, value)
74
+
75
+ @classmethod
76
+ def cmd(cls, *args, **kwargs):
77
+ def override(target):
78
+ overrides = getattr(target, 'cli2', {})
79
+ overrides.update(kwargs)
80
+ overrides['cls'] = cls
81
+ target.cli2 = overrides
82
+
83
+ if len(args) == 1 and not kwargs:
84
+ # simple @YourCommand.cmd syntax
85
+ target = args[0]
86
+ override(target)
87
+ return target
88
+ elif not args:
89
+ def wrap(cb):
90
+ override(cb)
91
+ return cb
92
+ return wrap
93
+ else:
94
+ raise Exception('Only kwargs are supported by Group.cmd')
95
+
96
+ def help(self, error=None, short=False, missing=None):
97
+ """Show help for a command."""
98
+ if short:
99
+ if self.doc:
100
+ return self.doc.replace('\n', ' ').split('.')[0]
101
+ return ''
102
+
103
+ if missing:
104
+ error = (
105
+ f'missing {len(missing)} required argument'
106
+ f'{"s" if len(missing) > 1 else ""}'
107
+ f': {", ".join(missing)}'
108
+ )
109
+
110
+ if error:
111
+ self.print('RED', 'ERROR: ' + colors.reset + error, end='\n\n')
112
+
113
+ self.print('ORANGE', 'SYNOPSYS')
114
+ chain = []
115
+ current = self
116
+ while current is not None:
117
+ chain.insert(0, current.name)
118
+ current = current.parent
119
+ for arg in self.values():
120
+ chain.append(str(arg))
121
+ self.print(' '.join(map(str, chain)), end='\n\n')
122
+
123
+ self.print('ORANGE', 'DESCRIPTION')
124
+ self.print(self.doc)
125
+
126
+ shown_posargs = False
127
+ shown_kwargs = False
128
+ for arg in self.values():
129
+ self.print()
130
+
131
+ if not arg.iskw and not shown_posargs:
132
+ self.print('ORANGE', 'POSITIONAL ARGUMENTS')
133
+ shown_posargs = True
134
+
135
+ varkw = arg.param.kind == arg.param.VAR_KEYWORD
136
+ if (arg.iskw or varkw) and not shown_kwargs:
137
+ self.print('ORANGE', 'NAMED ARGUMENTS')
138
+ shown_kwargs = True
139
+ arg.help()
140
+
141
+ def parse(self, *argv):
142
+ """Parse arguments into BoundArguments."""
143
+ self.setargs()
144
+ self.bound = self.sig.bind_partial()
145
+ extra = []
146
+ for current in argv:
147
+ taken = False
148
+ for arg in self.values():
149
+ taken = arg.take(current)
150
+ if taken:
151
+ break
152
+
153
+ if not taken:
154
+ extra.append(current)
155
+
156
+ if extra:
157
+ return 'No parameters for these arguments: ' + ', '.join(extra)
158
+
159
+ for name, arg in self.items(factories=None):
160
+ if arg.factory:
161
+ if self.async_function(arg.factory):
162
+ arg.value = 'to_be_computed'
163
+ else:
164
+ arg.value = arg.factory_value()
165
+ continue
166
+ if not arg.default:
167
+ continue
168
+ if name in self.bound.arguments:
169
+ continue
170
+ arg.value = arg.default
171
+
172
+ def async_function(self, function):
173
+ """ Return True if function is async """
174
+ return (
175
+ inspect.iscoroutinefunction(function)
176
+ or inspect.isasyncgenfunction(function)
177
+ )
178
+
179
+ def async_mode(self):
180
+ """ Return True if any callable we'll deal with is async """
181
+ for arg in self.values():
182
+ if self.async_function(arg.factory):
183
+ return True
184
+ if (
185
+ self.async_function(self.target)
186
+ or self.async_function(self.post_call)
187
+ ):
188
+ return True
189
+
190
+ for name, arg in self.items(factories=True):
191
+ if arg.factory and self.async_function(arg.factory):
192
+ return True
193
+ return False
194
+
195
+ def async_iter(self, obj):
196
+ return inspect.isasyncgen(obj) or hasattr(obj, '__aiter__')
197
+
198
+ async def async_resolve(self, result, output=False):
199
+ """ Recursively resolve awaitables. """
200
+ while inspect.iscoroutine(result):
201
+ result = await result
202
+ if self.async_iter(result):
203
+ results = []
204
+ async for _ in result:
205
+ if output:
206
+ if (
207
+ not inspect.iscoroutine(_)
208
+ and not inspect.isasyncgen(_)
209
+ ):
210
+ display.print(_)
211
+ else:
212
+ await self.async_resolve(_, output=output)
213
+ else:
214
+ results.append(await self.async_resolve(_))
215
+ return None if output else results
216
+ return result
217
+
218
+ def call(self, *args, **kwargs):
219
+ """Execute command target with bound arguments."""
220
+ return self.target(*args, **kwargs)
221
+
222
+ def missing(self):
223
+ return [
224
+ name
225
+ for name, arg in self.items()
226
+ if name not in self.bound.arguments
227
+ and name not in self.bound.kwargs
228
+ and arg.param.default == arg.param.empty
229
+ and arg.param.kind in (
230
+ arg.param.POSITIONAL_ONLY,
231
+ arg.param.POSITIONAL_OR_KEYWORD,
232
+ )
233
+ ]
234
+
235
+ def __call__(self, *argv):
236
+ """Execute command with args from sysargs."""
237
+ self.exit_code = 0
238
+
239
+ if self.help_hack and '--help' in argv:
240
+ self.exit_code = 1
241
+ return self.help()
242
+
243
+ if self.async_mode():
244
+ return asyncio.run(self.async_call(*argv))
245
+
246
+ error = self.parse(*argv)
247
+ if error:
248
+ self.exit_code = 1
249
+ return self.help(error=error)
250
+
251
+ missing = self.missing()
252
+ if missing:
253
+ self.exit_code = 1
254
+ return self.help(missing=missing)
255
+
256
+ try:
257
+ result = self.call(*self.bound.args, **self.bound.kwargs)
258
+ if (
259
+ inspect.isgenerator(result)
260
+ or isinstance(result, (list, tuple))
261
+ ):
262
+ for _ in result:
263
+ display.print(_)
264
+ result = None
265
+ except KeyboardInterrupt:
266
+ print('exiting')
267
+ sys.exit(1)
268
+ finally:
269
+ self.post_result = self.post_call()
270
+ return result
271
+
272
+ async def async_call(self, *argv):
273
+ """ Call with async stuff in single event loop """
274
+ error = self.parse(*argv)
275
+ if error:
276
+ self.exit_code = 1
277
+ return self.help(error=error)
278
+
279
+ missing = self.missing()
280
+ if missing:
281
+ self.exit_code = 1
282
+ return self.help(missing=missing)
283
+
284
+ factories = self.values(factories=True)
285
+ if factories:
286
+ results = await asyncio.gather(*[
287
+ self.async_resolve(arg.factory_value())
288
+ for arg in factories
289
+ ])
290
+ for _, arg in enumerate(factories):
291
+ arg.value = results[_]
292
+
293
+ try:
294
+ result = self.call(*self.bound.args, **self.bound.kwargs)
295
+ result = await self.async_resolve(result, output=True)
296
+ except KeyboardInterrupt:
297
+ print('exiting')
298
+ sys.exit(1)
299
+ finally:
300
+ self.post_result = await self.async_resolve(self.post_call())
301
+ return result
302
+
303
+ def ordered(self, factories=False):
304
+ """
305
+ Order the parameters by priority.
306
+
307
+ :param factories: Show only arguments with factory.
308
+ """
309
+ return {key: self[key] for key in self.keys(factories=factories)}
310
+
311
+ def values(self, factories=False):
312
+ """
313
+ Return ordered values.
314
+
315
+ :param factories: Show only arguments with factory.
316
+ """
317
+ return self.ordered(factories=factories).values()
318
+
319
+ def items(self, factories=False):
320
+ """
321
+ Return ordered items.
322
+
323
+ :param factories: Show only arguments with factory.
324
+ """
325
+ return self.ordered(factories=factories).items()
326
+
327
+ def keys(self, factories=False):
328
+ """
329
+ Return ordered keys.
330
+
331
+ :param factories: Show only arguments with factory.
332
+ """
333
+ order = (
334
+ inspect.Parameter.POSITIONAL_ONLY,
335
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
336
+ inspect.Parameter.VAR_POSITIONAL,
337
+ inspect.Parameter.KEYWORD_ONLY,
338
+ inspect.Parameter.VAR_KEYWORD,
339
+ )
340
+ keys = []
341
+ for kind in order:
342
+ for name, arg in super().items():
343
+ if factories is False and arg.factory:
344
+ continue
345
+ if factories is True and not arg.factory:
346
+ continue
347
+ if name in self.positions:
348
+ continue
349
+ if arg.param.kind == kind:
350
+ keys.append(name)
351
+ for key, position in self.positions.items():
352
+ if factories and not self[key].factory:
353
+ continue
354
+ keys.insert(position, key)
355
+ return keys
356
+
357
+ def __iter__(self):
358
+ return self.ordered().__iter__()
359
+
360
+ def arg(
361
+ self,
362
+ name,
363
+ *,
364
+ kind: str = None,
365
+ position: int = None,
366
+ doc=None,
367
+ color=None,
368
+ default=inspect.Parameter.empty,
369
+ annotation=inspect.Parameter.empty,
370
+ ):
371
+ """
372
+ Inject new :py:class:`~cli2.argument.Argument` into this command.
373
+
374
+ The new argument will appear in documentation, but won't be bound to
375
+ the callable: it will only be avalaible in `self`.
376
+
377
+ For example, you are deleting an "http_client" argument in
378
+ :py:meth:`setargs()` so that it doesn't appear to the CLI user, to whom
379
+ you want to expose a couple of arguments such as "base_url" and
380
+ "ssl_verify" that you are adding programatically with this method, so
381
+ that you can use `self['base_url'].value` and
382
+ `self['ssl_verify'].value` in to generate a "http_client" argument in
383
+ :py:meth:`call()`.
384
+
385
+ The tutorial has a more comprehensive example in the "CLI only
386
+ arguments" section.
387
+
388
+ :param name: Name of the argument to add
389
+ :param kind: Name of the inspect parameter kind
390
+ :param position: Position of the argument in the CLI
391
+ :param doc: Documentation for the argument
392
+ :param color: Color of the argument
393
+ :param default: Default value for the argument
394
+ :param annotation: Type of argument
395
+ """
396
+ self[name] = Argument(
397
+ self,
398
+ inspect.Parameter(
399
+ name,
400
+ kind=getattr(
401
+ inspect.Parameter,
402
+ kind or "POSITIONAL_OR_KEYWORD",
403
+ ),
404
+ default=default,
405
+ annotation=annotation,
406
+ ),
407
+ doc=doc,
408
+ color=color,
409
+ )
410
+ if position is not None:
411
+ self.positions[name] = position
412
+
413
+ def post_call(self):
414
+ """
415
+ Implement your cleaner here
416
+ """
417
+ pass
@@ -0,0 +1,92 @@
1
+ """
2
+ Generic pretty display utils.
3
+
4
+ This module defines a print function that's supposed to be able to pretty-print
5
+ anything, as well as a pretty diff printer.
6
+ """
7
+ import os
8
+
9
+ from rich.console import Console
10
+ from rich.syntax import Syntax
11
+
12
+
13
+ console_kwargs = dict()
14
+ if os.getenv('CI'):
15
+ console_kwargs['force_terminal'] = True
16
+ console = Console(**console_kwargs)
17
+
18
+
19
+ NO_COLOR = bool(os.getenv('NO_COLOR', ''))
20
+ _print = print
21
+
22
+
23
+ def highlight(string, lexer):
24
+ if NO_COLOR:
25
+ return string
26
+
27
+ return Syntax(string, lexer)
28
+
29
+
30
+ def yaml_dump(data):
31
+ import yaml
32
+ if isinstance(data, dict):
33
+ # ensure that objects inheriting from dict render nicely
34
+ data = dict(data)
35
+ return yaml.dump(data, indent=4, width=float('inf'))
36
+
37
+
38
+ def print(*args, **kwargs):
39
+ """
40
+ Try to print the args, pass the kwargs to actual print method.
41
+
42
+ If any arg is parseable as JSON then it'l be parsed.
43
+
44
+ Then, it'll be dumped as colored YAML.
45
+
46
+ Set the env var `NO_COLORS` to anything to
47
+ prevent `cli2.print` from printing colors.
48
+
49
+ .. code-block:: python
50
+
51
+ import cli2
52
+
53
+ # pretty print some_object
54
+ cli2.print(some_object)
55
+
56
+ This outputs colors by default, set the env var `NO_COLORS` to anything to
57
+ prevent printing colors.
58
+ """
59
+ try:
60
+ import jsonlight as json
61
+ except ImportError:
62
+ import json
63
+
64
+ for arg in args:
65
+ try: # deal with response objects
66
+ arg = arg.json()
67
+ except (TypeError, AttributeError):
68
+ pass
69
+
70
+ try: # is this json?
71
+ arg = json.loads(arg)
72
+ except: # noqa
73
+ pass
74
+
75
+ string = arg if isinstance(arg, str) else yaml_dump(arg)
76
+ console.print(highlight(string.strip(), 'yaml'), **kwargs)
77
+
78
+
79
+ def diff(diff, **kwargs):
80
+ """
81
+ Pretty-print a diff generated by Python's standard difflib.unified_diff
82
+ method.
83
+
84
+ .. code-block:: python
85
+
86
+ # pretty print a diff
87
+ cli2.diff(difflib.unified_diff(old, new))
88
+ """
89
+ string = "\n".join([
90
+ line.strip() for line in diff if line.strip()
91
+ ])
92
+ console.print(highlight(string, 'diff'), **kwargs)
@@ -3,7 +3,7 @@ import logging
3
3
  import sys
4
4
 
5
5
  from .colors import colors
6
- from .table import Table
6
+ from . import display
7
7
 
8
8
 
9
9
  class EntryPoint:
@@ -28,14 +28,9 @@ class EntryPoint:
28
28
 
29
29
  result = self(*args[1:])
30
30
  if result is not None:
31
- if isinstance(result, (list, tuple)):
32
- try:
33
- table = Table.factory(*result)
34
- except: # noqa
35
- print(result)
36
- else:
37
- table.print()
38
- else:
31
+ try:
32
+ display.print(result)
33
+ except: # noqa
39
34
  print(result)
40
35
  sys.exit(self.exit_code)
41
36
 
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import textwrap
2
3
 
3
4
  from .colors import colors
4
5
  from .command import Command
@@ -13,7 +14,10 @@ class Group(EntryPoint, dict):
13
14
  def __init__(self, name=None, doc=None, color=None, posix=False,
14
15
  outfile=None, cmdclass=None, log=True):
15
16
  self.name = name
16
- self.doc = doc or inspect.getdoc(self)
17
+ if doc:
18
+ self.doc = textwrap.dedent(doc).strip()
19
+ else:
20
+ self.doc = inspect.getdoc(self)
17
21
  self.color = color or colors.green
18
22
  self.posix = posix
19
23
  self.parent = None
@@ -31,6 +35,8 @@ class Group(EntryPoint, dict):
31
35
  return self
32
36
 
33
37
  def __setitem__(self, key, value):
38
+ if isinstance(value, Group):
39
+ value.name = key
34
40
  value.posix = self.posix
35
41
  value.parent = self
36
42
  value.outfile = self.outfile
@@ -54,7 +60,8 @@ class Group(EntryPoint, dict):
54
60
 
55
61
  def group(self, name, **kwargs):
56
62
  """Return a new sub-group."""
57
- self[name] = Group(name, cmdclass=self.cmdclass, **kwargs)
63
+ kwargs.setdefault('cmdclass', self.cmdclass)
64
+ self[name] = Group(name, **kwargs)
58
65
  return self[name]
59
66
 
60
67
  def help(self, *args, error=None, short=False):