decterm 0.1.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.
- decterm-0.1.0/PKG-INFO +78 -0
- decterm-0.1.0/README.md +49 -0
- decterm-0.1.0/decterm/__init__.py +16 -0
- decterm-0.1.0/decterm/terminal.py +448 -0
- decterm-0.1.0/decterm.egg-info/PKG-INFO +78 -0
- decterm-0.1.0/decterm.egg-info/SOURCES.txt +10 -0
- decterm-0.1.0/decterm.egg-info/dependency_links.txt +1 -0
- decterm-0.1.0/decterm.egg-info/requires.txt +3 -0
- decterm-0.1.0/decterm.egg-info/top_level.txt +1 -0
- decterm-0.1.0/pyproject.toml +34 -0
- decterm-0.1.0/setup.cfg +4 -0
- decterm-0.1.0/setup.py +64 -0
decterm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: decterm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-platform terminal controllable via Python (and web)
|
|
5
|
+
Home-page: https://github.com/decrule/decterm
|
|
6
|
+
Author: decterm
|
|
7
|
+
Author-email: decrule@outlook.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: terminal,shell,cross-platform,automation
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: System :: Shells
|
|
22
|
+
Requires-Python: >=3.7
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Dynamic: author-email
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: requires-python
|
|
29
|
+
|
|
30
|
+
# decterm
|
|
31
|
+
|
|
32
|
+
Cross-platform terminal you can drive from Python. Run shell commands and get output, with or without a GUI.
|
|
33
|
+
|
|
34
|
+
- **No GUI** (default): headless, ideal for scripts and servers.
|
|
35
|
+
- **GUI mode**: live terminal window (tkinter) where you can type commands and see output.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install decterm
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from decterm import DecTerm
|
|
45
|
+
|
|
46
|
+
# Headless
|
|
47
|
+
dt = DecTerm()
|
|
48
|
+
print(dt.send("dir")) # or "ls" on Unix
|
|
49
|
+
print(dt.sends("cd /tmp", "pwd"))
|
|
50
|
+
dt.close()
|
|
51
|
+
|
|
52
|
+
# With GUI
|
|
53
|
+
dt = DecTerm(gui=True)
|
|
54
|
+
dt.send("echo Hello")
|
|
55
|
+
# Type commands in the window, then:
|
|
56
|
+
dt.close()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API (concise)
|
|
60
|
+
|
|
61
|
+
| Method | Description |
|
|
62
|
+
|--------|-------------|
|
|
63
|
+
| `DecTerm(gui=False, cwd=None, env=None)` | Start a shell. `gui=True` opens a terminal window. |
|
|
64
|
+
| `send(cmd, timeout=30)` | Run one command, return output. Times out for interactive programs (e.g. `python`). |
|
|
65
|
+
| `sends(*cmds, timeout=30)` | Run several commands in order; returns last command’s output. |
|
|
66
|
+
| `close()` | Stop the shell. |
|
|
67
|
+
|
|
68
|
+
Use `timeout=None` in `send()` to wait indefinitely.
|
|
69
|
+
|
|
70
|
+
## Requirements
|
|
71
|
+
|
|
72
|
+
- Python 3.7+
|
|
73
|
+
- Windows, macOS, or Linux
|
|
74
|
+
- No extra packages (uses only the standard library, including tkinter for GUI).
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
decterm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# decterm
|
|
2
|
+
|
|
3
|
+
Cross-platform terminal you can drive from Python. Run shell commands and get output, with or without a GUI.
|
|
4
|
+
|
|
5
|
+
- **No GUI** (default): headless, ideal for scripts and servers.
|
|
6
|
+
- **GUI mode**: live terminal window (tkinter) where you can type commands and see output.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install decterm
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from decterm import DecTerm
|
|
16
|
+
|
|
17
|
+
# Headless
|
|
18
|
+
dt = DecTerm()
|
|
19
|
+
print(dt.send("dir")) # or "ls" on Unix
|
|
20
|
+
print(dt.sends("cd /tmp", "pwd"))
|
|
21
|
+
dt.close()
|
|
22
|
+
|
|
23
|
+
# With GUI
|
|
24
|
+
dt = DecTerm(gui=True)
|
|
25
|
+
dt.send("echo Hello")
|
|
26
|
+
# Type commands in the window, then:
|
|
27
|
+
dt.close()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## API (concise)
|
|
31
|
+
|
|
32
|
+
| Method | Description |
|
|
33
|
+
|--------|-------------|
|
|
34
|
+
| `DecTerm(gui=False, cwd=None, env=None)` | Start a shell. `gui=True` opens a terminal window. |
|
|
35
|
+
| `send(cmd, timeout=30)` | Run one command, return output. Times out for interactive programs (e.g. `python`). |
|
|
36
|
+
| `sends(*cmds, timeout=30)` | Run several commands in order; returns last command’s output. |
|
|
37
|
+
| `close()` | Stop the shell. |
|
|
38
|
+
|
|
39
|
+
Use `timeout=None` in `send()` to wait indefinitely.
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Python 3.7+
|
|
44
|
+
- Windows, macOS, or Linux
|
|
45
|
+
- No extra packages (uses only the standard library, including tkinter for GUI).
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
decterm - Cross-platform terminal controllable via Python (and web).
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from decterm import DecTerm
|
|
6
|
+
|
|
7
|
+
dt = DecTerm()
|
|
8
|
+
result = dt.send('dir')
|
|
9
|
+
result = dt.sends('cd e', 'mkdir aaaa')
|
|
10
|
+
dt.close()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from decterm.terminal import DecTerm
|
|
14
|
+
|
|
15
|
+
__all__ = ["DecTerm"]
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DecTerm - Cross-platform programmatic terminal session.
|
|
3
|
+
|
|
4
|
+
Runs a persistent shell; on GUI systems a visible terminal could be shown,
|
|
5
|
+
on headless systems (e.g. CentOS server) it runs without a window.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
# We append this to each command and read until we see it (avoids prompt parsing).
|
|
16
|
+
_END_MARKER = "__DECTERM_END__"
|
|
17
|
+
_END_MARKER_BYTES = _END_MARKER.encode("utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_windows():
|
|
21
|
+
return sys.platform == "win32"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DecTerm:
|
|
25
|
+
"""
|
|
26
|
+
A persistent terminal session. Use send() / sends() to run commands,
|
|
27
|
+
close() to shut down the shell.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, gui=False, shell=None, cwd=None, env=None):
|
|
31
|
+
"""
|
|
32
|
+
Start a terminal (shell process).
|
|
33
|
+
|
|
34
|
+
:param gui: If True, show a GUI window with live output and an input box to send commands.
|
|
35
|
+
:param shell: Optional path to shell (default: cmd on Windows, bash on Unix).
|
|
36
|
+
:param cwd: Working directory (default: current).
|
|
37
|
+
:param env: Environment dict (default: inherit from os.environ).
|
|
38
|
+
"""
|
|
39
|
+
self._gui = gui
|
|
40
|
+
self._cwd = cwd or os.getcwd()
|
|
41
|
+
self._env = dict(env) if env is not None else dict(os.environ)
|
|
42
|
+
self._shell_cmd = shell
|
|
43
|
+
self._process = None
|
|
44
|
+
self._closed = False
|
|
45
|
+
self._result_for_send = None # Queue for send() to get result in GUI mode
|
|
46
|
+
self._command_lock = threading.Lock()
|
|
47
|
+
self._display_queue = queue.Queue() if gui else None
|
|
48
|
+
self._command_done_event = threading.Event() if gui else None
|
|
49
|
+
self._gui_thread = None
|
|
50
|
+
self._skip_next_result = False # discard next marker result (after timeout)
|
|
51
|
+
self._result_queue = None if gui else queue.Queue() # non-GUI: reader puts here
|
|
52
|
+
self._reader_thread = None # non-GUI reader
|
|
53
|
+
self._start()
|
|
54
|
+
|
|
55
|
+
def _start(self):
|
|
56
|
+
if _is_windows():
|
|
57
|
+
shell_cmd = self._shell_cmd or "cmd.exe"
|
|
58
|
+
creationflags = 0
|
|
59
|
+
if _is_windows() and not self._gui:
|
|
60
|
+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
61
|
+
self._process = subprocess.Popen(
|
|
62
|
+
[shell_cmd, "/Q", "/K"],
|
|
63
|
+
stdin=subprocess.PIPE,
|
|
64
|
+
stdout=subprocess.PIPE,
|
|
65
|
+
stderr=subprocess.STDOUT,
|
|
66
|
+
cwd=self._cwd,
|
|
67
|
+
env=self._env,
|
|
68
|
+
creationflags=creationflags,
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
self._process.stdin.write("chcp 65001 >nul\r\n".encode("utf-8"))
|
|
72
|
+
self._process.stdin.write("prompt \r\n".encode("utf-8"))
|
|
73
|
+
self._process.stdin.write(f"echo {_END_MARKER}\r\n".encode("utf-8"))
|
|
74
|
+
self._process.stdin.flush()
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
self._read_until_marker(self._process.stdout)
|
|
78
|
+
else:
|
|
79
|
+
shell_cmd = self._shell_cmd or "/bin/bash"
|
|
80
|
+
self._process = subprocess.Popen(
|
|
81
|
+
[shell_cmd, "-i"],
|
|
82
|
+
stdin=subprocess.PIPE,
|
|
83
|
+
stdout=subprocess.PIPE,
|
|
84
|
+
stderr=subprocess.STDOUT,
|
|
85
|
+
cwd=self._cwd,
|
|
86
|
+
env={**self._env, "PS1": ""},
|
|
87
|
+
)
|
|
88
|
+
self._process.stdin.write(f"echo {_END_MARKER}\n".encode("utf-8"))
|
|
89
|
+
self._process.stdin.flush()
|
|
90
|
+
self._read_until_marker(self._process.stdout)
|
|
91
|
+
|
|
92
|
+
if not self._gui and self._result_queue is not None:
|
|
93
|
+
self._reader_thread = threading.Thread(target=self._non_gui_reader_loop, daemon=True)
|
|
94
|
+
self._reader_thread.start()
|
|
95
|
+
|
|
96
|
+
if self._gui:
|
|
97
|
+
self._command_done_event.set()
|
|
98
|
+
self._reader_thread = threading.Thread(target=self._gui_reader_loop, daemon=True)
|
|
99
|
+
self._reader_thread.start()
|
|
100
|
+
self._gui_thread = threading.Thread(target=self._run_gui, daemon=True)
|
|
101
|
+
self._gui_thread.start()
|
|
102
|
+
# Let GUI and reader start
|
|
103
|
+
threading.Event().wait(0.3)
|
|
104
|
+
|
|
105
|
+
def _read_until_marker(self, stream):
|
|
106
|
+
"""Read from stream until we see _END_MARKER. Returns collected text."""
|
|
107
|
+
buf = bytearray()
|
|
108
|
+
while True:
|
|
109
|
+
chunk = stream.read(1)
|
|
110
|
+
if not chunk:
|
|
111
|
+
break
|
|
112
|
+
buf.extend(chunk)
|
|
113
|
+
if len(buf) >= len(_END_MARKER_BYTES) and bytes(buf[-len(_END_MARKER_BYTES):]) == _END_MARKER_BYTES:
|
|
114
|
+
break
|
|
115
|
+
if _END_MARKER_BYTES in buf:
|
|
116
|
+
break
|
|
117
|
+
return buf.decode("utf-8", errors="replace")
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _strip_output(out, raw_cmd=None):
|
|
121
|
+
"""Strip marker line, path prompt, echoed command from output."""
|
|
122
|
+
lines = out.replace("\r", "").splitlines()
|
|
123
|
+
result_lines = []
|
|
124
|
+
for line in lines:
|
|
125
|
+
if _END_MARKER in line:
|
|
126
|
+
break
|
|
127
|
+
if raw_cmd and raw_cmd in line and _END_MARKER in line:
|
|
128
|
+
continue
|
|
129
|
+
if raw_cmd and line.strip() == raw_cmd:
|
|
130
|
+
continue
|
|
131
|
+
result_lines.append(line)
|
|
132
|
+
result = "\n".join(result_lines).strip()
|
|
133
|
+
result = re.sub(r"^[A-Za-z]:\\[^\r\n]*>", "", result, count=1)
|
|
134
|
+
if result.startswith(">") and (len(result) == 1 or result[1] != ">"):
|
|
135
|
+
result = result[1:].lstrip()
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
def _non_gui_reader_loop(self):
|
|
139
|
+
"""Background: read until marker, put stripped output to _result_queue. For non-GUI mode."""
|
|
140
|
+
stream = self._process.stdout
|
|
141
|
+
buf = bytearray()
|
|
142
|
+
while not self._closed and self._process and self._process.poll() is None:
|
|
143
|
+
try:
|
|
144
|
+
chunk = stream.read(1)
|
|
145
|
+
except (OSError, ValueError):
|
|
146
|
+
break
|
|
147
|
+
if not chunk:
|
|
148
|
+
break
|
|
149
|
+
buf.extend(chunk)
|
|
150
|
+
if _END_MARKER_BYTES not in buf:
|
|
151
|
+
continue
|
|
152
|
+
idx = buf.decode("utf-8", errors="replace").find(_END_MARKER)
|
|
153
|
+
if idx < 0:
|
|
154
|
+
continue
|
|
155
|
+
before = buf[:idx].decode("utf-8", errors="replace")
|
|
156
|
+
buf.clear()
|
|
157
|
+
result = self._strip_output(before)
|
|
158
|
+
try:
|
|
159
|
+
self._result_queue.put(result)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
try:
|
|
163
|
+
self._result_queue.put(None)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def _gui_reader_loop(self):
|
|
168
|
+
"""Read process stdout in GUI mode; push chunks to display_queue and result to _result_for_send."""
|
|
169
|
+
stream = self._process.stdout
|
|
170
|
+
buf = bytearray()
|
|
171
|
+
last_displayed = 0
|
|
172
|
+
while self._process and self._process.poll() is None and not self._closed:
|
|
173
|
+
try:
|
|
174
|
+
chunk = stream.read(1)
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
break
|
|
177
|
+
if not chunk:
|
|
178
|
+
break
|
|
179
|
+
buf.extend(chunk)
|
|
180
|
+
try:
|
|
181
|
+
s = buf.decode("utf-8", errors="replace")
|
|
182
|
+
except Exception:
|
|
183
|
+
continue
|
|
184
|
+
if _END_MARKER in s:
|
|
185
|
+
idx = s.find(_END_MARKER)
|
|
186
|
+
before = s[:idx]
|
|
187
|
+
to_show = before[last_displayed:].replace("\r", "")
|
|
188
|
+
if to_show:
|
|
189
|
+
self._display_queue.put(to_show)
|
|
190
|
+
last_displayed = 0
|
|
191
|
+
buf.clear()
|
|
192
|
+
# Strip marker line and path prompt for result
|
|
193
|
+
lines = before.replace("\r", "").splitlines()
|
|
194
|
+
result_lines = [ln for ln in lines if _END_MARKER not in ln]
|
|
195
|
+
result = "\n".join(result_lines).strip()
|
|
196
|
+
result = re.sub(r"^[A-Za-z]:\\[^\r\n]*>", "", result, count=1)
|
|
197
|
+
if result.startswith(">") and (len(result) == 1 or result[1] != ">"):
|
|
198
|
+
result = result[1:].lstrip()
|
|
199
|
+
if getattr(self, "_skip_next_result", False):
|
|
200
|
+
self._skip_next_result = False
|
|
201
|
+
elif getattr(self, "_result_for_send", None) is not None:
|
|
202
|
+
try:
|
|
203
|
+
self._result_for_send.put(result)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
self._result_for_send = None
|
|
207
|
+
self._command_done_event.set()
|
|
208
|
+
else:
|
|
209
|
+
new_part = s[last_displayed:].replace("\r", "")
|
|
210
|
+
if new_part:
|
|
211
|
+
self._display_queue.put(new_part)
|
|
212
|
+
last_displayed = len(s)
|
|
213
|
+
self._display_queue.put(None) # EOF
|
|
214
|
+
|
|
215
|
+
def _run_gui(self):
|
|
216
|
+
"""Run the tkinter terminal window (in a thread)."""
|
|
217
|
+
try:
|
|
218
|
+
import tkinter as tk
|
|
219
|
+
from tkinter import scrolledtext
|
|
220
|
+
except ImportError:
|
|
221
|
+
self._display_queue.put("[GUI requires tkinter]\n")
|
|
222
|
+
return
|
|
223
|
+
root = tk.Tk()
|
|
224
|
+
root.title("DecTerm")
|
|
225
|
+
root.geometry("860x520")
|
|
226
|
+
root.minsize(480, 320)
|
|
227
|
+
|
|
228
|
+
# 终端风格配色:深色背景 + 浅色字
|
|
229
|
+
bg_dark = "#0d1117"
|
|
230
|
+
bg_input = "#161b22"
|
|
231
|
+
fg_main = "#e6edf3"
|
|
232
|
+
fg_prompt = "#7ee787" # 绿色提示符
|
|
233
|
+
border = "#30363d"
|
|
234
|
+
insert_bg = "#58a6ff"
|
|
235
|
+
|
|
236
|
+
root.configure(bg=bg_dark)
|
|
237
|
+
|
|
238
|
+
# 输出区:等宽字体、合适行距
|
|
239
|
+
out = scrolledtext.ScrolledText(
|
|
240
|
+
root,
|
|
241
|
+
wrap=tk.WORD,
|
|
242
|
+
font=("Consolas", 11),
|
|
243
|
+
state=tk.DISABLED,
|
|
244
|
+
bg=bg_dark,
|
|
245
|
+
fg=fg_main,
|
|
246
|
+
insertbackground=insert_bg,
|
|
247
|
+
selectbackground="#264f78",
|
|
248
|
+
selectforeground=fg_main,
|
|
249
|
+
relief=tk.FLAT,
|
|
250
|
+
padx=12,
|
|
251
|
+
pady=12,
|
|
252
|
+
borderwidth=0,
|
|
253
|
+
)
|
|
254
|
+
out.pack(fill=tk.BOTH, expand=True, padx=8, pady=(8, 0))
|
|
255
|
+
out.tag_configure("prompt", foreground=fg_prompt)
|
|
256
|
+
out.tag_configure("output", foreground=fg_main)
|
|
257
|
+
|
|
258
|
+
# 输入行:单独背景、绿色提示符
|
|
259
|
+
frame = tk.Frame(root, bg=bg_input, highlightbackground=border, highlightthickness=1)
|
|
260
|
+
frame.pack(fill=tk.X, padx=8, pady=8)
|
|
261
|
+
prompt_label = tk.Label(
|
|
262
|
+
frame, text="> ", font=("Consolas", 11), fg=fg_prompt, bg=bg_input
|
|
263
|
+
)
|
|
264
|
+
prompt_label.pack(side=tk.LEFT, padx=(12, 4), pady=10)
|
|
265
|
+
entry = tk.Entry(
|
|
266
|
+
frame,
|
|
267
|
+
font=("Consolas", 11),
|
|
268
|
+
bg=bg_dark,
|
|
269
|
+
fg=fg_main,
|
|
270
|
+
insertbackground=insert_bg,
|
|
271
|
+
relief=tk.FLAT,
|
|
272
|
+
highlightthickness=0,
|
|
273
|
+
)
|
|
274
|
+
entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 12), pady=10, ipady=6)
|
|
275
|
+
|
|
276
|
+
def append_output(text, tag=None):
|
|
277
|
+
if text is None:
|
|
278
|
+
return
|
|
279
|
+
out.config(state=tk.NORMAL)
|
|
280
|
+
if tag:
|
|
281
|
+
out.insert(tk.END, text, tag)
|
|
282
|
+
else:
|
|
283
|
+
out.insert(tk.END, text)
|
|
284
|
+
out.see(tk.END)
|
|
285
|
+
out.config(state=tk.DISABLED)
|
|
286
|
+
|
|
287
|
+
def pump_display():
|
|
288
|
+
try:
|
|
289
|
+
while True:
|
|
290
|
+
msg = self._display_queue.get_nowait()
|
|
291
|
+
append_output(msg)
|
|
292
|
+
if msg is None:
|
|
293
|
+
break
|
|
294
|
+
except queue.Empty:
|
|
295
|
+
pass
|
|
296
|
+
if not self._closed and self._process and self._process.poll() is None:
|
|
297
|
+
root.after(50, pump_display)
|
|
298
|
+
else:
|
|
299
|
+
try:
|
|
300
|
+
while True:
|
|
301
|
+
msg = self._display_queue.get(timeout=0.1)
|
|
302
|
+
append_output(msg)
|
|
303
|
+
if msg is None:
|
|
304
|
+
break
|
|
305
|
+
except (queue.Empty, queue.Full):
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
def on_send():
|
|
309
|
+
cmd = entry.get().strip()
|
|
310
|
+
if not cmd:
|
|
311
|
+
return
|
|
312
|
+
entry.delete(0, tk.END)
|
|
313
|
+
append_output("\n> " + cmd + "\n", "prompt")
|
|
314
|
+
threading.Thread(target=_user_send, args=(cmd,), daemon=True).start()
|
|
315
|
+
|
|
316
|
+
def _user_send(cmd):
|
|
317
|
+
with self._command_lock:
|
|
318
|
+
self._command_done_event.clear()
|
|
319
|
+
if self._closed or self._process is None or self._process.poll() is not None:
|
|
320
|
+
return
|
|
321
|
+
if _is_windows():
|
|
322
|
+
full = f"{cmd} & echo {_END_MARKER}\r\n"
|
|
323
|
+
else:
|
|
324
|
+
full = f"{cmd}; echo {_END_MARKER}\n"
|
|
325
|
+
try:
|
|
326
|
+
self._process.stdin.write(full.encode("utf-8"))
|
|
327
|
+
self._process.stdin.flush()
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
self._command_done_event.wait(timeout=60)
|
|
331
|
+
|
|
332
|
+
entry.bind("<Return>", lambda e: on_send())
|
|
333
|
+
entry.focus_set()
|
|
334
|
+
root.after(50, pump_display)
|
|
335
|
+
|
|
336
|
+
def on_closing():
|
|
337
|
+
self.close()
|
|
338
|
+
root.destroy()
|
|
339
|
+
|
|
340
|
+
root.protocol("WM_DELETE_WINDOW", on_closing)
|
|
341
|
+
try:
|
|
342
|
+
root.mainloop()
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
def send(self, command, timeout=30):
|
|
347
|
+
"""
|
|
348
|
+
Run one command in the terminal and return its output (stdout + stderr).
|
|
349
|
+
|
|
350
|
+
For interactive commands (e.g. python, bash) that never print the end marker,
|
|
351
|
+
send() will return after timeout seconds with a hint instead of blocking forever.
|
|
352
|
+
|
|
353
|
+
:param command: Command string (e.g. 'dir', 'ls -la').
|
|
354
|
+
:param timeout: Max seconds to wait for command output (default 30). Use None to wait forever.
|
|
355
|
+
:return: Output text of the command (string).
|
|
356
|
+
"""
|
|
357
|
+
if self._closed or self._process is None or self._process.poll() is not None:
|
|
358
|
+
raise RuntimeError("DecTerm is closed or not running")
|
|
359
|
+
raw = command.strip()
|
|
360
|
+
_timeout = timeout if timeout is not None else 1e9
|
|
361
|
+
|
|
362
|
+
if self._gui:
|
|
363
|
+
if self._skip_next_result:
|
|
364
|
+
self._skip_next_result = False
|
|
365
|
+
self._result_for_send = None
|
|
366
|
+
result_queue = queue.Queue()
|
|
367
|
+
with self._command_lock:
|
|
368
|
+
self._result_for_send = result_queue
|
|
369
|
+
self._command_done_event.clear()
|
|
370
|
+
if self._display_queue is not None:
|
|
371
|
+
self._display_queue.put("\n> " + raw + "\n")
|
|
372
|
+
if _is_windows():
|
|
373
|
+
full_cmd = f"{raw} & echo {_END_MARKER}\r\n"
|
|
374
|
+
else:
|
|
375
|
+
full_cmd = f"{raw}; echo {_END_MARKER}\n"
|
|
376
|
+
try:
|
|
377
|
+
self._process.stdin.write(full_cmd.encode("utf-8"))
|
|
378
|
+
self._process.stdin.flush()
|
|
379
|
+
except Exception:
|
|
380
|
+
self._result_for_send = None
|
|
381
|
+
return ""
|
|
382
|
+
try:
|
|
383
|
+
return result_queue.get(timeout=_timeout)
|
|
384
|
+
except queue.Empty:
|
|
385
|
+
self._skip_next_result = True
|
|
386
|
+
self._result_for_send = None
|
|
387
|
+
return "[DecTerm] Timeout - command may be interactive (e.g. python). Use GUI to interact or exit the subprocess."
|
|
388
|
+
|
|
389
|
+
# Non-GUI: discard stale result from previous timeout if any
|
|
390
|
+
if self._skip_next_result and self._result_queue is not None:
|
|
391
|
+
self._skip_next_result = False
|
|
392
|
+
try:
|
|
393
|
+
self._result_queue.get(timeout=0.5)
|
|
394
|
+
except queue.Empty:
|
|
395
|
+
pass
|
|
396
|
+
if _is_windows():
|
|
397
|
+
full_cmd = f"{raw} & echo {_END_MARKER}\r\n"
|
|
398
|
+
else:
|
|
399
|
+
full_cmd = f"{raw}; echo {_END_MARKER}\n"
|
|
400
|
+
try:
|
|
401
|
+
self._process.stdin.write(full_cmd.encode("utf-8"))
|
|
402
|
+
self._process.stdin.flush()
|
|
403
|
+
except Exception:
|
|
404
|
+
return ""
|
|
405
|
+
try:
|
|
406
|
+
result = self._result_queue.get(timeout=_timeout)
|
|
407
|
+
except queue.Empty:
|
|
408
|
+
self._skip_next_result = True
|
|
409
|
+
return "[DecTerm] Timeout - command may be interactive (e.g. python). Exit the subprocess (e.g. exit()) or use a longer timeout."
|
|
410
|
+
if result is None:
|
|
411
|
+
return ""
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
def sends(self, *commands, timeout=30):
|
|
415
|
+
"""
|
|
416
|
+
Run multiple commands in sequence in the same shell session.
|
|
417
|
+
Returns the output of the last command.
|
|
418
|
+
|
|
419
|
+
:param commands: One or more command strings.
|
|
420
|
+
:param timeout: Max seconds to wait per command (default 30). Passed to each send().
|
|
421
|
+
:return: Output of the last command (string).
|
|
422
|
+
"""
|
|
423
|
+
result = None
|
|
424
|
+
for cmd in commands:
|
|
425
|
+
result = self.send(cmd, timeout=timeout)
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
def close(self):
|
|
429
|
+
"""Close the terminal and terminate the shell process."""
|
|
430
|
+
if self._closed:
|
|
431
|
+
return
|
|
432
|
+
self._closed = True
|
|
433
|
+
if self._process and self._process.poll() is None:
|
|
434
|
+
try:
|
|
435
|
+
self._process.terminate()
|
|
436
|
+
self._process.wait(timeout=3)
|
|
437
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
438
|
+
try:
|
|
439
|
+
self._process.kill()
|
|
440
|
+
except OSError:
|
|
441
|
+
pass
|
|
442
|
+
self._process = None
|
|
443
|
+
|
|
444
|
+
def __enter__(self):
|
|
445
|
+
return self
|
|
446
|
+
|
|
447
|
+
def __exit__(self, *args):
|
|
448
|
+
self.close()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: decterm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-platform terminal controllable via Python (and web)
|
|
5
|
+
Home-page: https://github.com/decrule/decterm
|
|
6
|
+
Author: decterm
|
|
7
|
+
Author-email: decrule@outlook.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: terminal,shell,cross-platform,automation
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: System :: Shells
|
|
22
|
+
Requires-Python: >=3.7
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Dynamic: author-email
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: requires-python
|
|
29
|
+
|
|
30
|
+
# decterm
|
|
31
|
+
|
|
32
|
+
Cross-platform terminal you can drive from Python. Run shell commands and get output, with or without a GUI.
|
|
33
|
+
|
|
34
|
+
- **No GUI** (default): headless, ideal for scripts and servers.
|
|
35
|
+
- **GUI mode**: live terminal window (tkinter) where you can type commands and see output.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install decterm
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from decterm import DecTerm
|
|
45
|
+
|
|
46
|
+
# Headless
|
|
47
|
+
dt = DecTerm()
|
|
48
|
+
print(dt.send("dir")) # or "ls" on Unix
|
|
49
|
+
print(dt.sends("cd /tmp", "pwd"))
|
|
50
|
+
dt.close()
|
|
51
|
+
|
|
52
|
+
# With GUI
|
|
53
|
+
dt = DecTerm(gui=True)
|
|
54
|
+
dt.send("echo Hello")
|
|
55
|
+
# Type commands in the window, then:
|
|
56
|
+
dt.close()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API (concise)
|
|
60
|
+
|
|
61
|
+
| Method | Description |
|
|
62
|
+
|--------|-------------|
|
|
63
|
+
| `DecTerm(gui=False, cwd=None, env=None)` | Start a shell. `gui=True` opens a terminal window. |
|
|
64
|
+
| `send(cmd, timeout=30)` | Run one command, return output. Times out for interactive programs (e.g. `python`). |
|
|
65
|
+
| `sends(*cmds, timeout=30)` | Run several commands in order; returns last command’s output. |
|
|
66
|
+
| `close()` | Stop the shell. |
|
|
67
|
+
|
|
68
|
+
Use `timeout=None` in `send()` to wait indefinitely.
|
|
69
|
+
|
|
70
|
+
## Requirements
|
|
71
|
+
|
|
72
|
+
- Python 3.7+
|
|
73
|
+
- Windows, macOS, or Linux
|
|
74
|
+
- No extra packages (uses only the standard library, including tkinter for GUI).
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
decterm
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "decterm"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cross-platform terminal controllable via Python (and web)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "decterm" }]
|
|
13
|
+
keywords = ["terminal", "shell", "cross-platform", "automation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.7",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: System :: Shells",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=7.0"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["."]
|
|
34
|
+
include = ["decterm*"]
|
decterm-0.1.0/setup.cfg
ADDED
decterm-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Setup script for decterm. Use: pip install . or python setup.py sdist bdist_wheel then twine upload dist/*"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from setuptools import find_packages, setup
|
|
7
|
+
|
|
8
|
+
# Package metadata (edit these for your PyPI listing)
|
|
9
|
+
NAME = "decterm"
|
|
10
|
+
DESCRIPTION = "Cross-platform terminal controllable via Python (and web)"
|
|
11
|
+
URL = "https://github.com/decrule/decterm"
|
|
12
|
+
EMAIL = "decrule@outlook.com"
|
|
13
|
+
AUTHOR = "DecRule"
|
|
14
|
+
REQUIRES_PYTHON = ">=3.7"
|
|
15
|
+
REQUIRED = [] # no runtime dependencies
|
|
16
|
+
EXTRAS = {"dev": ["pytest>=7.0"]}
|
|
17
|
+
|
|
18
|
+
# Version: prefer decterm.__version__, fallback to literal
|
|
19
|
+
here = os.path.abspath(os.path.dirname(__file__))
|
|
20
|
+
try:
|
|
21
|
+
import importlib.util
|
|
22
|
+
spec = importlib.util.spec_from_file_location("_decterm_version", os.path.join(here, "decterm", "__init__.py"))
|
|
23
|
+
mod = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(mod)
|
|
25
|
+
VERSION = getattr(mod, "__version__", "0.1.0")
|
|
26
|
+
except Exception:
|
|
27
|
+
VERSION = "0.1.0"
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
with open(os.path.join(here, "README.md"), encoding="utf-8") as f:
|
|
31
|
+
long_description = f.read()
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
long_description = DESCRIPTION
|
|
34
|
+
|
|
35
|
+
setup(
|
|
36
|
+
name=NAME,
|
|
37
|
+
version=VERSION,
|
|
38
|
+
description=DESCRIPTION,
|
|
39
|
+
long_description=long_description,
|
|
40
|
+
long_description_content_type="text/markdown",
|
|
41
|
+
author=AUTHOR,
|
|
42
|
+
author_email=EMAIL,
|
|
43
|
+
url=URL,
|
|
44
|
+
python_requires=REQUIRES_PYTHON,
|
|
45
|
+
packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]),
|
|
46
|
+
install_requires=REQUIRED,
|
|
47
|
+
extras_require=EXTRAS,
|
|
48
|
+
include_package_data=True,
|
|
49
|
+
license="MIT",
|
|
50
|
+
classifiers=[
|
|
51
|
+
"Development Status :: 3 - Alpha",
|
|
52
|
+
"Intended Audience :: Developers",
|
|
53
|
+
"License :: OSI Approved :: MIT License",
|
|
54
|
+
"Operating System :: OS Independent",
|
|
55
|
+
"Programming Language :: Python :: 3",
|
|
56
|
+
"Programming Language :: Python :: 3.7",
|
|
57
|
+
"Programming Language :: Python :: 3.8",
|
|
58
|
+
"Programming Language :: Python :: 3.9",
|
|
59
|
+
"Programming Language :: Python :: 3.10",
|
|
60
|
+
"Programming Language :: Python :: 3.11",
|
|
61
|
+
"Programming Language :: Python :: 3.12",
|
|
62
|
+
"Topic :: System :: Shells",
|
|
63
|
+
],
|
|
64
|
+
)
|