cli2 5.1.6__tar.gz → 5.1.8__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 (59) hide show
  1. {cli2-5.1.6/cli2.egg-info → cli2-5.1.8}/PKG-INFO +1 -1
  2. {cli2-5.1.6 → cli2-5.1.8}/cli2/__init__.py +4 -0
  3. {cli2-5.1.6 → cli2-5.1.8}/cli2/cli.py +17 -5
  4. {cli2-5.1.6 → cli2-5.1.8}/cli2/configuration.py +17 -1
  5. cli2-5.1.8/cli2/interactive.py +80 -0
  6. {cli2-5.1.6 → cli2-5.1.8}/cli2/log.py +17 -10
  7. cli2-5.1.8/cli2/notlevenshtein.py +74 -0
  8. {cli2-5.1.6 → cli2-5.1.8}/cli2/table.py +1 -1
  9. {cli2-5.1.6 → cli2-5.1.8}/cli2/test.py +3 -3
  10. cli2-5.1.8/cli2/theme.py +223 -0
  11. {cli2-5.1.6 → cli2-5.1.8/cli2.egg-info}/PKG-INFO +1 -1
  12. {cli2-5.1.6 → cli2-5.1.8}/cli2.egg-info/SOURCES.txt +5 -0
  13. {cli2-5.1.6 → cli2-5.1.8}/cli2.egg-info/entry_points.txt +2 -0
  14. {cli2-5.1.6 → cli2-5.1.8}/setup.py +3 -1
  15. {cli2-5.1.6 → cli2-5.1.8}/tests/test_ansible.py +1 -1
  16. {cli2-5.1.6 → cli2-5.1.8}/tests/test_cli.py +3 -3
  17. {cli2-5.1.6 → cli2-5.1.8}/tests/test_client.py +6 -5
  18. {cli2-5.1.6 → cli2-5.1.8}/tests/test_command.py +5 -4
  19. {cli2-5.1.6 → cli2-5.1.8}/tests/test_configuration.py +3 -0
  20. {cli2-5.1.6 → cli2-5.1.8}/tests/test_group.py +14 -6
  21. cli2-5.1.8/tests/test_interactive.py +50 -0
  22. {cli2-5.1.6 → cli2-5.1.8}/tests/test_lock.py +1 -1
  23. cli2-5.1.8/tests/test_notlevenshtein.py +46 -0
  24. {cli2-5.1.6 → cli2-5.1.8}/MANIFEST.in +0 -0
  25. {cli2-5.1.6 → cli2-5.1.8}/README.rst +0 -0
  26. {cli2-5.1.6 → cli2-5.1.8}/classifiers.txt +0 -0
  27. {cli2-5.1.6 → cli2-5.1.8}/cli2/asyncio.py +0 -0
  28. {cli2-5.1.6 → cli2-5.1.8}/cli2/cli2.py +0 -0
  29. {cli2-5.1.6 → cli2-5.1.8}/cli2/colors.py +0 -0
  30. {cli2-5.1.6 → cli2-5.1.8}/cli2/decorators.py +0 -0
  31. {cli2-5.1.6 → cli2-5.1.8}/cli2/display.py +0 -0
  32. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/__init__.py +0 -0
  33. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/conf.py +0 -0
  34. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/example.py +0 -0
  35. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/example_obj.py +0 -0
  36. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/nesting.py +0 -0
  37. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/obj.py +0 -0
  38. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/obj2.py +0 -0
  39. {cli2-5.1.6 → cli2-5.1.8}/cli2/examples/test.py +0 -0
  40. {cli2-5.1.6 → cli2-5.1.8}/cli2/lock.py +0 -0
  41. {cli2-5.1.6 → cli2-5.1.8}/cli2/mask.py +0 -0
  42. {cli2-5.1.6 → cli2-5.1.8}/cli2/node.py +0 -0
  43. {cli2-5.1.6 → cli2-5.1.8}/cli2/sphinx.py +0 -0
  44. {cli2-5.1.6 → cli2-5.1.8}/cli2.egg-info/dependency_links.txt +0 -0
  45. {cli2-5.1.6 → cli2-5.1.8}/cli2.egg-info/requires.txt +0 -0
  46. {cli2-5.1.6 → cli2-5.1.8}/cli2.egg-info/top_level.txt +0 -0
  47. {cli2-5.1.6 → cli2-5.1.8}/setup.cfg +0 -0
  48. {cli2-5.1.6 → cli2-5.1.8}/tests/test_ansible_variables.py +0 -0
  49. {cli2-5.1.6 → cli2-5.1.8}/tests/test_asyncio.py +0 -0
  50. {cli2-5.1.6 → cli2-5.1.8}/tests/test_client_test.py +0 -0
  51. {cli2-5.1.6 → cli2-5.1.8}/tests/test_decorators.py +0 -0
  52. {cli2-5.1.6 → cli2-5.1.8}/tests/test_display.py +0 -0
  53. {cli2-5.1.6 → cli2-5.1.8}/tests/test_entry_point.py +0 -0
  54. {cli2-5.1.6 → cli2-5.1.8}/tests/test_inject.py +0 -0
  55. {cli2-5.1.6 → cli2-5.1.8}/tests/test_log.py +0 -0
  56. {cli2-5.1.6 → cli2-5.1.8}/tests/test_mask.py +0 -0
  57. {cli2-5.1.6 → cli2-5.1.8}/tests/test_node.py +0 -0
  58. {cli2-5.1.6 → cli2-5.1.8}/tests/test_restful.py +0 -0
  59. {cli2-5.1.6 → cli2-5.1.8}/tests/test_table.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cli2
