Python解释器验证器工具类

需求背景

现需要实现一个工具类,功能为验证给定路径是否为有效的 Python 解释器可执行文件(不一定是主程序所使用的解释器),并获取该解释器版本信息、是否安装某模块/包等信息。该工具类将赋予主程序类似 PyCharm 中选取 Python 解释器的功能。

快速编写了百余行代码完成基本设计需求,记录如下,旨在抛砖引玉。

Python解释器验证器工具类_第1张图片

总体结构

设计名为 InterpreterVailidator 的类,主要实现如下几个方法:

import subprocess
from pathlib import Path

class InterpreterValidator:
    """
    验证给定的可执行文件是否为有效的Python解释器,并获取该解释器相关信息
    """
    
    def __init__(self, path):
        """
        :param path: 可执行文件路径
        """
        self._itp_path = Path(path)
        self._itp_validated = False  # 获取解释器信息前先检查这个属性,若为False则直接跳过
        self.validate_itp()
    
    def validate_itp(self) -> bool:
        """
        验证解释器是否有效
        :return: 解释器是否有效
        """
        pass
    
    @classmethod
    def validate(cls, path) -> bool:
        """
        验证path是否指向有效的Python解释器
        :return: 是否有效
        """
        pass
    
    def module_installed(self, module) -> bool:
        """
        验证该解释器环境中是否已安装某个模块
        :param module: 模块名,要求为import语句中使用的名称
        :return: 未安装该模块或解释器无效时返回False
        """
        pass
    
    def itp_info(self):
        """
        获取解释器的相关信息
        """
        pass

仅使用标准库即可完成以上需求,下面简单介绍一下将会用到的标准库。

相关标准库简介

pathlib

pathlib 为面向对象的文件系统路径模块。这个从3.4新引入的标准库,相比 os.path 提供了更高级更面向对象的路径操作。用字符串或 os.PathLike 类型的路径创建 pathlib.Path 对象,后续可以很方便地获取该路径对象是否存在、是否为文件、绝对路径……等。

subprocess

subprocess 是子进程管理模块,可以在 Python 主进程中以子进程的形式创建并运行一个外部命令/程序。对于一般使用,直接调用 subprocess.run() 函数即可,并可获取子进程退出码等信息。

重要函数方法设计

validate_itp()

self.__init__() 中已经将待判断的路径转为 pathlib.Path 对象并保存在实例属性 self._itp_path 中,直接处理该对象即可。此处使用的若干种判断方式按顺序逐渐严格:

  1. 该路径是否存在
  2. 该路径指向的是否为文件(不是目录)
  3. 该文件的文件名是否以 python 起始
  4. 该名为python的文件是否可以作为可执行程序运行测试代码 import os

前三条直接使用 pathlib 提供的相关接口判断即可,可以依顺序写在同一条 if 语句中,注意完成判断后需要返回值和修改 self._itp_validated 属性值:

    def validate_itp(self) -> bool:
        """
        验证解释器是否有效 \n
        :return: 解释器是否有效
        """

        if (
            self._itp_path.exists()  # 该路径存在
            and self._itp_path.is_file()  # 为文件
            and self._itp_path.name.startswith("python")  # 文件名以python起始
        ):
            self._itp_validated = True
            return True
        else:
            self._itp_validated = False
            return False

至此,只能保证路径指向一个名为 python* 的现有文件,但不能保证该文件就是可执行的解释器。

通过尝试使用该文件执行 python -c 'import os' 命令来进一步验证。python -c 模式会将后面的命令行参数作为 Python 代码执行。对于任一个有效的 Python 解释器,在启动时其实已经运行过了一次 import os 命令(而在日常编程中还需要显式导入一次,应该是出于命名空间之考虑),因此使用该命令进行验证,是额外开销很低而又无需 stdio 的理想方式。

使用 subprocess.run() 来尝试运行 python -c 'import os',如果顺利运行而返回码为 0,则有非常大的把握确认该文件是一个有效的 Python 解释器可执行文件。validate_itp() 方法扩展为:

import subprocess
from pathlib import Path

    def validate_itp(self) -> bool:
        """
        验证解释器是否有效 \n
        :return: 解释器是否有效
        """

        if (
            self._itp_path.exists()  # 该路径存在
            and self._itp_path.is_file()  # 为文件
            and self._itp_path.name.startswith("python")  # 文件名以python起始
        ):
            # 尝试将该文件作为Python解释器运行
            subprocess_args = [
                str(self._itp_path.resolve()),  # 解析为绝对路径并转为字符串
                "-c",
                "import os",
            ]
            result = subprocess.run(
                args=subprocess_args,
                timeout=300,  #超时时间300毫秒
            )
            if result.returncode == 0:  # 返回码为0,说明子进程顺利运行结束
                self._itp_validated = True
                return True
            else:
                self._itp_validated = False
                return False
        else:
            self._itp_validated = False
            return False

然而这个版本还有些缺陷——在随意创建的一个假解释器文件 python4 上调用此方法时,由于子进程错误,最终引发了 OSError 并导致主程序也崩溃。所以最终版本还需加入一点异常处理:

import subprocess
from pathlib import Path

    def validate_itp(self) -> bool:
        """
        验证解释器是否有效 \n
        :return: 解释器是否有效
        """

        if (
            self._itp_path.exists()  # 该路径存在
            and self._itp_path.is_file()  # 为文件
            and self._itp_path.name.startswith("python")  # 文件名以python起始
        ):
            try:
                # 尝试将该文件作为Python解释器运行
                subprocess_args = [
                    str(self._itp_path.resolve()),
                    "-c",
                    "import os",
                ]
                result = subprocess.run(
                    args=subprocess_args,
                    timeout=300,
                )
                if result.returncode == 0:
                    self._itp_validated = True
                    return True
                else:
                    self._itp_validated = False
                    return False
            except OSError:
                self._itp_validated = False
                return False
        else:
            self._itp_validated = False
            return False

