cli-ih 0.6.3__tar.gz → 0.7.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli_ih
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: A background command handler for python's command-line interface.
5
5
  Author-email: Hotment <michatchuplay@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -1,5 +1,6 @@
1
1
  from .client import InputHandler
2
2
  from .asyncClient import AsyncInputHandler
3
+ from .utils import safe_print
3
4
  import importlib.metadata
4
5
 
5
6
  try:
@@ -1,15 +1,22 @@
1
1
  from typing import Coroutine, Callable, Any
2
2
  from .exceptions import HandlerClosed
3
- import logging, warnings, asyncio, inspect, threading
3
+ from .utils import safe_print as print, register_handler
4
+ import logging, warnings, asyncio, inspect, threading, sys, shutil, msvcrt
4
5
 
5
6
  class AsyncInputHandler:
6
- def __init__(self, cursor = "", *, logger: logging.Logger | None = None, register_defaults: bool = True):
7
+ def __init__(self, cursor = "", thread_mode: bool = True, *, logger: logging.Logger | None = None, register_defaults: bool = True):
8
+ register_handler(self)
7
9
  self.commands = {}
8
10
  self.is_running = False
11
+ self.thread_mode = thread_mode
9
12
  self.cursor = f"{cursor.strip()} " if cursor else ""
10
13
  self.global_logger = logger if logger else None
11
14
  self.logger = logger.getChild("InputHandler") if logger else None
12
15
  self.register_defaults = register_defaults
16
+ self.print_lock = threading.Lock()
17
+ self.input_buffer = ""
18
+ self.processing_command = False
19
+
13
20
  if self.register_defaults:
14
21
  self.register_default_commands()
15
22
  else:
@@ -71,23 +78,71 @@ class AsyncInputHandler:
71
78
  return func
72
79
  return decorator
73
80
 
74
- async def start(self):
75
- """Starts the input handler loop in a separate thread if thread mode is enabled."""
81
+ def start(self):
82
+ """Starts the input handler loop. Runs in a thread if thread_mode is True, otherwise blocks."""
76
83
  self.is_running = True
84
+ if self.thread_mode:
85
+ thread = threading.Thread(target=self._start_thread, daemon=True)
86
+ thread.start()
87
+ else:
88
+ self._start_thread()
89
+
90
+ def _start_thread(self):
91
+ asyncio.run(self._run())
92
+
93
+ async def _run(self):
94
+ """Starts the input handler loop in a separate thread if thread mode is enabled."""
77
95
  loop = asyncio.get_running_loop()
78
96
  input_queue = asyncio.Queue()
79
97
 
80
98
  def _input_worker():
99
+ # Initial prompt
100
+ with self.print_lock:
101
+ sys.stdout.write(self.cursor)
102
+ sys.stdout.flush()
103
+
81
104
  while self.is_running:
82
105
  try:
83
- text = input(self.cursor)
84
- loop.call_soon_threadsafe(input_queue.put_nowait, text)
85
- except EOFError:
86
- loop.call_soon_threadsafe(input_queue.put_nowait, EOFError)
87
- break
106
+ if msvcrt.kbhit():
107
+ char = msvcrt.getwch()
108
+
109
+ if char == '\r': # Enter
110
+ with self.print_lock:
111
+ sys.stdout.write('\n')
112
+ sys.stdout.flush()
113
+ text = self.input_buffer
114
+ self.input_buffer = ""
115
+ loop.call_soon_threadsafe(input_queue.put_nowait, text)
116
+
117
+ elif char == '\x08': # Backspace
118
+ if len(self.input_buffer) > 0:
119
+ self.input_buffer = self.input_buffer[:-1]
120
+ with self.print_lock:
121
+ sys.stdout.write('\b \b')
122
+ sys.stdout.flush()
123
+
124
+ elif char == '\x03': # Ctrl+C
125
+ loop.call_soon_threadsafe(input_queue.put_nowait, KeyboardInterrupt)
126
+ break
127
+
128
+ else:
129
+ # Verify printable
130
+ if char.isprintable():
131
+ self.input_buffer += char
132
+ with self.print_lock:
133
+ sys.stdout.write(char)
134
+ sys.stdout.flush()
135
+ else:
136
+ pass
137
+ # Small sleep to prevent high CPU usage,
138
+ # but we are in a dedicated thread so it's fine-ish,
139
+ # actually explicit sleep is good.
140
+ import time
141
+ time.sleep(0.01)
142
+
88
143
  except Exception:
89
144
  break
90
-
145
+
91
146
  thread = threading.Thread(target=_input_worker, daemon=True)
92
147
  thread.start()
93
148
 
@@ -106,26 +161,32 @@ class AsyncInputHandler:
106
161
 
107
162
  try:
108
163
  sig = inspect.signature(func)
164
+ has_var_args = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values())
165
+ if has_var_args:
166
+ final_args = args
167
+ else:
168
+ params = [p for p in sig.parameters.values() if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY)]
169
+ final_args = args[:len(params)]
109
170
  if is_legacy:
110
- sig.bind(args)
171
+ sig.bind(final_args)
111
172
  else:
112
- sig.bind(*args)
173
+ sig.bind(*final_args)
113
174
  except TypeError as e:
114
- self.__warning(f"Argument error for legacy command '{name}': {e}")
175
+ self.__warning(f"Argument error for {"legacy " if is_legacy else ""}command '{name}': {e}")
115
176
  return
116
177
 
117
178
  try:
118
179
  if is_legacy:
119
180
  warnings.warn("This way of running commands id Deprecated. And should be changed to the new decorator way.", DeprecationWarning, 2)
120
181
  if inspect.iscoroutinefunction(func):
121
- await func(args)
182
+ await func(final_args)
122
183
  else:
123
- await asyncio.to_thread(func, args)
184
+ await asyncio.to_thread(func, final_args)
124
185
  else:
125
186
  if inspect.iscoroutinefunction(func):
126
- await func(*args)
187
+ await func(*final_args)
127
188
  else:
128
- await asyncio.to_thread(func, *args)
189
+ await asyncio.to_thread(func, *final_args)
129
190
 
130
191
  except HandlerClosed as e:
131
192
  raise e
@@ -146,6 +207,7 @@ class AsyncInputHandler:
146
207
  if not user_input:
147
208
  continue
148
209
 
210
+ self.processing_command = True
149
211
  cmdargs = user_input.split(' ')
150
212
  command_name = cmdargs[0].lower()
151
213
  args = cmdargs[1:]
@@ -153,6 +215,13 @@ class AsyncInputHandler:
153
215
  await _run_command(self.commands, command_name, args)
154
216
  else:
155
217
  self.__warning(f"Unknown command: '{command_name}'")
218
+ self.processing_command = False
219
+
220
+ # Reprompt after command execution
221
+ with self.print_lock:
222
+ sys.stdout.write(self.cursor)
223
+ sys.stdout.flush()
224
+
156
225
  except EOFError:
157
226
  self.__error("Input ended unexpectedly.")
158
227
  break
@@ -1,9 +1,11 @@
1
1
  from typing import Callable
2
2
  from .exceptions import HandlerClosed
3
- import logging, warnings
3
+ from .utils import safe_print as print, register_handler
4
+ import logging, warnings, sys, shutil, threading, msvcrt
4
5
 
5
6
  class InputHandler:
6
7
  def __init__(self, thread_mode = True, cursor = "", *, logger: logging.Logger | None = None, register_defaults: bool = True):
8
+ register_handler(self)
7
9
  self.commands = {}
8
10
  self.is_running = False
9
11
  self.thread_mode = thread_mode
@@ -12,6 +14,10 @@ class InputHandler:
12
14
  self.global_logger = logger if logger else None
13
15
  self.logger = logger.getChild("InputHandler") if logger else None
14
16
  self.register_defaults = register_defaults
17
+ self.print_lock = threading.Lock()
18
+ self.input_buffer = ""
19
+ self.processing_command = False
20
+
15
21
  if self.register_defaults:
16
22
  self.register_default_commands()
17
23
  else:
@@ -85,30 +91,37 @@ class InputHandler:
85
91
  func = command.get("cmd")
86
92
  is_legacy = command.get("legacy", False)
87
93
  if callable(func):
94
+ sig = inspect.signature(func)
95
+ has_var_args = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values())
96
+
97
+ if has_var_args:
98
+ final_args = args
99
+ else:
100
+ params = [p for p in sig.parameters.values() if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY)]
101
+ final_args = args[:len(params)]
102
+
88
103
  if is_legacy:
89
104
  try:
90
- sig = inspect.signature(func)
91
- sig.bind(args)
105
+ sig.bind(final_args)
92
106
  except TypeError as e:
93
107
  self.__warning(f"Argument error for legacy command '{name}': {e}")
94
108
  return
95
109
 
96
110
  try:
97
111
  warnings.warn("This way of running commands id Deprecated. And should be changed to the new decorator way.", DeprecationWarning, 2)
98
- func(args)
112
+ func(final_args)
99
113
  except HandlerClosed as e:
100
114
  raise e
101
115
  except Exception as e:
102
116
  self.__exeption(f"An error occurred in legacy command '{name}'", e)
103
117
  else:
104
118
  try:
105
- sig = inspect.signature(func)
106
- sig.bind(*args)
119
+ sig.bind(*final_args)
107
120
  except TypeError as e:
108
121
  self.__warning(f"Argument error for command '{name}': {e}")
109
122
  return
110
123
  try:
111
- func(*args)
124
+ func(*final_args)
112
125
  except HandlerClosed as e:
113
126
  raise e
114
127
  except Exception as e:
@@ -123,26 +136,65 @@ class InputHandler:
123
136
  """Continuously listens for user input and processes commands."""
124
137
  while self.is_running:
125
138
  try:
126
- user_input = input(self.cursor).strip()
127
- if not user_input:
128
- continue
129
-
130
- cmdargs = user_input.split(' ')
131
- command_name = cmdargs[0].lower()
132
- args = cmdargs[1:]
133
- if command_name in self.commands:
134
- _run_command(self.commands, command_name, args)
135
- else:
136
- self.__warning(f"Unknown command: '{command_name}'")
137
- except EOFError:
138
- self.__error("Input ended unexpectedly.")
139
- break
140
- except KeyboardInterrupt:
141
- self.__error("Input interrupted.")
142
- break
139
+ # Initial prompt
140
+ with self.print_lock:
141
+ sys.stdout.write(self.cursor)
142
+ sys.stdout.flush()
143
+
144
+ while self.is_running:
145
+ if msvcrt.kbhit():
146
+ char = msvcrt.getwch()
147
+
148
+ if char == '\r': # Enter
149
+ with self.print_lock:
150
+ sys.stdout.write('\n')
151
+ sys.stdout.flush()
152
+ text = self.input_buffer
153
+ self.input_buffer = ""
154
+
155
+ if text:
156
+ self.processing_command = True
157
+ cmdargs = text.split(' ')
158
+ command_name = cmdargs[0].lower()
159
+ args = cmdargs[1:]
160
+ if command_name in self.commands:
161
+ _run_command(self.commands, command_name, args)
162
+ else:
163
+ self.__warning(f"Unknown command: '{command_name}'")
164
+ self.processing_command = False
165
+
166
+ # Break inner loop to reprompt
167
+ break
168
+
169
+ elif char == '\x08': # Backspace
170
+ if len(self.input_buffer) > 0:
171
+ self.input_buffer = self.input_buffer[:-1]
172
+ with self.print_lock:
173
+ sys.stdout.write('\b \b')
174
+ sys.stdout.flush()
175
+
176
+ elif char == '\x03': # Ctrl+C
177
+ self.__error("Input interrupted.")
178
+ self.is_running = False
179
+ return
180
+
181
+ else:
182
+ # Verify printable
183
+ if char.isprintable():
184
+ self.input_buffer += char
185
+ with self.print_lock:
186
+ sys.stdout.write(char)
187
+ sys.stdout.flush()
188
+ else:
189
+ import time
190
+ time.sleep(0.01)
191
+
143
192
  except HandlerClosed:
144
193
  self.__info("Input Handler exited.")
145
194
  break
195
+ except Exception as e:
196
+ self.__exeption("Input loop error", e)
197
+ break
146
198
  self.is_running = False
147
199
  if self.thread_mode:
148
200
  self.thread = threading.Thread(target=_thread, daemon=True)
@@ -0,0 +1,51 @@
1
+ import shutil
2
+ import sys
3
+
4
+ _HANDLER = None
5
+
6
+ def register_handler(handler):
7
+ global _HANDLER
8
+ _HANDLER = handler
9
+
10
+ def safe_print(msg: str, cursor: str | None = None, input_buffer: str | None = None):
11
+ """
12
+ Prints a message safely while preserving the current input buffer and cursor.
13
+ This ensures logs appear above the input line appropriately.
14
+ """
15
+ if cursor is None and input_buffer is None and _HANDLER is not None:
16
+ lock = None
17
+ try:
18
+ # Try to acquire lock if available to prevent race conditions
19
+ lock = getattr(_HANDLER, "print_lock", None)
20
+ if lock:
21
+ lock.acquire()
22
+
23
+ cursor = str(getattr(_HANDLER, "cursor", ""))
24
+ input_buffer = str(getattr(_HANDLER, "input_buffer", ""))
25
+ processing_command = getattr(_HANDLER, "processing_command", False)
26
+
27
+ if processing_command:
28
+ cursor = ""
29
+ input_buffer = ""
30
+
31
+ _do_safe_print(msg, cursor, input_buffer)
32
+ finally:
33
+ if lock:
34
+ lock.release()
35
+ else:
36
+ # Fallback or explicit arguments
37
+ _do_safe_print(msg, cursor or "", input_buffer or "")
38
+
39
+ def _do_safe_print(msg: str, cursor: str, input_buffer: str):
40
+ try:
41
+ columns = shutil.get_terminal_size().columns
42
+ except:
43
+ columns = 80
44
+
45
+ # Clear line
46
+ sys.stdout.write('\r' + ' ' * (columns - 1) + '\r')
47
+ # Print message
48
+ sys.stdout.write(f"{msg}\n")
49
+ # Reprint cursor and buffer
50
+ sys.stdout.write(f"{cursor}{input_buffer}")
51
+ sys.stdout.flush()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli_ih
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: A background command handler for python's command-line interface.
5
5
  Author-email: Hotment <michatchuplay@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -4,6 +4,7 @@ cli_ih/__init__.py
4
4
  cli_ih/asyncClient.py
5
5
  cli_ih/client.py
6
6
  cli_ih/exceptions.py
7
+ cli_ih/utils.py
7
8
  cli_ih.egg-info/PKG-INFO
8
9
  cli_ih.egg-info/SOURCES.txt
9
10
  cli_ih.egg-info/dependency_links.txt
@@ -1,2 +1,3 @@
1
+ build
1
2
  cli_ih
2
3
  dist
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli_ih"
7
- version = "0.6.3"
7
+ version = "0.7.0"
8
8
  description = "A background command handler for python's command-line interface."
9
9
  readme = "README.md"
10
10
  requires-python = ">= 3.8"
File without changes
File without changes
File without changes