cli2 5.2.1rc1__tar.gz → 5.2.3__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 (64) hide show
  1. {cli2-5.2.1rc1/cli2.egg-info → cli2-5.2.3}/PKG-INFO +3 -2
  2. {cli2-5.2.1rc1 → cli2-5.2.3}/README.rst +2 -1
  3. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/__init__.py +3 -1
  4. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/asyncio.py +0 -17
  5. cli2-5.2.3/cli2/find.py +189 -0
  6. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/interactive.py +32 -16
  7. cli2-5.2.3/cli2/proc.py +302 -0
  8. {cli2-5.2.1rc1 → cli2-5.2.3/cli2.egg-info}/PKG-INFO +3 -2
  9. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2.egg-info/SOURCES.txt +4 -0
  10. {cli2-5.2.1rc1 → cli2-5.2.3}/setup.py +1 -1
  11. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_asyncio.py +0 -15
  12. cli2-5.2.3/tests/test_find.py +131 -0
  13. cli2-5.2.3/tests/test_proc.py +150 -0
  14. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_prompt2.py +78 -12
  15. {cli2-5.2.1rc1 → cli2-5.2.3}/MANIFEST.in +0 -0
  16. {cli2-5.2.1rc1 → cli2-5.2.3}/classifiers.txt +0 -0
  17. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/cli.py +0 -0
  18. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/cli2.py +0 -0
  19. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/colors.py +0 -0
  20. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/configuration.py +0 -0
  21. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/decorators.py +0 -0
  22. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/display.py +0 -0
  23. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/__init__.py +0 -0
  24. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/conf.py +0 -0
  25. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/example.py +0 -0
  26. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/example_obj.py +0 -0
  27. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/nesting.py +0 -0
  28. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/obj.py +0 -0
  29. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/obj2.py +0 -0
  30. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/examples/test.py +0 -0
  31. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/lock.py +0 -0
  32. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/log.py +0 -0
  33. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/mask.py +0 -0
  34. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/node.py +0 -0
  35. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/notlevenshtein.py +0 -0
  36. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/sphinx.py +0 -0
  37. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/table.py +0 -0
  38. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/test.py +0 -0
  39. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2/theme.py +0 -0
  40. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2.egg-info/dependency_links.txt +0 -0
  41. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2.egg-info/entry_points.txt +0 -0
  42. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2.egg-info/requires.txt +0 -0
  43. {cli2-5.2.1rc1 → cli2-5.2.3}/cli2.egg-info/top_level.txt +0 -0
  44. {cli2-5.2.1rc1 → cli2-5.2.3}/setup.cfg +0 -0
  45. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_ansible.py +0 -0
  46. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_ansible_variables.py +0 -0
  47. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_cli.py +0 -0
  48. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_client.py +0 -0
  49. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_client_test.py +0 -0
  50. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_command.py +0 -0
  51. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_configuration.py +0 -0
  52. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_decorators.py +0 -0
  53. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_display.py +0 -0
  54. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_entry_point.py +0 -0
  55. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_group.py +0 -0
  56. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_inject.py +0 -0
  57. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_interactive.py +0 -0
  58. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_lock.py +0 -0
  59. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_log.py +0 -0
  60. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_mask.py +0 -0
  61. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_node.py +0 -0
  62. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_notlevenshtein.py +0 -0
  63. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_restful.py +0 -0
  64. {cli2-5.2.1rc1 → cli2-5.2.3}/tests/test_table.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cli2
3
- Version: 5.2.1rc1
3
+ Version: 5.2.3
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,6 +63,7 @@ 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
+ - **AI CLI with prompt2**
67
+ - **AI coding with code2** (TBA)
67
68
 
68
69
  `Documentation available on RTFD <https://cli2.rtfd.io>`_.
@@ -25,6 +25,7 @@ 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
+ - **AI CLI with prompt2**
29
+ - **AI coding with code2** (TBA)
29
30
 
30
31
  `Documentation available on RTFD <https://cli2.rtfd.io>`_.
@@ -11,7 +11,7 @@ from .cli import (
11
11
  Cli2Error,
12
12
  Cli2ValueError,
13
13
  )
14
- from .asyncio import async_resolve, async_run, Queue
14
+ from .asyncio import async_resolve, Queue
15
15
  from .colors import colors as c
16
16
  from .theme import theme, t
17
17
 
@@ -28,6 +28,8 @@ else:
28
28
  from .log import configure, log, parse
29
29
  from .mask import Mask
30
30
  from .notlevenshtein import closest, closest_path
31
+ from .proc import Proc
32
+ from .find import Find
31
33
  from .table import Table
32
34
 
33
35
 
@@ -39,23 +39,6 @@ async def async_resolve(result, output=False):
39
39
  return result
40
40
 
41
41
 
42
- def async_run(coroutine):
43
- """Run an async coroutine in the current event loop or create a new one.
44
-
45
- If an event loop is already running, creates a task in that loop.
46
- If no event loop is running, creates a new one and runs the coroutine.
47
-
48
- :param coroutine: The coroutine to run (return value of an async function)
49
- :return: The result of the coroutine execution
50
- """
51
- try:
52
- loop = asyncio.get_running_loop()
53
- except RuntimeError:
54
- return asyncio.run(coroutine)
55
- else:
56
- return loop.create_task(coroutine)
57
-
58
-
59
42
  class Queue(asyncio.Queue):