(末尾的三条完全一样的返回语句看起来并不优雅,暂时还没想到该如何解决。)

validate() 类方法

使用 InterpreterVailidator 类的用户可能只需简单快速判断某个文件路径是否指向有效的 Python 解释器,而并不关心其他细节信息。所以提供一个类方法是非常必要的。简单来说,使用 @classclassmethod 修饰类中的某个函数,并以 cls (这代表这个类本身)作为首个参数,即可将其变成类方法。

    @classmethod
    def validate(cls, path) -> bool:
        """
        验证path是否指向有效的Python解释器 \n
        :return: 是否有效
        """
        return InterpreterValidator(path).validate_itp()

这里采用了偷懒的写法:偷偷实例化一个 InterpreterValidator 并调用其 validate_itp() 判断方法,最后返回。无需担心——得益于引用计数,此处实例化的对象很快就会被自动销毁掉。而有了这个类方法,调用过程简化了:

if __name__ == "__main__":
    # 原版判断方式:
    iv = InterpreterValidator("/usr/bin/python")
    result = iv.validate_itp()
    
    # 现在的方式:
    result = InterpreterValidator.validate("/usr/bin/python")

module_installed()

假设已经确认该路径确实是一个有效的 Python 解释器,那么接下来很可能关心的一个问题是该解释器环境是否安装了某个(某些)模块/包。有了上面的思路,编写这个方法并不难,继续使用 python -c 子进程就好:

    def module_installed(self, module) -> bool:
        """
        验证该解释器环境中是否已安装某个模块 \n
        :param module: 模块名,要求为import语句中使用的名称
        :return: 未安装该模块或解释器无效时返回False
        """

        if self._itp_validated:
            subprocess_arg_list = [
                str(self._itp_path.resolve()),
                "-c",
                f"import {module}",
            ]
            result = subprocess.run(
                args=subprocess_arg_list,
                stdout=subprocess.PIPE,  # 重定向标准输出
                stderr=subprocess.PIPE,  # 重定向标准错误
                timeout=300,  # 300毫米超时
            )
            if result.returncode != 0 and b"ModuleNotFoundError" in result.stderr:
                return False
            else:
                return True
        else:
            return False

有几条需要注意的地方:

  1. 参数中的module模块名必须是用于 import 导入的名称,有些库的常用名和导入名并不相同(比如 PyTorch 的导入名为 torch);
  2. 调用subprocess.run() 时需要将 stderr 重定向至 PIPE 以便于捕捉,在result.stderr中即为字节串形式的标准错误;
  3. 而同样也需将 stdout 重定向,以防某些模块的 __init__ 中有输出而对主程序控制台造成干扰;

完整代码

最终版本的完整代码如下,使用 Black 与 isort 工具进行了代码格式化、加入类型注解并能够通过 Mypy 静态检查:

# Copyright (C) 2022  muzing

# interpreter_validator.py

import os
import subprocess
from pathlib import Path
from typing import Union


class InterpreterValidator:
    """
    验证给定的可执行文件是否为有效的Python解释器,并获取该解释器相关信息
    """

    def __init__(self, path: Union[str, os.PathLike[str]]) -> None:
        """
        :param path: 可执行文件路径
        """

        self._itp_path: Path = Path(path)
        self._itp_validated: bool = False
        self.validate_itp()

    def validate_itp(self) -> bool:
        """
        验证解释器是否有效 \n
        :return: 解释器是否有效
        """

        if (
            self._itp_path.exists()  # 该路径存在
            and self._itp_path.is_file()  # 为文件
            and self._itp_path.name.startswith("python")  # 文件名以python起始
        ):
            try:
                # 尝试将该文件作为Python解释器运行
                subprocess_args = [
                    str(self._itp_path.resolve()),
                    "-c",
                    "import os",
                ]
                result = subprocess.run(
                    args=subprocess_args,
                    timeout=300,
                )
                if result.returncode == 0:
                    self._itp_validated = True
                    return True
                else:
                    self._itp_validated = False
                    return False
            except OSError:
                self._itp_validated = False
                return False
        else:
            self._itp_validated = False
            return False

    def itp_info(self):
        """
        获取解释器的相关信息 \n
        """

        # FIXME 完善此方法
        if self._itp_validated:
            pass

    def module_installed(self, module: str) -> bool:
        """
        验证该解释器环境中是否已安装某个模块 \n
        :param module: 模块名,要求为import语句中使用的名称
        :return: 未安装该模块或解释器无效时返回False
        """

        if self._itp_validated:
            subprocess_arg_list = [
                str(self._itp_path.resolve()),
                "-c",
                f"import {module}",
            ]
            result = subprocess.run(
                args=subprocess_arg_list,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=300,
            )
            if result.returncode != 0 and b"ModuleNotFoundError" in result.stderr:
                return False
            else:
                return True
        else:
            return False

    @classmethod
    def validate(cls, path: Union[str, os.PathLike[str]]) -> bool:
        """
        验证path是否指向有效的Python解释器 \n
        :return: 是否有效
        """

        return InterpreterValidator(path).validate_itp()


if __name__ == "__main__":
    # print(InterpreterValidator.validate("/usr/bin/python"))
    iv = InterpreterValidator("/usr/bin/python")
    print(iv.module_installed("PySide6"))
    print(iv.module_installed("black"))

你可能感兴趣的:(python,pycharm)