1 说明
在Linux环境下Python有一个pecpect模块(对应的文档:pexpect document),该模块可以实现shell终端的交互式操作,如执行某个命令之后,捕获输出内容,根据输出的提示语再输入用户名、密码以及其他控制指令,最终通过多次交互完成某个功能或指令;但在Windows环境下暂时未找到比较稳定可靠的模块可以实现类似的功能,有些拿Linux环境的pexpect源码来进行修改并适配Windows系统的文章,但是多数都无法使用,或者不稳定,因此个人经过多次实验通过subprocess结合线程的方式实现了一个简单的Windows环境下的交互式cmd实现的方法,因暂时没有更大的需求,实现的功能也比较简单;
本文介绍如何获通过Python的subprocess模块实现windows端的cmd的交互式控制,本文章主要给大家提供一些简单的示例,仅供参考;
2 依赖
- Python
- 版本:3.8
- 系统
- Windows11
3 代码实现
3.1 实现思路
- 首先需要使用subprocess.Popen接口创建一个cmd的进程,用于运行cmd命令
- 创建一个子线程,用于监控cmd进程,实时捕获cmd的输出,并将cmd的输出保存到日志文件中(这里也可以保存到其他任意位置或内存中)
- 在主线程中,可以实时获取日志文件中的字符串,以监控cmd输出
- 在主线程中,可以根据CMD输出进行新的指令发送
注意1:在子线程写入cmd输出到日志文件与主线程从日志文件读取日志信息中,应该使用线程锁,避免读写冲突
注意2:本示例中读写日志文件使用了一同一个文件指针,需要注意使用完成后恢复文件指针的位置
3.2 代码示例
import threading
import subprocess
import time
import typing
import os
import datetime
class WinExpect:
_Lock = threading.Lock()
def __init__(self):
current_path = os.path.split(os.path.abspath(__file__))[0]
self.__log_dir = os.path.join(current_path, "win_expect")
if not os.path.exists(self.__log_dir):
os.makedirs(self.__log_dir)
self.__log_file = os.path.join(self.__log_dir, f'cmd_{self.timestamp()}.log')
self.__log_file_obj = None
self.__log_file_available = False
self.__cursor = 0
self.__thd = None
self.started = False
self.__cmd_pid = None
self.__process = None
self.start_new_cmd()
@staticmethod
def timestamp():
now = datetime.datetime.now()
return f'{now.year}-{now.month}-{now.day}_{now.hour}-{now.minute}-{now.second}'
# 停止子线程,关闭cmd进程,关闭保存cmd输出的日志文件
def close(self):
self.started = False
if self.__thd:
for _ in range(30):
time.sleep(0.1)
if not self.__thd.is_alive():
print(f'terminate thread for cmd process succeed')
self.__thd = None
break
else:
print(f'terminate thread for cmd process failed, start: {self.started}')
if self.__process:
self.__process.terminate()
self.__process = None
if self.__log_file_obj:
self.__log_file_obj.close()
# 开启一个新的线程用于运行命令后的CMD输出的捕获和记录
def start_new_cmd(self):
# 通过subprocess打开一个子进程,用于执行CMD命令
# 子进程中的命令只需要执行"cmd.exe"即可,模拟用户手动开启一个cmd窗口
if not self.__process:
self.__process = subprocess.Popen(
"cmd.exe",
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT
)
self.__cmd_pid = self.__process.pid
print(f'new console started')
else:
print(f'cmd.exe has already started')
# 开启一个子线程来监控通过subprocess开启的子进程的输出
if not self.__thd:
self.started = True
self.__thd = threading.Thread(target=self.__continuous_read_from_cmd_output, args=(self.__process, self._Lock))
self.__thd.setDaemon(True)
self.__thd.start()
print(f'new thread started')
else:
print(f'thread has already started')
# 读CMD输出的子线程:
# 将CMD输出的字符逐一读取并写入到一个自己创建的日志文件中,如果不想用文件进行记录可以自己改成内存方式,可以使用stringio模块来模拟文件
# 由于读写文件使用了一个文件指针,在读和写时都需要移动文件指针,必须对文件的读写操作使用线程锁
# 参数1:output_stream,标准输出,通过该参数获取CMD输出
# 参数2:lock_,线程锁
def __continuous_read_from_cmd_output(self, output_stream, lock_: threading.Lock):
log_bytes_cnt = 0
# 读写文件时的文件指针
self.__cursor = 0
# 打开一个日志文件,用于存放从cmd中获取到的所有输出字符,根据输出字符可以判断当前程序运行到哪个步骤,是否需要输入指令或密码
self.__log_file_obj = open(self.__log_file, 'a+', encoding='utf-8', newline='\n')
print(f'start reading cmd output, log file started: "{self.__log_file}"')
self.__log_file_available = True
start_t = time.time()
while True:
# 停止监控cmd输出的线程
if not self.started:
print(f'<Thread win_expect> exit')
return
# 每60秒打印一次子线程在线消息
if time.time() - start_t >= 60:
print(f'<Thread win_expect> online')
start_t = time.time()
try:
# 从cmd中读取一个字符,不要读取多个,如果cmd中打印的是输入提示,可能导致预期的字符数一直无法达到,进而导致卡在获取输出位置
out = output_stream.stdout.read(1)
if not out:
continue
# 对获取到的输出字符进行解码
out = out.decode("gbk", errors="ignore") if isinstance(out, bytes) else out
# 将解码后的字符写入到文件中,此处的线程锁用于避免读写冲突
lock_.acquire()
self.__log_file_obj.write(out)
lock_.release()
log_bytes_cnt += 2
# 当写入文件的字符数量大于设定的值后,则保存并关闭该文件,同时重新开启一个新的文件,避免单一文件过大
if log_bytes_cnt >= 50000 * 100:
self.__log_file_available = False
self.__log_file_obj.close()
self.__log_file_obj = None
log_bytes_cnt = 0
self.__log_file = os.path.join(self.__log_dir, f'cmd_{self.timestamp()}.log')
self.__log_file_obj = open(self.__log_file, 'a+', encoding='utf-8', newline='\n')
self.__cursor = 0
print(f'new log file started: "{self.__log_file}"')
self.__log_file_available = True
except (ValueError, IOError) as e:
print(f'win expect error: "{repr(e)}"')
# 从保存的日志文件中读取一行
# 读取日志时需要先将文件指针移动到上一次读取的位置(文件位置会被变量记录),避免重复读取;然后进行读取操作,读取一行;读取完成后将文件
# 指针移动到当前文件的末尾(确保在子线程中写入cmd输出时的位置正确)
# 参数1:timeout,如果上一次记录的文件指针不在文件末尾,则直接读取一行,否则等待新的内容被写入后再读取,直到超时
def read_line(self, timeout: float = 1) -> str:
# 如果当前文件不可用(当前文件正在被保存关闭,新的文件还未创建,或其他异常情况)则直接返回空字符串
if not self.__log_file_obj or not self.__log_file_available:
time.sleep(0.1)
return ''
if timeout < 0.12:
timeout = 0.12
for _ in range(int(timeout * 10)):
# 记录的文件指针位置不在文件末尾
if self.__cursor < self.__log_file_obj.tell():
self._Lock.acquire()
self.__log_file_obj.seek(self.__cursor)
result = self.__log_file_obj.readline()
self.__cursor = self.__log_file_obj.tell()
self.__log_file_obj.seek(0, 2)
self._Lock.release()
return result
else:
time.sleep(0.1)
return ''
# 从保存的日志文件中读取剩余所有行,返回行列表
# 该接口可以用于快速将文件指针移动到文件末尾,可以用于快速更新文件指针,当文件内容很多且旧的CMD日志已不需要时,可以使用该接口,避免在
# 搜索日志的时候重复搜索无效内容
def read_all(self):
if not self.__log_file_obj or not self.__log_file_available:
return []
self._Lock.acquire()
self.__log_file_obj.seek(self.__cursor)
result = self.__log_file_obj.readlines()
self.__log_file_obj.seek(0, 2)
self.__cursor = self.__log_file_obj.tell()
self._Lock.release()
return result
# 向正在运行的CMD进程中发送一个指令,可以用于执行新的命令,或者在交互式场景中输入用户名、密码或yes/no等确认信息等
# 参数1:s,要发送的指令的字符串或要输入的用户名、密码等,如:"adb logcat", "dir", "ping www.baidu.com", "username", "password"等
# 参数2:line_break,换行符,该换行符可以等同于回车(Enter),表示指令立刻被执行,没有这个换行符只会写入字符串,不会执行
# 参数3:flush,立刻执行指令
def send(self, s: str, line_break: str = '\n', flush: bool = True):
self.__process.stdin.write(f'{s}{line_break}'.encode("gbk"))
if flush:
self.__process.stdin.flush()
# 向正在运行的CMD进程中发送一个ctrl+c指令,用于终止当前无法停止或不在需要执行的指令,模拟在CMD窗口中使用ctrl+c快捷键
# 参数1:flush,立刻终止当前正在执行的指令
def ctrl_c(self, flush: bool = True):
self.__process.stdin.write("\003\r".encode("utf8"))
if flush:
self.__process.stdin.flush()
# 等待CMD输出中的特定内容:
# 从保存的日志文件开头开始读取一行,并将读取到的字符串与设置的预期值进行比较,以判断cmd是否有符合预期的输出字符串,以便执行下一步操作
# 参数1:s,预期的cmd字符串,可以是一个字符串或是一个字符串列表(如果是字符串列表则表示需要判断该列表中所有字符串都要出现,或任意一个出现均可,根据其他参数决定)
# 参数2:case_sensitive,将预期的字符串(s)与日志文件中保存的cmd输出进行比较时,是否区分大小写
# 参数3:any_expect,将预期的字符串列表(s)与日志文件中保存的cmd输出进行比较时,只要列表中任意一个字符串匹配成功则表示成功,否则需要列表中所有字符串均匹配成功才表示成功
# 参数4:timeout,如果在比较期间超时,则比较失败,未在cmd输出中找到预期的字符串
def wait_until(
self,
s: (str, typing.List[str]),
case_sensitive: bool = False,
any_expect: bool = False,
timeout: float = 1) -> str:
if timeout < 0.1:
timeout = 0.1
if isinstance(s, str):
s = [s]
start_t = time.time()
while True:
if time.time() - start_t >= timeout:
print(f'timeout waiting for strings: {s}')
return ''
line = self.read_line(timeout=0.2)
if line:
if any_expect:
if not case_sensitive and any([True if x.lower() in line.lower() else False for x in s]):
return line
elif case_sensitive and any([True if x in line else False for x in s]):
return line
else:
if not case_sensitive and all([True if x.lower() in line.lower() else False for x in s]):
return line
elif case_sensitive and all([True if x in line else False for x in s]):
return line
3.3 使用示例
大多数使用场景基本能覆盖,以下是一个假设的使用场景,请根据自己的实际使用进行调整
# 假设一个场景:我们需要使用cmd来安装一个非界面软件工具:TestTool.exe,该安装过程中会需要输入用户名、密码
we = WinExpect()
we.send("D:\\TestTool.exe")
ret = we.wait_until("new user name:", timeout=10)
if ret:
we.send("user1")
ret2 = we.wait_until("new user password:", timeout=3)
if ret2:
we.send("qazwsx")
ret3 = we.wait_until("install succeed!", timeout=60)
if ret3:
print('安装成功')
else:
pass
else:
pass
else:
pass
we.close()