Python3+Subprocess实现cmd的交互式控制

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 实现思路

  1. 首先需要使用subprocess.Popen接口创建一个cmd的进程,用于运行cmd命令
  2. 创建一个子线程,用于监控cmd进程,实时捕获cmd的输出,并将cmd的输出保存到日志文件中(这里也可以保存到其他任意位置或内存中)
  3. 在主线程中,可以实时获取日志文件中的字符串,以监控cmd输出
  4. 在主线程中,可以根据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()
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

李星星BruceL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值