3
- Version: 5.1.6
3
+ Version: 5.1.8
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
@@ -11,17 +11,21 @@ from .cli import (
11
11
  )
12
12
  from .asyncio import async_resolve, async_run, Queue
13
13
  from .colors import colors as c
14
+ from .theme import theme, t
14
15
 
15
16
  from .configuration import Configuration, cfg
16
17
  from .display import diff, diff_data, render, print, highlight, yaml_highlight
18
+ from .interactive import choice, editor
17
19
  try:
18
20
  import fcntl
19
21
  except ImportError:
20
22
  """ windows """
21
23
  else:
22
24
  from .lock import Lock
25
+
23
26
  from .log import configure, log, parse
24
27
  from .mask import Mask
28
+ from .notlevenshtein import closest, closest_path
25
29
  from .table import Table
26
30
 
27
31
 
@@ -299,6 +299,9 @@ class Group(EntryPoint, dict):
299
299
  continue
300
300
  if leaf and getattr(final, name, '_') is None:
301
301
  continue
302
+ if isinstance(method, classmethod):
303
+ # get the bound method
304
+ method = getattr(cls, name)
302
305
  self.load_method(final, method)
303
306
 
304
307
  def load_obj(self, obj):
@@ -308,9 +311,10 @@ class Group(EntryPoint, dict):
308
311
  for name in dir(obj):
309
312
  if name.startswith('_'):
310
313
  continue
311
- if not callable(getattr(type(obj), name)):
314
+ if not callable(getattr(type(obj), name, None)):
312
315
  continue
313
- self.load_method(obj, getattr(obj, name))
316
+ method = getattr(obj, name)
317
+ self.load_method(obj, method)
314
318
 
315
319
  def load_method(self, obj, method):
316
320
  wrapped_method = getattr(method, '__func__', None)
@@ -325,7 +329,7 @@ class Group(EntryPoint, dict):
325
329
  if condition:
326
330
  if not condition(obj):
327
331
  return
328
- self.cmd(wrapped_method or method)
332
+ self.cmd(method)
329
333
 
330
334
  def __call__(self, *argv):
331
335
  self.exit_code = 0
@@ -825,7 +829,11 @@ class Argument:
825
829
  self.type = param.annotation
826
830
 
827
831
  self.negate = None
828
- if self.iskw and self.param.annotation == bool:
832
+ if (
833
+ self.iskw
834
+ and self.param.annotation == bool
835
+ and self.param.default is not False
836
+ ):
829
837
  self.negate = 'no-' + param.name
830
838
  if cmd.posix:
831
839
  self.negate = self.negate.replace('_', '-')
@@ -978,7 +986,11 @@ class Argument:
978
986
  + colors.reset
979
987
  )
980
988
 
