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