60
43
  """
61
44
  An async queue with worker pool for concurrent task processing.
@@ -0,0 +1,189 @@
1
+ """
2
+ Effecient git-aware file finder with filtering capabilities.
3
+
4
+ Uses Linux commands: find, comm and git-ignore for an efficient path walker.
5
+
6
+ Example usage:
7
+
8
+ .. code-block:: python
9
+
10
+ # Simple usage with default root
11
+ finder = cli2.Find()
12
+ files = finder.files()
13
+ dirs = finder.dirs()
14
+
15
+ # Usage with filters and callback
16
+ def callback(filepath):
17
+ print(f"Found: {filepath}")
18
+
19
+ finder = cli2.Find(
20
+ root="/path/to/repo",
21
+ glob_include=['*.py'],
22
+ glob_exclude=['*test*'],
23
+ file_callback=callback
24
+ )
25
+ files = finder.files()
26
+ dirs = finder.dirs()
27
+ """
28
+ import cli2
29
+ from fnmatch import fnmatch
30
+ from pathlib import Path
31
+ import os
32
+
33
+
34
+ class Find:
35
+ """
36
+ A class to walk through files and directories not ignored by git with
37
+ optional filtering.
38
+
39
+ .. py:attribute:: root
40
+
41
+ Root directory for file operations
42
+
43
+ .. py:attribute:: glob_include
44
+
45
+ Optional list of glob patterns to include
46
+
47
+ .. py:attribute:: glob_exclude
48
+
49
+ Optional list of glob patterns to exclude
50
+
51
+ .. py:attribute:: file_callback
52
+
53
+ Optional callback function called for each file or directory
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ root=None,
59
+ glob_include=None,
60
+ glob_exclude=None,
61
+ file_callback=None,
62
+ ):
63
+ """
64
+ Initialize Find with optional root directory, filters, and callback.
65
+
66
+ :param root: Root directory (defaults to current working directory if
67
+ not specified)
68
+ :type root: str or pathlib.Path or None
69
+ :param glob_include: List of glob patterns to include
70
+ :type glob_include: list or None
71
+ :param glob_exclude: List of glob patterns to exclude
72
+ :type glob_exclude: list or None
73
+ :param file_callback: Function to call for each file or directory
74
+ :type file_callback: callable or None
75
+ """
76
+ self.root = Path(root if root is not None else os.getcwd()).resolve()
77
+ self.glob_include = glob_include if glob_include is not None else []
78
+ self.glob_exclude = glob_exclude if glob_exclude is not None else []
79
+ self.file_callback = file_callback
80
+
81
+ def _matches_filters(self, filepath):
82
+ """
83
+ Check if a file or directory matches the include/exclude filters.
84
+
85
+ :param filepath: Path to check against filters
86
+ :type filepath: pathlib.Path
87
+ :return: True if path should be included, False otherwise
88
+ :rtype: bool
89
+ """
90
+ filepath_str = str(filepath.relative_to(self.root))
91
+
92
+ if self.glob_include:
93
+ if not any(
94
+ fnmatch(filepath_str, pattern) for pattern in self.glob_include
95
+ ):
96
+ return False
97
+
98
+ if self.glob_exclude:
99
+ if any(
100
+ fnmatch(filepath_str, pattern) for pattern in self.glob_exclude
101
+ ):
102
+ return False
103
+
104
+ return True
105
+
106
+ def files(self, directory=None):
107
+ """
108
+ List files not ignored by git, applying filters and callback.
109
+
110
+ :param directory: Directory to start search from (defaults to root if
111
+ not specified)
112
+ :type directory: str or pathlib.Path or None
113
+ :return: List of Path objects for files not ignored by git that match
114
+ filters
115
+ :rtype: list
116
+ :raises RuntimeError: If the git command fails
117
+ """
118
+ base_path = Path(directory).resolve() if directory else self.root
119
+
120
+ cmd = ' '.join([
121
+ f'comm -23 <(find {base_path} -type f | sort)',
122
+ f'<(find {base_path} -type f | git check-ignore --stdin | sort)'
123
+ ])
124
+ proc = cli2.Proc("bash", "-c", cmd).wait()
125
+
126
+ if proc.rc != 0:
127
+ raise RuntimeError(
128
+ f"Command failed with return code {proc.rc}: {proc.stderr}"
129
+ )
130
+
131
+ files = []
132
+ for line in proc.stdout.splitlines():
133
+ if not line.strip():
134
+ continue
135
+
136
+ filepath = (base_path / line.strip()).resolve()
137
+
138
+ if (
139
+ not self.glob_include and not self.glob_exclude
140
+ ) or self._matches_filters(filepath):
141
+ files.append(filepath)
142
+ if self.file_callback:
143
+ self.file_callback(filepath)
144
+
145
+ return files
146
+
147
+ def dirs(self, directory=None):
148
+ """
149
+ List directories not ignored by git, applying filters and callback.
150
+
151
+ :param directory: Directory to start search from (defaults to root if
152
+ not specified)
153
+ :type directory: str or pathlib.Path or None
154
+ :return: List of Path objects for directories not ignored by git that
155
+ match filters
156
+ :rtype: list
157
+ :raises RuntimeError: If the git command fails
158
+ """
159
+ base_path = Path(directory).resolve() if directory else self.root
160
+
161
+ cmd = ' '.join([
162
+ f'comm -23 <(find {base_path} -type d | sort)',
163
+ f'<(find {base_path} -type d | git check-ignore --stdin | sort)'
164
+ ])
165
+ proc = cli2.Proc("bash", "-c", cmd).wait()
166
+
167
+ if proc.rc != 0:
168
+ raise RuntimeError(
169
+ f"Command failed with return code {proc.rc}: {proc.stderr}"
170
+ )
171
+
172
+ dirs = []
173
+ for line in proc.stdout.splitlines():
174
+ if not line.strip():
175
+ continue
176
+
177
+ dirpath = (base_path / line.strip()).resolve()
178
+
179
+ if dirpath == base_path:
180
+ continue
181
+
182
+ if (
183
+ not self.glob_include and not self.glob_exclude
184
+ ) or self._matches_filters(dirpath):
185
+ dirs.append(dirpath)
186
+ if self.file_callback:
187
+ self.file_callback(dirpath)
188
+
189
+ return dirs
@@ -3,6 +3,7 @@ import os
3
3
  import shlex