981
- if self.type == bool and not self.negates:
989
+ if (
990
+ self.type == bool
991
+ and not self.negates
992
+ and self.param.default is not False
993
+ ):
982
994
  self.cmd.print(
983
995
  'Accepted: '
984
996
  + colors.blue3
@@ -8,6 +8,8 @@ The developer story we are after:
8
8
  - when there's a minute to be nice to the user, add some help that will be
9
9
  displayed to them: ``cli2.cfg.questions['YOUR_ENV_VAR'] = 'this is a help
10
10
  text that will be display to the user when we prompt them for YOUR_ENV_VAR'``
11
+ - you can also set ``cli2.cfg.defaults['YOUR_ENV_VAR']`` if you prefer a
12
+ default value to an interactive prompt
11
13
 
12
14
  This is the user story we are after:
13
15
 
@@ -29,6 +31,8 @@ import shlex
29
31
  import textwrap
30
32
  from pathlib import Path
31
33
 
34
+ from .log import log
35
+
32
36
 
33
37
  class Configuration(dict):
34
38
  """
@@ -43,6 +47,12 @@ class Configuration(dict):
43
47
  configuration then question_string will be used as text to prompt the
44
48
  user.
45
49
 
50
+ .. py:attribute:: defaults
51
+
52
+ A dict of ``ENV_VAR='default_value'``, if an env var is missing from
53
+ configuration then the default value will be returned instead of
54
+ displaying an interactive prompt.
55
+
46
56
  .. py:attribute:: profile_path
47
57
 
48
58
  Path to the shell profile to save/read variables, defaults to
@@ -67,8 +77,9 @@ class Configuration(dict):
67
77
  api_url = cli2.cfg['USERNAME']
68
78
 
69
79
  """
70
- def __init__(self, profile_path=None, **questions):
80
+ def __init__(self, profile_path=None, defaults=None, **questions):
71
81
  self.questions = questions
82
+ self.defaults = defaults or dict()
72
83
  self.profile_path = Path(
73
84
  profile_path or os.getenv('HOME') + '/.profile'
74
85
  )
@@ -155,6 +166,11 @@ class Configuration(dict):
155
166
  if key in self.profile_variables:
156
167
  return self.profile_variables[key]
157
168
 
169
+ if key in self.defaults:
170
+ value = self.defaults[key]
171
+ log.debug(f'Defaulting {key} to {value}')
172
+ return value
173
+
158
174
  prompt = self.questions.get(key, key)
159
175
  prompt = textwrap.dedent(prompt).strip()
160
176
  value = self.input(prompt)
@@ -0,0 +1,80 @@
1
+ """ Interactive user inputs """
2
+ import os
3
+ import shlex
4
+ import subprocess
5
+ import tempfile
6
+
7
+ from .log import log
8
+
9
+
10
+ def choice(question, choices=None, default=None):
11
+ """
12
+ Ask user to make a choice.
13
+
14
+ .. code-block::
15
+
16
+ accepted = cli2.choice('Accept terms?') == 'y'
17
+
18
+ :param question: String question to ask
19
+ :param choices: List of acceptable choices, y/n by default
20
+ :param default: Default value for when the user does not add a value.
21
+ """
22
+ choices = [c.lower() for c in choices or ['y', 'n']]
23
+
24
+ if default:
25
+ choices_display = [
26
+ c.upper() if c == default.lower() else c
27
+ for c in choices
28
+ ]
29
+ else:
30
+ choices_display = choices
31
+
32
+ question = question + f' ({"/".join(choices_display)})'
33
+
34
+ tries = 30
35
+ while tries:
36
+ answer = input(question)
37
+ if not answer and default:
38
+ return default
39
+
40
+ if answer.lower() in choices:
41
+ return answer.lower()
42
+
43
+ tries -= 1
44
+
45
+
46
+ def editor(content=None):
47
+ """
48
+ Open $EDITOR with content, return the result.
49
+
50
+ Like git rebase -i does!
51
+
52
+ :param content: Initial content if any
53
+ :return: The edited content after $EDITOR exit
54
+ """
55
+ tmp = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix=".txt")
56
+ with tmp as f:
57
+ f.write(content)
58
+ f.flush()
59
+ filepath = f.name
60
+
61
+ editor = os.getenv('EDITOR', 'vim')
62
+
63
+ try:
64
+ command = f"{editor} {shlex.quote(filepath)}"
65
+ subprocess.run(shlex.split(command), check=True)
66
+
67
+ with open(filepath, 'r') as f:
68
+ content = f.read()
69
+ return content
70
+ except subprocess.CalledProcessError as e:
71
+ log.error(f"Error running Vim: {e}")
72
+ return None
73
+ except FileNotFoundError:
74
+ log.warn(f"Temporary file gone?? {filepath}")
75
+ return None
76
+ finally:
77
+ try:
78
+ os.remove(filepath)
79
+ except OSError as e:
80
+ log.warn(f"Error deleting temporary file {filepath}: {e}")
@@ -94,7 +94,6 @@ def configure(log_file=None):
94
94
  if os.getenv('DEBUG'):
95
95
  LOG_LEVEL = 'DEBUG'
96
96
 
97
- timestamper = structlog.processors.TimeStamper(fmt='%Y-%m-%d %H:%M:%S')
98
97
  pre_chain = [
99
98
  # add log level and timestamp to event_dict
100
99
  structlog.stdlib.add_log_level,
@@ -102,9 +101,12 @@ def configure(log_file=None):
102
101
  # that values in the extra parameters of log methods pass through to
103
102
  # log output
104
103
  structlog.stdlib.ExtraAdder(),
105
- timestamper,
106
104
  ]
107
105
 
