cli2 5.1.6__tar.gz → 5.2.1rc1__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 (60) hide show
  1. {cli2-5.1.6/cli2.egg-info → cli2-5.2.1rc1}/PKG-INFO +2 -1
  2. {cli2-5.1.6 → cli2-5.2.1rc1}/README.rst +1 -0
  3. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/__init__.py +6 -0
  4. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/cli.py +36 -8
  5. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/configuration.py +17 -1
  6. cli2-5.2.1rc1/cli2/interactive.py +80 -0
  7. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/log.py +17 -10
  8. cli2-5.2.1rc1/cli2/notlevenshtein.py +74 -0
  9. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/table.py +1 -1
  10. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/test.py +5 -5
  11. cli2-5.2.1rc1/cli2/theme.py +223 -0
  12. {cli2-5.1.6 → cli2-5.2.1rc1/cli2.egg-info}/PKG-INFO +2 -1
  13. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2.egg-info/SOURCES.txt +6 -0
  14. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2.egg-info/entry_points.txt +2 -0
  15. {cli2-5.1.6 → cli2-5.2.1rc1}/setup.py +3 -1
  16. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_ansible.py +1 -1
  17. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_cli.py +3 -3
  18. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_client.py +6 -5
  19. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_command.py +7 -6
  20. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_configuration.py +3 -0
  21. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_group.py +14 -6
  22. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_inject.py +1 -1
  23. cli2-5.2.1rc1/tests/test_interactive.py +50 -0
  24. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_lock.py +1 -1
  25. cli2-5.2.1rc1/tests/test_notlevenshtein.py +46 -0
  26. cli2-5.2.1rc1/tests/test_prompt2.py +144 -0
  27. {cli2-5.1.6 → cli2-5.2.1rc1}/MANIFEST.in +0 -0
  28. {cli2-5.1.6 → cli2-5.2.1rc1}/classifiers.txt +0 -0
  29. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/asyncio.py +0 -0
  30. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/cli2.py +0 -0
  31. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/colors.py +0 -0
  32. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/decorators.py +0 -0
  33. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/display.py +0 -0
  34. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/__init__.py +0 -0
  35. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/conf.py +0 -0
  36. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/example.py +0 -0
  37. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/example_obj.py +0 -0
  38. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/nesting.py +0 -0
  39. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/obj.py +0 -0
  40. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/obj2.py +0 -0
  41. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/examples/test.py +0 -0
  42. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/lock.py +0 -0
  43. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/mask.py +0 -0
  44. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/node.py +0 -0
  45. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2/sphinx.py +0 -0
  46. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2.egg-info/dependency_links.txt +0 -0
  47. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2.egg-info/requires.txt +0 -0
  48. {cli2-5.1.6 → cli2-5.2.1rc1}/cli2.egg-info/top_level.txt +0 -0
  49. {cli2-5.1.6 → cli2-5.2.1rc1}/setup.cfg +0 -0
  50. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_ansible_variables.py +0 -0
  51. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_asyncio.py +0 -0
  52. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_client_test.py +0 -0
  53. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_decorators.py +0 -0
  54. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_display.py +0 -0
  55. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_entry_point.py +0 -0
  56. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_log.py +0 -0
  57. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_mask.py +0 -0
  58. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_node.py +0 -0
  59. {cli2-5.1.6 → cli2-5.2.1rc1}/tests/test_restful.py +0 -0
  60. {cli2-5.1.6 → cli2-5.2.1rc1}/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.2.1rc1
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
@@ -63,5 +63,6 @@ Batteries included, all of which are useful on their own:
63
63
  testing library so that you can go straight to the point in pytest
64
64
  - a good old fcntl based locking
65
65
  - a command line to run any python function over a beautiful CLI
66
+ - **AI assisted programing CLI & framework with code2**
66
67
 
67
68
  `Documentation available on RTFD <https://cli2.rtfd.io>`_.
@@ -25,5 +25,6 @@ Batteries included, all of which are useful on their own:
25
25
  testing library so that you can go straight to the point in pytest
26
26
  - a good old fcntl based locking
27
27
  - a command line to run any python function over a beautiful CLI
28
+ - **AI assisted programing CLI & framework with code2**
28
29
 
29
30
  `Documentation available on RTFD <https://cli2.rtfd.io>`_.
@@ -8,20 +8,26 @@ from .cli import (
8
8
  Command,
9
9
  Group,
10
10
  EntryPoint,
11
+ Cli2Error,
12
+ Cli2ValueError,
11
13
  )
12
14
  from .asyncio import async_resolve, async_run, Queue
13
15
  from .colors import colors as c
16
+ from .theme import theme, t
14
17
 
15
18
  from .configuration import Configuration, cfg
16
19
  from .display import diff, diff_data, render, print, highlight, yaml_highlight
20
+ from .interactive import choice, editor
17
21
  try:
18
22
  import fcntl
19
23
  except ImportError:
20
24
  """ windows """
21
25
  else:
22
26
  from .lock import Lock
27
+
23
28
  from .log import configure, log, parse
24
29
  from .mask import Mask
30
+ from .notlevenshtein import closest, closest_path
25
31
  from .table import Table
26
32
 
27
33
 
@@ -14,6 +14,14 @@ from .asyncio import async_resolve
14
14
  from .colors import colors
15
15
 
16
16
 
17
+ class Cli2Error(Exception):
18
+ pass
19
+
20
+
21
+ class Cli2ValueError(Cli2Error):
22
+ pass
23
+
24
+
17
25
  class Overrides(dict):
18
26
  """
19
27
  Lazy overrides dict
@@ -299,6 +307,9 @@ class Group(EntryPoint, dict):
299
307
  continue
300
308
  if leaf and getattr(final, name, '_') is None:
301
309
  continue
310
+ if isinstance(method, classmethod):
311
+ # get the bound method
312
+ method = getattr(cls, name)
302
313
  self.load_method(final, method)
303
314
 
304
315
  def load_obj(self, obj):
@@ -308,9 +319,10 @@ class Group(EntryPoint, dict):
308
319
  for name in dir(obj):
309
320
  if name.startswith('_'):
310
321
  continue
311
- if not callable(getattr(type(obj), name)):
322
+ if not callable(getattr(type(obj), name, None)):
312
323
  continue
313
- self.load_method(obj, getattr(obj, name))
324
+ method = getattr(obj, name)
325
+ self.load_method(obj, method)
314
326
 
315
327
  def load_method(self, obj, method):
316
328
  wrapped_method = getattr(method, '__func__', None)
@@ -325,7 +337,7 @@ class Group(EntryPoint, dict):
325
337
  if condition:
326
338
  if not condition(obj):
327
339
  return
328
- self.cmd(wrapped_method or method)
340
+ self.cmd(method)
329
341
 
330
342
  def __call__(self, *argv):
331
343
  self.exit_code = 0
@@ -648,9 +660,14 @@ class Command(EntryPoint, dict):
648
660
  except KeyboardInterrupt:
649
661
  print('exiting cleanly...')
650
662
  self.exit_code = 1
663
+ except Exception as exc:
664
+ self.handle_exception(exc)
651
665
  finally:
652
666
  self.post_result = self.post_call()
653
667
 
668
+ def handle_exception(self, exc):
669
+ raise exc
670
+
654
671
  async def async_call(self, *argv):
655
672
  """ Call with async stuff in single event loop """
656
673
  error = self.parse(*argv)
@@ -665,8 +682,11 @@ class Command(EntryPoint, dict):
665
682
 
666
683
  await self.factories_resolve()
667
684
 
668
- result = self.call(*self.bound.args, **self.bound.kwargs)
669
- return await async_resolve(result, output=True)
685
+ try:
686
+ result = self.call(*self.bound.args, **self.bound.kwargs)
687
+ return await async_resolve(result, output=True)
688
+ except Exception as exc:
689
+ self.handle_exception(exc)
670
690
 
671
691
  async def factories_resolve(self):
672
692
  """ Resolve all factories values. """
@@ -825,7 +845,11 @@ class Argument:
825
845
  self.type = param.annotation
826
846
 
827
847
  self.negate = None
828
- if self.iskw and self.param.annotation == bool:
848
+ if (
849
+ self.iskw
850
+ and self.param.annotation == bool
851
+ and self.param.default is not False
852
+ ):
829
853
  self.negate = 'no-' + param.name
830
854
  if cmd.posix:
831
855
  self.negate = self.negate.replace('_', '-')
@@ -978,7 +1002,11 @@ class Argument:
978
1002
  + colors.reset
979
1003
  )
980
1004
 
981
- if self.type == bool and not self.negates:
1005
+ if (
1006
+ self.type == bool
1007
+ and not self.negates
1008
+ and self.param.default is not False
1009
+ ):
982
1010
  self.cmd.print(
983
1011
  'Accepted: '
984
1012
  + colors.blue3
@@ -1035,7 +1063,7 @@ class Argument:
1035
1063
  if self.default != self.param.empty:
1036
1064
  return self.default
1037
1065
  msg = f'{self.param.name} has no CLI bound value nor default'
1038
- raise ValueError(msg) from exc
1066
+ raise Cli2ValueError(msg) from exc
1039
1067
 
1040
1068
  @value.setter
1041
1069
  def value(self, value):
@@ -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)
@@ -21,8 +21,8 @@ def autotest(path, cmd, ignore=None, env=None):
21
21
  """
22
22
  environ = copy.copy(os.environ)
23
23
  if env:
24
- for key, value in env.items():
25
- environ[key] = value
24
+ environ.update(env)
25
+ environ['CLI2_THEME'] = 'standard'
26
26
  environ['FORCE_TERMSIZE'] = '1'
27
27
  environ['PATH'] = ':'.join([
28
28
  environ.get('HOME', '') + '/.local/bin',
@@ -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.2.1rc1
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
@@ -63,5 +63,6 @@ Batteries included, all of which are useful on their own:
63
63
  testing library so that you can go straight to the point in pytest
64
64
  - a good old fcntl based locking
65
65
  - a command line to run any python function over a beautiful CLI
66
+ - **AI assisted programing CLI & framework with code2**
66
67
 
67
68
  `Documentation available on RTFD <https://cli2.rtfd.io>`_.