4
4
  import subprocess
5
5
  import tempfile
6
+ from pathlib import Path
6
7
 
7
8
  from .log import log
8
9
 
@@ -49,32 +50,47 @@ def editor(content=None):
49
50
 
50
51
  Like git rebase -i does!
51
52
 
52
- :param content: Initial content if any
53
+ - If a file path is given, edit in place.
54
+ - Otherwise, write to a temporary file.
55
+ - Anyway: return the written contents.
56
+
57
+ :param content: Initial content if any, or a file path
53
58
  :return: The edited content after $EDITOR exit
54
59
  """
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
60
  editor = os.getenv('EDITOR', 'vim')
62
61
 
62
+ if Path(content).exists():
63
+ # open directly on target path
64
+ filepath = content
65
+ tmp = None
66
+ else:
67
+ tmp = tempfile.NamedTemporaryFile(
68
+ mode='w+',
69
+ delete=False,
70
+ suffix=".txt",
71
+ )
72
+ with tmp as f:
73
+ f.write(content)
74
+ f.flush()
75
+ filepath = f.name
76
+
77
+ command = f"{editor} {shlex.quote(str(filepath))}"
78
+
63
79
  try:
64
- command = f"{editor} {shlex.quote(filepath)}"
65
80
  subprocess.run(shlex.split(command), check=True)
66
-
67
- with open(filepath, 'r') as f:
68
- content = f.read()
69
- return content
70
81
  except subprocess.CalledProcessError as e:
71
82
  log.error(f"Error running Vim: {e}")
72
83
  return None
73
84
  except FileNotFoundError:
74
85
  log.warn(f"Temporary file gone?? {filepath}")
75
86
  return None
87
+ else:
88
+ with open(filepath, 'r') as f:
89
+ content = f.read()
90
+ return content
76
91
  finally:
77
- try:
78
- os.remove(filepath)
79
- except OSError as e:
80
- log.warn(f"Error deleting temporary file {filepath}: {e}")
92
+ if tmp:
93
+ try:
94
+ os.remove(filepath)
95
+ except OSError as e:
96
+ log.warn(f"Error deleting temporary file {filepath}: {e}")
@@ -0,0 +1,302 @@
1
+ """
2
+ Asyncio subprocess wrapper featuring:
3
+
4
+ - Capture + live logging of stdout/stderr
5
+ - ANSI escape code cleaning for captured output: print colored output for
6
+ humans, have clean output in a variable for processing, log, cache... and
7
+ sending to LLMs!
8
+ - Separate start/wait methods for process control
9
+
10
+ Example usage:
11
+
12
+ .. code-block:: python
13
+
14
+ # pass shell command in a string for convenience
15
+ proc = cli2.Proc('foo bar')
16
+
17
+ # or as list, better when building commands
18
+ proc = cli2.Proc('foo', 'bar')
19
+
20
+ # run in sync mode (ie. for jinja2)
21
+ proc.wait()
22
+
23
+ # OR run in async loop
24
+ await proc.waita()
25
+
26
+ # You can chain
27
+ proc = cli2.Proc('hi').wait()
28
+ proc = await cli2.Proc('hi').waita()
29
+
30
+ .. note:: There are also start functions, sync and async, in case you want to
31
+ start the proc and wait later.
32
+ """
33
+ import asyncio
34
+ import os
35
+ import shlex
36
+ import re
37
+ import subprocess
38
+
39
+ from .log import log
40
+
41
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
42
+
43
+
44
+ class Proc:
45
+ """
46
+ Asynchronous subprocess manager with advanced IO handling.
47
+
48
+ .. py:attribute:: args
49
+
50
+ Full command arguments list used to launch the process
51
+
52
+ .. py:attribute:: rc
53
+
54
+ Return Code: process exit code (available after process completes)
55
+
56
+ .. py:attribute:: out
57
+
58
+ Combined cleaned output with ANSI escape codes removed.
59
+
60
+ .. py:attribute:: out_ansi
61
+
62
+ Combined stdout/stderr output with ANSI codes preserved.
63
+
64
+ .. py:attribute:: stdout
65
+
66
+ Cleaned stdout output with ANSI escape codes removed.
67
+
68
+ .. py:attribute:: stderr
69
+
70
+ Cleaned stdout output with ANSI escape codes removed.
71
+
72
+ .. py:attribute:: stdout_ansi
73
+
74
+ Stdout output with ANSI escape codes preserved.
75
+
76
+ .. py:attribute:: stderr_ansi
77
+
78
+ Stderr output with ANSI escape codes preserved.
79
+ """
80
+ def __init__(self, cmd, *args, quiet=False, inherit=True, timeout=None,
81
+ **env):
82
+ """
83
+ :param cmd: Command string (will shlex split) or initial argument
84
+ :param args: Additional command arguments
85
+ :param quiet: Suppress live output printing (default: False)
86
+ :param inherit: Inherit parent environment variables (default: True)
87
+ :param timeout: Maximum execution time in seconds (default: None)
88
+ :param env: Additional environment variables to set
89
+ :type env: Environment variables.
90
+ """
91
+ if args:
92
+ self.args = [cmd] + list(args)
93
+ else:
94
+ self.args = shlex.split(cmd)
95
+
96
+ self.quiet = quiet
97
+
98
+ self.env = dict()
99
+ if inherit:
100
+ self.env = os.environ.copy()
101
+ self.env.update(env)
102
+
103
+ self.out_raw = bytearray()
104
+ self.err_raw = bytearray()
105
+ self.raw = bytearray()
106
+
107
+ self.started = False
108
+ self.waited = False
109
+ self.timeout = timeout
110
+ self.rc = None
111
+ self.proc = None
112
+
113
+ def clone(self):
114
+ """
115
+ Create a new unstarted Proc instance with identical configuration.
116
+
117
+ :return: New Proc instance ready for execution
118
+ """
119
+ return type(self)(
120
+ *self.args, quiet=self.quiet, inherit=True, timeout=self.timeout,
121
+ **self.env
122
+ )
123
+
124
+ @property
125
+ def cmd(self):
126
+ """
127
+ Get/set the command as a shell-joinable string.
128
+
129
+ :getter: Returns shell-escaped command string
130
+ :setter: Parses and updates internal args list
131
+ :type: str
132
+ """
133
+ return shlex.join(self.args)
134
+
135
+ @cmd.setter
136
+ def cmd(self, value):
137
+ self.args = shlex.split(value)
138
+
139
+ async def starta(self):
140
+ """
141
+ Launch the subprocess asynchronously.
142
+
143
+ :return: Self reference for method chaining
144
+ :raises RuntimeError: If process is already started
145
+ """
146
+ if self.started:
147
+ raise RuntimeError("Process already started")
148
+
149
+ if not self.quiet:
150
+ log.debug('cmd', cmd=self.cmd)
151
+
152
+ self.proc = await asyncio.create_subprocess_exec(
153
+ *[str(arg) for arg in self.args],
154
+ stdin=asyncio.subprocess.PIPE,
155
+ stdout=asyncio.subprocess.PIPE,
156
+ stderr=asyncio.subprocess.PIPE,
157
+ env={str(k): str(v) for k, v in self.env.items()},
158
+ )
159
+ self.started = True
160
+
161
+ self.stdout_task = asyncio.create_task(
162
+ self._handle_output(self.proc.stdout, 1)
163
+ )
164
+ self.stderr_task = asyncio.create_task(
165
+ self._handle_output(self.proc.stderr, 2)
166
+ )
167
+ return self
168
+
169
+ async def waita(self):
170
+ """
171
+ Wait for process completion with timeout handling.
172
+
173
+ Terminates process if timeout occurs. Gathers all output streams.
174
+
175
+ :return: Self reference for method chaining
176
+ """
177
+ if not self.started:
178
+ await self.starta()
179
+
180
+ try:
181
+ if self.timeout:
182
+ await asyncio.wait_for(self.proc.wait(), timeout=self.timeout)
183
+ else:
184
+ await self.proc.wait()
185
+ except asyncio.TimeoutError:
186
+ print(f"Process timed out after {self.timeout}s")
187
+ self.proc.terminate()
188
+ await self.proc.wait()
189
+
190
+ await asyncio.gather(self.stdout_task, self.stderr_task)
191
+ self.rc = self.proc.returncode
192
+ self.waited = True
193
+ return self
194
+
195
+ async def _handle_output(self, stream, fd):
196
+ """
197
+ Internal method for stream handling.
198
+
199
+ :param stream: Output stream to monitor
200
+ :type stream: asyncio.StreamReader
201
+ :param fd: Stream identifier (1=stdout, 2=stderr)
202
+ :type fd: int
203
+ """
204
+ while True:
205
+ line = await stream.readline()
206
+ if not line: # EOF
207
+ break
208
+
209
+ decoded_line = line.decode().rstrip()
210
+ if fd == 1: # stdout
211
+ self.out_raw.extend(line)
212
+ elif fd == 2: # stderr
213
+ self.err_raw.extend(line)
214
+ self.raw.extend(line)
215
+
216
+ if not self.quiet:
217
+ print(decoded_line)
218
+
219
+ def start(self):
220
+ """
221
+ Start the subprocess synchronously without waiting for output.
222
+ """
223
+ if self.started:
224
+ raise RuntimeError("Process already started")
225
+
226
+ if not self.quiet:
227
+ log.debug('cmd', cmd=self.cmd)
228
+
229
+ self.proc = subprocess.Popen(
230
+ self.args,
231
+ stdin=subprocess.PIPE,
232
+ stdout=subprocess.PIPE,
233
+ stderr=subprocess.PIPE,
234
+ env=self.env,
235
+ universal_newlines=True
236
+ )
237
+ self.started = True
238
+ # Do NOT start output handling here; defer to wait
239
+ return self
240
+
241
+ def wait(self):
242
+ """
243
+ Wait for process completion synchronously with timeout handling.
244
+ Collects output streams after waiting.
245
+ """
246
+ if not self.started:
247
+ self.start()
248
+
249
+ try:
250
+ if self.timeout:
251
+ self.rc = self.proc.wait(timeout=self.timeout)
252
+ else:
253
+ self.rc = self.proc.wait()
254
+ except subprocess.TimeoutExpired:
255
+ print(f"Process timed out after {self.timeout}s")
256
+ self.proc.terminate()
257
+ try:
258
+ # Grace period for termination
259
+ self.rc = self.proc.wait(timeout=1)
260
+ except subprocess.TimeoutExpired:
261
+ self.proc.kill()
262
+ self.rc = self.proc.wait() # Final wait after kill
263
+
264
+ # Collect output after process has finished or been terminated
265
+ stdout, stderr = self.proc.communicate()
266
+ if stdout:
267
+ self.out_raw.extend(stdout.encode())
268
+ self.raw.extend(stdout.encode())
269
+ if not self.quiet:
270
+ print(stdout.rstrip())
271
+ if stderr:
272
+ self.err_raw.extend(stderr.encode())
273
+ self.raw.extend(stderr.encode())
274
+ if not self.quiet:
275
+ print(stderr.rstrip())
276
+
277
+ self.waited = True
278
+ return self
279
+
280
+ @property
281
+ def stdout_ansi(self):
282
+ return self.out_raw.decode().rstrip()
283
+
284
+ @property
285
+ def stderr_ansi(self):
286
+ return self.err_raw.decode().rstrip()
287
+
288
+ @property
289
+ def out_ansi(self):
290
+ return self.raw.decode().rstrip()
291
+
292
+ @property
293
+ def stdout(self):
294
+ return ansi_escape.sub('', self.stdout_ansi)
295
+
296
+ @property
297
+ def stderr(self):
298
+ return ansi_escape.sub('', self.stderr_ansi)
299
+
300
+ @property
301
+ def out(self):
302
+ return ansi_escape.sub('', self.out_ansi)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cli2
3
- Version: 5.2.1rc1
3
+ Version: 5.2.3
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,6 +63,7 @@ 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
+ - **AI CLI with prompt2**
67
+ - **AI coding with code2** (TBA)
67
68
 
68
69
  `Documentation available on RTFD <https://cli2.rtfd.io>`_.
@@ -10,12 +10,14 @@ cli2/colors.py
10
10
  cli2/configuration.py
11
11
  cli2/decorators.py
12
12
  cli2/display.py
13
+ cli2/find.py
13
14
  cli2/interactive.py
14
15
  cli2/lock.py
15
16
  cli2/log.py
16
17
  cli2/mask.py
17
18
  cli2/node.py
18
19
  cli2/notlevenshtein.py
20
+ cli2/proc.py
19
21
  cli2/sphinx.py
20
22
  cli2/table.py
21
23
  cli2/test.py
@@ -45,6 +47,7 @@ tests/test_configuration.py
45
47
  tests/test_decorators.py
46
48
  tests/test_display.py
47
49
  tests/test_entry_point.py
50
+ tests/test_find.py
48
51
  tests/test_group.py
49
52
  tests/test_inject.py
50
53
  tests/test_interactive.py
@@ -53,6 +56,7 @@ tests/test_log.py
53
56
  tests/test_mask.py
54
57
  tests/test_node.py
55
58
  tests/test_notlevenshtein.py
59
+ tests/test_proc.py
56
60
  tests/test_prompt2.py
57
61
  tests/test_restful.py
58
62
  tests/test_table.py
@@ -44,7 +44,7 @@ from setuptools import setup
44
44
 
45
45
  setup(
46
46
  name='cli2',
47
- version='5.2.1rc1',
47
+ version='5.2.3',
48
48
  setup_requires='setupmeta',
49
49
  packages=['cli2'],
50
50
  install_requires=[
@@ -4,21 +4,6 @@ import pytest
4
4
  from unittest import mock
5
5
 
6
6
 
7
- def test_async_run_noloop():
8
- async def task():
9
- await asyncio.sleep(.01)
10
-
11
- cli2.async_run(task())
12
-
13
-
14
- @pytest.mark.asyncio
15
- async def test_async_run_loop():
16
- async def task():
17
- await asyncio.sleep(.01)
18
-
19
- cli2.async_run(task())
20
-
21
-
22
7
  @pytest.mark.asyncio
23
8
  async def test_queue_basic():
24
9
  async def task(i):
@@ -0,0 +1,131 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ import os
4
+ from cli2 import Find
5
+
6
+ @pytest.fixture
7
+ def temp_git_repo(tmp_path):
8
+ """Create a temporary git repository with test files."""
9
+ # Initialize git repo
10
+ os.system(f"git init {tmp_path}")
11
+
12
+ # Create some test files and directories
13
+ (tmp_path / "src").mkdir()
14
+ (tmp_path / "src" / "main.py").write_text("main content")
15
+ (tmp_path / "src" / "utils.py").write_text("utils content")
16
+ (tmp_path / "tests").mkdir()
17
+ (tmp_path / "tests" / "test_main.py").write_text("test content")
18
+ (tmp_path / ".gitignore").write_text("*.pyc\n__pycache__")
19
+
20
+ # Add files to git
21
+ os.system(f"cd {tmp_path} && git add . && git commit -m 'Initial commit'")
22
+
23
+ return tmp_path
24
+
25
+ def test_basic_initialization():
26
+ """Test basic Find initialization."""
27
+ finder = Find()
28
+ assert isinstance(finder.root, Path)
29
+ assert finder.root.is_dir()
30
+ assert finder.glob_include == []
31
+ assert finder.glob_exclude == []
32
+ assert finder.file_callback is None
33
+
34
+ def test_custom_root_initialization(temp_git_repo):
35
+ """Test initialization with custom root."""
36
+ finder = Find(root=temp_git_repo)
37
+ assert finder.root == temp_git_repo.resolve()
38
+
39
+ def test_files_listing(temp_git_repo):
40
+ """Test basic file listing."""
41
+ finder = Find(root=temp_git_repo)
42
+ files = finder.files()
43
+
44
+ file_names = {f.name for f in files}
45
+ assert "main.py" in file_names
46
+ assert "utils.py" in file_names
47
+ assert "test_main.py" in file_names
48
+ assert ".gitignore" in file_names
49
+
50
+ def test_dirs_listing(temp_git_repo):
51
+ """Test basic directory listing."""
52
+ finder = Find(root=temp_git_repo)
53
+ dirs = finder.dirs()
54
+
55
+ dir_names = {d.name for d in dirs}
56
+ assert "src" in dir_names
57
+ assert "tests" in dir_names
58
+
59
+ def test_glob_include_filter(temp_git_repo):
60
+ """Test glob include filtering."""
61
+ finder = Find(root=temp_git_repo, glob_include=["*.py"])
62
+ files = finder.files()
63
+
64
+ file_names = {f.name for f in files}
65
+ assert "main.py" in file_names
66
+ assert "utils.py" in file_names
67
+ assert "test_main.py" in file_names
68
+ assert ".gitignore" not in file_names
69
+
70
+ def test_glob_exclude_filter(temp_git_repo):
71
+ """Test glob exclude filtering."""
72
+ finder = Find(root=temp_git_repo, glob_exclude=["*test*"])
73
+ files = finder.files()
74
+
75
+ file_names = {f.name for f in files}
76
+ assert "main.py" in file_names
77
+ assert "utils.py" in file_names
78
+ assert "test_main.py" not in file_names
79
+
80
+ def test_file_callback(temp_git_repo):
81
+ """Test file callback functionality."""
82
+ called_paths = []
83
+ def callback(filepath):
84
+ called_paths.append(filepath)
85
+
86
+ finder = Find(root=temp_git_repo, file_callback=callback)
87
+ files = finder.files()
88
+
89
+ assert len(called_paths) == len(files)
90
+ assert set(called_paths) == set(files)
91
+
92
+ def test_custom_directory_search(temp_git_repo):
93
+ """Test searching from a specific directory."""
94
+ finder = Find(root=temp_git_repo)
95
+ files = finder.files(directory=temp_git_repo / "src")
96
+
97
+ file_names = {f.name for f in files}
98
+ assert "main.py" in file_names
99
+ assert "utils.py" in file_names
100
+ assert "test_main.py" not in file_names
101
+
102
+ def test_error_handling(temp_git_repo, monkeypatch):
103
+ """Test error handling when git command fails."""
104
+ # Mock cli2.Proc to simulate git command failure
105
+ class MockProc:
106
+ def __init__(self, *args, **kwargs):
107
+ pass
108
+ def wait(self):
109
+ return self
110
+ rc = 1
111
+ stderr = "Git error"
112
+ stdout = ""
113
+
114
+ monkeypatch.setattr("cli2.Proc", MockProc)
115
+
116
+ finder = Find(root=temp_git_repo)
117
+ with pytest.raises(RuntimeError, match="Command failed with return code 1: Git error"):
118
+ finder.files()
119
+
120
+ def test_matches_filters(temp_git_repo):
121
+ """Test the _matches_filters method."""
122
+ finder = Find(
123
+ root=temp_git_repo,
124
+ glob_include=["*.py"],
125
+ glob_exclude=["*test*"]
126
+ )
127
+
128
+ assert finder._matches_filters(temp_git_repo / "src" / "main.py")
129
+ assert finder._matches_filters(temp_git_repo / "src" / "utils.py")
130
+ assert not finder._matches_filters(temp_git_repo / "tests" / "test_main.py")
131
+ assert not finder._matches_filters(temp_git_repo / ".gitignore")
@@ -0,0 +1,150 @@
1
+ import cli2
2
+ import os
3
+ import pytest
4
+
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_proc_init_with_command_string():
8
+ proc = cli2.Proc("echo hello")
9
+ assert proc.args == ["echo", "hello"]
10
+ assert proc.quiet is False
11
+ assert proc.timeout is None
12
+ assert proc.env == os.environ
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_proc_init_with_command_and_args():
17
+ proc = cli2.Proc("echo", "hello", "world")
18
+ assert proc.args == ["echo", "hello", "world"]
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_proc_init_with_quiet():
23
+ proc = cli2.Proc("echo hello", quiet=True)
24
+ assert proc.quiet is True
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_proc_init_with_timeout():
29
+ proc = cli2.Proc("echo hello", timeout=10)
30
+ assert proc.timeout == 10
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_proc_init_with_env():
35
+ proc = cli2.Proc("echo hello", MY_VAR="test")
36
+ assert proc.env["MY_VAR"] == "test"
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_proc_clone():
41
+ proc = cli2.Proc("echo hello", quiet=True, timeout=10, MY_VAR="test")
42
+ cloned_proc = proc.clone()
43
+ assert cloned_proc.args == proc.args
44
+ assert cloned_proc.quiet == proc.quiet
45
+ assert cloned_proc.timeout == proc.timeout
46
+ assert cloned_proc.env == proc.env
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_proc_cmd_property():
51
+ proc = cli2.Proc("echo hello")
52
+ assert proc.cmd == "echo hello"
53
+ proc.cmd = "echo world"
54
+ assert proc.args == ["echo", "world"]
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_proc_start_and_waita():
59
+ proc = cli2.Proc("echo hello")
60
+ await proc.starta()
61
+ await proc.waita()
62
+ assert proc.started is True
63
+ assert proc.waited is True
64
+ assert proc.rc == 0
65
+ assert proc.out == "hello"
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_proc_start_and_wait_with_timeout():
70
+ proc = cli2.Proc("sleep 2", timeout=1)
71
+ await proc.starta()
72
+ await proc.waita()
73
+ assert proc.rc != 0 # Should be terminated due to timeout
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_proc_output_properties():
78
+ proc = cli2.Proc("echo hello")
79
+ await proc.starta()
80
+ await proc.waita()
81
+ assert proc.stdout == "hello"
82
+ assert proc.stderr == ""
83
+ assert proc.out == "hello"
84
+ assert proc.stdout_ansi == "hello"
85
+ assert proc.stderr_ansi == ""
86
+ assert proc.out_ansi == "hello"
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_proc_with_stderr():
91
+ proc = cli2.Proc("bash", "-c", "echo error >&2 ")
92
+ await proc.starta()
93
+ await proc.waita()
94
+ assert proc.stdout == ""
95
+ assert proc.stderr == "error"
96
+ assert proc.out == "error"
97
+ assert proc.stdout_ansi == ""
98
+ assert proc.stderr_ansi == "error"
99
+ assert proc.out_ansi == "error"
100
+
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_proc_with_ansi_codes():
104
+ proc = cli2.Proc("echo -e '\033[31mred\033[0m'")
105
+ await proc.starta()
106
+ await proc.waita()
107
+ assert proc.stdout == "red"
108
+ assert proc.stdout_ansi == "\x1b[31mred\x1b[0m"
109
+
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_proc_quiet_mode():
113
+ proc = cli2.Proc("echo hello", quiet=True)
114
+ await proc.starta()
115
+ await proc.waita()
116
+ assert proc.out == "hello"
117
+
118
+
119
+ def test_proc_start_and_wait():
120
+ proc = cli2.Proc("echo hello").wait()
121
+ assert proc.started is True
122
+ assert proc.waited is True
123
+ assert proc.rc == 0
124
+ assert proc.out == "hello"
125
+
126
+
127
+ def test_proc_start_and_wait_with_timeout():
128
+ proc = cli2.Proc("sleep 2", timeout=1).wait()
129
+ assert proc.rc != 0 # Should be terminated due to timeout
130
+
131
+
132
+ def test_proc_start_and_wait_with_stderr():
133
+ proc = cli2.Proc("bash", "-c", "echo error >&2").wait()
134
+ assert proc.stdout == ""
135
+ assert proc.stderr == "error"
136
+ assert proc.out == "error"
137
+ assert proc.stdout_ansi == ""
138
+ assert proc.stderr_ansi == "error"
139
+ assert proc.out_ansi == "error"
140
+
141
+
142
+ def test_proc_start_and_wait_with_ansi_codes():
143
+ proc = cli2.Proc("echo -e '\033[31mred\033[0m'").wait()
144
+ assert proc.stdout == "red"
145
+ assert proc.stdout_ansi == "\x1b[31mred\x1b[0m"
146
+
147
+
148
+ def test_proc_start_and_wait_quiet_mode():
149
+ proc = cli2.Proc("echo hello", quiet=True).wait()
150
+ assert proc.out == "hello"
@@ -7,21 +7,52 @@ from unittest import mock
7
7
  from prompt2 import cli, Model, Prompt
8
8
 
9
9
 
10
+ def test_model():
11
+ os.environ['MODEL'] = 'litellm foo bar=1 foo=.2'
12
+ model = Model()
13
+ assert type(model.backend).__name__ == 'LiteLLMBackend'
14
+ assert model.backend.model_name == 'foo'
15
+ assert model.backend.model_kwargs['bar'] == 1
16
+ assert model.backend.model_kwargs['foo'] == .2
17
+ del os.environ['MODEL']
18
+
19
+ os.environ['MODEL_FOO'] = 'test a=b'
20
+ model = Model('foo')
21
+ assert type(model.backend).__name__ == 'LiteLLMBackend'
22
+ assert model.backend.model_name == 'test'
23
+ assert model.backend.model_kwargs['a'] == 'b'
24
+
25
+
26
+ def test_prompt(user, local):
27
+ with user.open('w') as f:
28
+ f.write('hello')
29
+
30
+ args = ['user', str(user), user]
31
+ for arg in args:
32
+ prompt = Prompt(arg)
33
+ assert prompt.path == user, arg
34
+ assert prompt.content == 'hello', arg
35
+ assert prompt.name == 'user'
36
+
37
+
10
38
  def test_paths():
11
- assert cli.cli('paths') == [
12
- str(Prompt.LOCAL_PATH),
13
- str(Prompt.USER_PATH),
14
- ]
39
+ paths = cli.cli('paths')
40
+ assert paths[0] == str(Prompt.local_path)
41
+ assert paths[1] == str(Prompt.user_path)
15
42
 
16
43
 
17
44
  @pytest.fixture
18
- def user():
19
- return Path(os.getenv('PROMPT2_USER_PATH')) / 'user.txt'
45
+ def user(prompt2_env):
46
+ path = Path(prompt2_env.get('PROMPT2_USER_PATH'))
47
+ path.mkdir(parents=True, exist_ok=True)
48
+ return path / 'user.txt'
20
49
 
21
50
 
22
51
  @pytest.fixture
23
- def local():
24
- return Path(os.getenv('PROMPT2_LOCAL_PATH')) / 'local.txt'
52
+ def local(prompt2_env):
53
+ path = Path(prompt2_env.get('PROMPT2_LOCAL_PATH'))
54
+ path.mkdir(parents=True, exist_ok=True)
55
+ return path / 'local.txt'
25
56
 
26
57
 
27
58
  @pytest.fixture
@@ -39,10 +70,8 @@ def kwargs(prompt2_env, user, local):
39
70
 
40
71
  @pytest.mark.asyncio
41
72
  async def test_python(prompt2_env):
42
- model = Model.get()
43
-
44
- prompt = Prompt()
45
- prompt.parts.append('make a hello world in python')
73
+ model = Model()
74
+ prompt = Prompt(content='make a hello world in python')
46
75
  result = await model(prompt)
47
76
  assert 'To run this:' in result
48
77
  result = await model(prompt, 'wholefile')
@@ -67,6 +96,32 @@ def test_parsers(kwargs):
67
96
  )
68
97
 
69
98
 
99
+ def test_ask(kwargs):
100
+ autotest(
101
+ 'tests/prompt2/test_ask.txt',
102
+ 'prompt2 ask Write hello world in python',
103
+ **kwargs,
104
+ )
105
+
106
+
107
+ def test_command():
108
+ from prompt2.cli import PromptCommand
109
+ def test(model=None, parser=None):
110
+ return model, parser
111
+ model, parser = PromptCommand(test)()
112
+ assert type(model.backend).__name__ == 'LiteLLMBackend'
113
+ assert model.backend.model_name == Model.default
114
+ assert not parser
115
+
116
+ os.environ['MODEL_LOL'] = 'foo temperature=test'
117
+ model, parser = PromptCommand(test)('lol')
118
+ assert model.backend.model_name == 'foo'
119
+ assert model.backend.model_kwargs == dict(temperature='test')
120
+
121
+ model, parser = PromptCommand(test)('parser=wholefile')
122
+ assert type(parser).__name__.lower() == 'wholefile'
123
+
124
+
70
125
  def test_crud(user, kwargs):
71
126
  autotest(
72
127
  'tests/prompt2/test_edit_user.txt',
@@ -142,3 +197,14 @@ def test_crud(user, kwargs):
142
197
  'prompt2 send user wholefile',
143
198
  **kwargs,
144
199
  )
200
+ autotest(
201
+ 'tests/prompt2/test_send_code_failparser.txt',
202
+ 'prompt2 send user foeuau',
203
+ **kwargs,
204
+ )
205
+ os.environ['MODEL_FOO'] = 'test a=b'
206
+ autotest(
207
+ 'tests/prompt2/test_send_code_failmodel.txt',
208
+ 'prompt2 send user model=oaeoeau',
209
+ **kwargs,
210
+ )
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
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