106
+ if 'NO_TIMESTAMPER' not in os.environ:
107
+ timestamper = structlog.processors.TimeStamper(fmt='%Y-%m-%d %H:%M:%S')
108
+ pre_chain.append(timestamper)
109
+
108
110
  cmd = '_'.join([
109
111
  re.sub('[^0-9a-zA-Z]+', '_', arg.split('/')[-1])
110
112
  for arg in sys.argv
@@ -213,18 +215,23 @@ def configure(log_file=None):
213
215
 
214
216
  logging.config.dictConfig(LOGGING)
215
217
 
218
+ processors = [
219
+ structlog.stdlib.add_log_level,
220
+ structlog.stdlib.PositionalArgumentsFormatter(),
221
+ ]
222
+ if 'NO_TIMESTAMPER' not in os.environ:
223
+ processors.append(timestamper)
224
+ processors += [
225
+ structlog.processors.StackInfoRenderer(),
226
+ structlog.processors.format_exc_info,
227
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
228
+ ]
229
+
216
230
  structlog.configure(
217
231
  logger_factory=structlog.stdlib.LoggerFactory(),
218
232
  wrapper_class=structlog.stdlib.BoundLogger,
219
233
  cache_logger_on_first_use=True,
220
- processors=[
221
- structlog.stdlib.add_log_level,
222
- structlog.stdlib.PositionalArgumentsFormatter(),
223
- timestamper,
224
- structlog.processors.StackInfoRenderer(),
225
- structlog.processors.format_exc_info,
226
- structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
227
- ],
234
+ processors=processors,
228
235
  )
229
236
 
230
237
 
@@ -0,0 +1,74 @@
1
+ """
2
+ Like levenshtein with difflib.
3
+
4
+ .. code-block:: python
5
+
6
+ source_word = "apple"
7
+ word_list = ["apply", "aple", "banana", "orange", "applet"]
8
+
9
+ closest = cli2.closest(source_word, word_list)
10
+ print(f"The closest word to '{source_word}' is: {closest}")
11
+ """
12
+
13
+ import difflib
14
+
15
+
16
+ def closest(source_token, token_list):
17
+ """
18
+ Finds the token in token_list with the shortest distance to source_token.
19
+
20
+ :param source_token: The source token (string).
21
+ :param token_list: A list of tokens (strings).
22
+ :return: The token with the shortest distance, or None if token_list is
23
+ empty.
24
+ """
25
+
26
+ if not token_list:
27
+ return None
28
+
29
+ closest_token = None
30
+ shortest_distance = float("inf") # Initialize with infinity
31
+
32
+ for token in token_list:
33
+ matcher = difflib.SequenceMatcher(None, source_token, token)
34
+ distance = (
35
+ 1 - matcher.ratio()
36
+ ) # Calculate a distance metric (1 - similarity ratio)
37
+
38
+ if distance < shortest_distance:
39
+ shortest_distance = distance
40
+ closest_token = token
41
+
42
+ return closest_token
43
+
44
+
45
+ def closest_path(path, paths):
46
+ """
47
+ Find the closest path from paths.
48
+
49
+ LLM may output broken paths, this fixes them.
50
+
51
+ :param path: Path to find closest
52
+ :param paths: List of paths to search in.
53
+ """
54
+ parts = path.split('/')
55
+ for number, part in enumerate(parts):
56
+ paths_parts = {
57
+ str(path).split('/')[number]
58
+ for path in paths
59
+ }
60
+ if part not in paths_parts:
61
+ path_part = closest(part, paths_parts)
62
+ if not path_part:
63
+ return None # not found at all
64
+ else:
65
+ path_part = part
66
+
67
+ parts[number] = path_part
68
+ paths = {
69
+ path
70
+ for path in paths
71
+ if str(path).startswith('/'.join(parts[:number + 1]))
72
+ }
73
+
74
+ return '/'.join(parts)
@@ -184,7 +184,7 @@ class Table(list):
184
184
  if len(wrapped) > 1:
185
185
  leftovers[colnum] = ' '.join(wrapped[1:])
186
186
  if color:
187
- line[-1] = color + line[-1] + colors.reset
187
+ line[-1] = str(color) + line[-1] + colors.reset
188
188
  for leftover in leftovers:
189
189
  if len(leftover):
190
190
  rows.insert(0, leftovers)
@@ -75,7 +75,7 @@ def autotest(path, cmd, ignore=None, env=None):
75
75
  '''.strip(),
76
76
  )
77
77
 
78
- diff_cmd = 'diff -U 1 - "%s" | sed "1,2 d"' % path
78
+ diff_cmd = 'diff -U 1 "%s" - | sed "1,2 d"' % path
79
79
  proc = subprocess.Popen(
80
80
  diff_cmd,
81
81
  stdout=subprocess.PIPE,
@@ -88,8 +88,8 @@ def autotest(path, cmd, ignore=None, env=None):
88
88
  if diff_out:
89
89
  raise type(f'''
90
90
  DiffFound
91
- - {cmd}
92
- + {path}
91
+ - {path}
92
+ + {cmd}
93
93
  '''.strip(), (Exception,), {})('\n' + diff_out.decode('utf8'))
94
94
 
95
95
 
@@ -0,0 +1,223 @@
1
+ """
2
+ Color themes.
3
+
4
+ The theme is available in the `cli2.t` namespace.
5
+
6
+ .. envar:: CLI2_THEME
7
+
8
+ The default is "standard" but "monokai" and "flashy" are also available.
9
+ Standard theme uses basic colors, and lets you override them with
10
+ environment variables: CLI2_RED, CLI2_GREEN, and so on.
11
+
12
+ Example:
13
+
14
+ .. code-block:: python
15
+
16
+ import cli2
17
+
18
+ print(f'{cli2.theme.green}OK{cli2.theme.reset}')
19
+
20
+ # with a mode, such as bold, dim, italic, underline and strike:
21
+ print(f'{cli2.theme.green.bold}OK{cli2.theme.reset}')
22
+
23
+ # all is also callable appending the reset automatically
24
+ print(cli2.theme.green('OK'))
25
+ print(cli2.theme.green.bold('OK'))
26
+
27
+ We also have shortcuts, ``cli2.theme`` is ``cli2.t``, each color can be
28
+ referred to by first letter in lowercase, except for black and gray which are
29
+ refered to by their first letter in uppercase. Modes can be referred to by
30
+ first letter in lowercase too, and reset is rs:
31
+
32
+ .. code-block:: python
33
+
34
+ # shortcuts
35
+ print(f'{cli2.t.g.b}OK{cli2.t.rs}')
36
+
37
+ # shorter with callable
38
+ print(cli2.t.g.b('OK'))
39
+
40
+ Run ``cli2-theme`` for the list of colors by theme.
41
+ """
42
+ import re
43
+ import os
44
+
45
+ from .cli import Command
46
+
47
+ themes = dict(
48
+ standard=dict(
49
+ black=int(os.getenv('CLI2_BLACK', 0)),
50
+ red=int(os.getenv('CLI2_RED', 1)),
51
+ green=int(os.getenv('CLI2_GREEN', 2)),
52
+ yellow=int(os.getenv('CLI2_YELLOW', 3)),
53
+ orange=int(os.getenv('CLI2_ORANGE', 208)),
54
+ blue=int(os.getenv('CLI2_BLUE', 4)),
55
+ mauve=int(os.getenv('CLI2_MAUVE', 5)),
56
+ pink=int(os.getenv('CLI2_PINK', 164)),
57
+ cyan=int(os.getenv('CLI2_CYAN', 6)),
58
+ gray=int(os.getenv('CLI2_GRAY', 7)),
59
+ ),
60
+ flashy=dict(
61
+ black=0,
62
+ red=196,
63
+ green=46,
64
+ yellow=227,
65
+ blue=27,
66
+ mauve=129,
67
+ pink=201,
68
+ orange=202,
69
+ cyan=51,
70
+ gray=253,
71
+ ),
72
+ monokai=dict(
73
+ black=0,
74
+ red=124,
75
+ green=150,
76
+ yellow=179,
77
+ blue=67,
78
+ mauve=140,
79
+ pink=132,
80
+ orange=202,
81
+ cyan=80,
82
+ gray=246,
83
+ ),
84
+ )
85
+
86
+
87
+ class Renderer:
88
+ def __call__(self, *content):
89
+ return f'{self}{" ".join([str(c) for c in content])}{t.rs}'
90
+
91
+
92
+ class Mode(Renderer):
93
+ def __init__(self, name, code):
94
+ self.name = name
95
+ self.code = code
96
+ self.alias = name[0]
97
+
98
+ def __str__(self):
99
+ return f'\u001b[{self.code}m'
100
+
101
+
102
+ class ColorMode(Renderer):
103
+ def __init__(self, color, mode):
104
+ self.color = color
105
+ self.mode = mode
106
+
107
+ def __str__(self):
108
+ return f'\u001b[{self.mode.code};38;5;{self.color.code}m'
109
+
110
+
111
+ modes = {
112
+ name: Mode(name, code)
113
+ for name, code in dict(
114
+ bold=1,
115
+ dim=2,
116
+ italic=3,
117
+ underline=4,
118
+ strike=9,
119
+ ).items()
120
+ }
121
+
122
+
123
+ class Color(Renderer):
124
+ def __init__(self, code, name=None, alias=None):
125
+ self.name = name
126
+ self.alias = alias
127
+ self.code = code
128
+
129
+ for mode, code in modes.items():
130
+ color_mode = ColorMode(self, code)
131
+ setattr(self, mode, color_mode)
132
+ setattr(self, mode[0], color_mode)
133
+
134
+ def __str__(self):
135
+ return f'\u001b[38;5;{self.code}m'
136
+
137
+
138
+ class Theme:
139
+ def __init__(self, colors):
140
+ self.colors = colors
141
+
142
+ for name, mode in modes.items():
143
+ setattr(self, mode.name, mode)
144
+
145
+ for name, value in colors.items():
146
+ if name in ('black', 'gray'):
147
+ alias = name[0].upper()
148
+ else:
149
+ alias = name[0]
150
+
151
+ color = Color(value, name, alias)
152
+ setattr(self, name, color)
153
+ setattr(self, alias, color)
154
+
155
+ for name in ('reset', 'rs'):
156
+ setattr(self, name, '\u001b[0m')
157
+
158
+ @staticmethod
159
+ def len(string):
160
+ """
161
+ Counts the number of alphabetic characters in a string,
162
+ ignoring ANSI escape codes.
163
+
164
+ :param string: Input string
165
+ :return: Integer count of actual printable chars
166
+ """
167
+ # Regular expression to match ANSI escape codes
168
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
169
+
170
+ # Remove ANSI escape codes from the string
171
+ cleaned_text = ansi_escape.sub('', string)
172
+
173
+ # Count the alphabetic characters in the cleaned string
174
+ letter_count = 0
175
+ for char in cleaned_text:
176
+ if 'a' <= char <= 'z' or 'A' <= char <= 'Z':
177
+ letter_count += 1
178
+
179
+ return letter_count
180
+
181
+
182
+ t = theme = Theme(themes[os.getenv('CLI2_THEME', 'standard')])
183
+
184
+
185
+ def demo():
186
+ """
187
+ Print all colors and modes from the theme.
188
+ """
189
+ for name, theme in themes.items():
190
+ theme = Theme(themes[name])
191
+ print(f'\n\nTheme: {theme.bold(name)}')
192
+ _demo(theme)
193
+
194
+ print()
195
+ print(theme.bold('MODES:'))
196
+ for name in modes:
197
+ mode = getattr(theme, name)
198
+ print(f'{mode}t.{name}{t.rs}')
199
+
200
+
201
+ def _demo(theme):
202
+ def color_data(alias, color):
203
+ data = [
204
+ (color, alias),
205
+ ]
206
+
207
+ for mode in modes:
208
+ data.append(
209
+ (getattr(color, mode[0]), f'{alias}.{mode[0]}'),
210
+ )
211
+
212
+ return data
213
+
214
+ from .table import Table
215
+ table = Table()
216
+ for name in t.colors:
217
+ color = getattr(theme, name)
218
+
219
+ table.append(color_data(color.alias, color))
220
+ table.print()
221
+
222
+
223
+ cli = Command(demo)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cli2
3
- Version: 5.1.6
3
+ Version: 5.1.8
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
@@ -10,13 +10,16 @@ cli2/colors.py
10
10
  cli2/configuration.py
11
11
  cli2/decorators.py
12
12
  cli2/display.py
13
+ cli2/interactive.py
13
14
  cli2/lock.py
14
15
  cli2/log.py
15
16
  cli2/mask.py
16
17
  cli2/node.py
18
+ cli2/notlevenshtein.py
17
19
  cli2/sphinx.py
18
20
  cli2/table.py
19
21
  cli2/test.py
22
+ cli2/theme.py
20
23
  cli2.egg-info/PKG-INFO
21
24
  cli2.egg-info/SOURCES.txt
22
25
  cli2.egg-info/dependency_links.txt
@@ -44,9 +47,11 @@ tests/test_display.py
44
47
  tests/test_entry_point.py
45
48
  tests/test_group.py
46
49
  tests/test_inject.py
50
+ tests/test_interactive.py
47
51
  tests/test_lock.py
48
52
  tests/test_log.py
49
53
  tests/test_mask.py
50
54
  tests/test_node.py
55
+ tests/test_notlevenshtein.py
51
56
  tests/test_restful.py
52
57
  tests/test_table.py
@@ -4,3 +4,5 @@ cli2-example = cli2.examples.obj:cli.entry_point
4
4
  cli2-example-client = cli2.examples.client:cli.entry_point
5
5
  cli2-example-nesting = cli2.examples.nesting:cli.entry_point
6
6
  cli2-example2 = cli2.examples.obj2:cli.entry_point
7
+ cli2-md = cli2.markdown:cli.entry_point
8
+ cli2-theme = cli2.theme:cli.entry_point
@@ -44,7 +44,7 @@ from setuptools import setup
44
44
 
45
45
  setup(
46
46
  name='cli2',
47
- version='5.1.6',
47
+ version='5.1.8',
48
48
  setup_requires='setupmeta',
49
49
  packages=['cli2'],
50
50
  install_requires=[
@@ -75,6 +75,8 @@ setup(
75
75
  entry_points={
76
76
  'console_scripts': [
77
77
  'cli2 = cli2.cli2:main.entry_point',
78
+ 'cli2-theme = cli2.theme:cli.entry_point',
79
+ 'cli2-md = cli2.markdown:cli.entry_point',
78
80
  'cli2-example = cli2.examples.obj:cli.entry_point',
79
81
  'cli2-example2 = cli2.examples.obj2:cli.entry_point',
80
82
  'cli2-example-nesting = cli2.examples.nesting:cli.entry_point',
@@ -1,7 +1,7 @@
1
1
  import cli2
2
2
  import chttpx
3
3
  import httpx
4
- import mock
4
+ from unittest import mock
5
5
  import pytest
6
6
  import textwrap
7
7
  import yaml
@@ -13,19 +13,19 @@ def test_call():
13
13
  assert "args=('http://?x=bar',)" in result
14
14
 
15
15
 
16
- def test_doc_by_default(mocker):
16
+ def test_doc_by_default():
17
17
  main.outfile = Outfile()
18
18
  main('cli2.examples.test')
19
19
  assert 'example_function' in main.outfile
20
20
 
21
21
 
22
- def test_help_argument(mocker):
22
+ def test_help_argument():
23
23
  main.outfile = Outfile()
24
24
  main('help', 'cli2.examples.test')
25
25
  assert 'example_function' in main.outfile
26
26
 
27
27
 
28
- def test_help_no_argument(mocker):
28
+ def test_help_no_argument():
29
29
  main.outfile = Outfile()
30
30
  main()
31
31
  assert 'help' in main.outfile
@@ -3,7 +3,7 @@ import cli2
3
3
  import chttpx
4
4
  import httpx
5
5
  import inspect
6
- import mock
6
+ from unittest import mock
7
7
  import pytest
8
8
 
9
9
 
@@ -304,7 +304,7 @@ async def test_handler(client_class):
304
304
  response = httpx.Response(status_code=200)
305
305
  response.request = httpx.Request('POST', '/', json=[1])
306
306
  result = await handler(client, response, 0, log)
307
- log.info.assert_called_once_with(
307
+ log.warn.assert_called_once_with(
308
308
  'retry', status_code=200, tries=0, sleep=.0
309
309
  )
310
310
  assert not result
@@ -313,7 +313,7 @@ async def test_handler(client_class):
313
313
  response.request = httpx.Request('POST', '/', json=[1])
314
314
  with pytest.raises(chttpx.RetriesExceededError) as exc:
315
315
  await handler(client, response, handler.tries + 1, log)
316
- log.info.assert_called_once_with(
316
+ log.warn.assert_called_once_with(
317
317
  'retry', status_code=200, tries=0, sleep=.0
318
318
  )
319
319
 
@@ -347,8 +347,9 @@ async def test_handler(client_class):
347
347
  assert not client.client_reset.await_count
348
348
  exc = httpx.TransportError('foo')
349
349
  exc.request = response.request
350
+ log.warn.reset_mock()
350
351
  result = await handler(client, exc, 0, log)
351
- log.warn.assert_called_once_with(
352
+ log.warn.assert_called_with(
352
353
  'reconnect',
353
354
  error="TransportError('foo')",
354
355
  method='POST',
@@ -366,7 +367,7 @@ async def test_handler(client_class):
366
367
  assert not client.token_reset.await_count
367
368
  log.warn.reset_mock()
368
369
  result = await handler(client, response, 0, log)
369
- log.warn.assert_called_once_with('retoken')
370
+ assert mock.call('retoken') in log.warn.call_args_list
370
371
  assert not result
371
372
  assert client.token_reset.await_count == 1
372
373
 
@@ -4,6 +4,7 @@ import cli2.test
4
4
  import inspect
5
5
  import pytest
6
6
  import os
7
+ from unittest import mock
7
8
 
8
9
 
9
10
  os.environ['FORCE_COLOR'] = '1'
@@ -438,16 +439,16 @@ def test_docstring():
438
439
  )
439
440
 
440
441
 
441
- def test_print(mocker):
442
- cmd = cli2.Command(lambda: True, outfile=mocker.Mock())
442
+ def test_print():
443
+ cmd = cli2.Command(lambda: True, outfile=mock.Mock())
443
444
  cmd.print('orangebold', 'foo', 'bar')
444
445
  assert cmd.outfile.write.call_args_list[0].args == (
445
446
  '\x1b[1;38;5;202mfoo bar\x1b[0m',
446
447
  )
447
448
 
448
449
 
449
- def test_print_bold(mocker):
450
- cmd = cli2.Command(lambda: True, outfile=mocker.Mock())
450
+ def test_print_bold():
451
+ cmd = cli2.Command(lambda: True, outfile=mock.Mock())
451
452
  cmd.print('ORANGE', 'foo', 'bar')
452
453
  assert cmd.outfile.write.call_args_list[0].args == (
453
454
  '\x1b[1;38;5;202mfoo bar\x1b[0m',
@@ -75,3 +75,6 @@ def test_idempotent(tmp_path):
75
75
  assert cfg.prints[0][0][0] == (
76
76
  f'Appended to {cfg.profile_path}:\nexport TEST=success'
77
77
  )
78
+
79
+ cfg.defaults['HASDEF'] = 1
80
+ assert cfg['HASDEF'] == 1
@@ -195,9 +195,12 @@ def test_load():
195
195
  raise Exception('fails')
196
196
 
197
197
  class Bar(metaclass=BarMetaclass):
198
+ def __init__(self, init=None):
199
+ self.init = init
200
+
198
201
  @cli2.cmd
199
202
  def test(self):
200
- pass
203
+ return self.init
201
204
 
202
205
  @classmethod
203
206
  @cli2.cmd
@@ -228,11 +231,16 @@ def test_load():
228
231
  group.load(Foo)
229
232
  assert list(group.keys()) == ['help', 'test', 'classmeth', 'test2']
230
233
 
231
- group = cli2.Group(overrides=dict(self=dict(factory=lambda: Foo())))
232
- group.load(Foo())
233
- assert list(group.keys()) == ['help', 'classmeth', 'test', 'test2']
234
-
235
- assert isinstance(group['test2'](), Foo)
234
+ group = cli2.Group()
235
+ group['0'] = group.group('0')
236
+ group['1'] = group.group('1')
237
+ group['0'].load(Foo(0))
238
+ group['1'].load(Foo(1))
239
+ assert list(group['0'].keys()) == ['help', 'classmeth', 'test', 'test2']
240
+
241
+ assert isinstance(group['0']['test2'](), Foo)
242
+ assert group['1']['test']() == 1
243
+ assert group['0']['test']() == 0
236
244
 
237
245
  class Child(Foo):
238
246
  test2 = None
@@ -0,0 +1,50 @@
1
+ import cli2
2
+ from unittest import mock
3
+
4
+
5
+ def test_default_choices_y():
6
+ with mock.patch("builtins.input", return_value="y"):
7
+ result = cli2.choice("Test question")
8
+ assert result == "y"
9
+
10
+
11
+ def test_default_choices_n():
12
+ with mock.patch("builtins.input", return_value="n"):
13
+ result = cli2.choice("Test question")
14
+ assert result == "n"
15
+
16
+
17
+ def test_custom_choices():
18
+ with mock.patch("builtins.input", return_value="b"):
19
+ result = cli2.choice("Test question", choices=["a", "b", "c"])
20
+ assert result == "b"
21
+
22
+
23
+ def test_default_value_provided():
24
+ with mock.patch("builtins.input", return_value=""):
25
+ result = cli2.choice("Test question", choices=["a", "b", "c"], default="b")
26
+ assert result == "b"
27
+
28
+
29
+ def test_default_value_not_provided():
30
+ with mock.patch("builtins.input", return_value=""):
31
+ result = cli2.choice("Test question", choices=["a", "b", "c"])
32
+ assert result is None # because the while loop breaks without return.
33
+
34
+
35
+ def test_case_insensitive_input():
36
+ with mock.patch("builtins.input", return_value="A"):
37
+ result = cli2.choice("Test question", choices=["a", "b", "c"])
38
+ assert result == "a"
39
+
40
+
41
+ def test_invalid_input():
42
+ with mock.patch("builtins.input", side_effect=["d", "a"]):
43
+ result = cli2.choice("Test question", choices=["a", "b", "c"])
44
+ assert result == "a"
45
+
46
+
47
+ def test_empty_choices_defaults_to_yn():
48
+ with mock.patch("builtins.input", return_value="y"):
49
+ result = cli2.choice("Test question", choices=[])
50
+ assert result == "y"
@@ -7,7 +7,7 @@ Another test to do is to run, in two terminals:
7
7
  - python tests/lock.py
8
8
  """
9
9
  import cli2
10
- import mock
10
+ from unittest import mock
11
11
 
12
12
 
13
13
  def test_non_blocking(tmp_path):
@@ -0,0 +1,46 @@
1
+ import pytest
2
+ import cli2
3
+
4
+
5
+ def test_find_closest_token_basic():
6
+ source_word = "apple"
7
+ word_list = ["apply", "aple", "banana", "orange", "applet"]
8
+ assert cli2.closest(source_word, word_list) == "applet"
9
+
10
+
11
+ def test_find_closest_token_exact_match():
12
+ source_word = "cat"
13
+ word_list = ["dog", "cat", "bat"]
14
+ assert cli2.closest(source_word, word_list) == "cat"
15
+
16
+
17
+ def test_find_closest_token_multiple_close():
18
+ source_word = "car"
19
+ word_list = ["card", "cart", "care"]
20
+ assert cli2.closest(source_word, word_list) == "card"
21
+
22
+
23
+ def test_find_closest_token_empty_list():
24
+ source_word = "test"
25
+ word_list = []
26
+ assert cli2.closest(source_word, word_list) is None
27
+
28
+
29
+ def test_find_closest_token_no_close_match():
30
+ source_word = "xyz"
31
+ word_list = ["apple", "banana", "orange"]
32
+ # difflib will pick the first if no close matches.
33
+ assert cli2.closest(source_word, word_list) == "apple"
34
+
35
+
36
+ def test_find_closest_token_different_lengths():
37
+ source_word = "longword"
38
+ word_list = ["short", "longer", "longestword"]
39
+ assert cli2.closest(source_word, word_list) == "longestword"
40
+
41
+
42
+ def test_find_closest_token_same_distance_multiple():
43
+ source_word = "test"
44
+ word_list = ["tesa", "tesb", "tesc"]
45
+ # difflib will pick the first if same distances.
46
+ assert cli2.closest(source_word, word_list) == "tesa"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes