需求背景
现需要实现一个工具类,功能为验证给定路径是否为有效的 Python 解释器可执行文件(不一定是主程序所使用的解释器),并获取该解释器版本信息、是否安装某模块/包等信息。该工具类将赋予主程序类似 PyCharm 中选取 Python 解释器的功能。
快速编写了百余行代码完成基本设计需求,记录如下,旨在抛砖引玉。
总体结构
设计名为 InterpreterVailidator
的类,主要实现如下几个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import subprocess from pathlib import Path
class InterpreterValidator: """ 验证给定的可执行文件是否为有效的Python解释器,并获取该解释器相关信息 """ def __init__(self, path): """ :param path: 可执行文件路径 """ self._itp_path = Path(path) self._itp_validated = 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
中,直接处理该对象即可。此处使用的若干种判断方式按顺序逐渐严格:
- 该路径是否存在
- 该路径指向的是否为文件(不是目录)
- 该文件的文件名是否以
python
起始
- 该名为
python
的文件是否可以作为可执行程序运行测试代码 import os
前三条直接使用 pathlib
提供的相关接口判断即可,可以依顺序写在同一条 if 语句中,注意完成判断后需要返回值和修改 self._itp_validated
属性值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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") ): 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()
方法扩展为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| 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") ): 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 else: self._itp_validated = False return False
|
然而这个版本还有些缺陷——在随意创建的一个假解释器文件 python4
上调用此方法时,由于子进程错误,最终引发了 OSError
并导致主程序也崩溃。所以最终版本还需加入一点异常处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| 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") ): try: 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
(这代表这个类本身)作为首个参数,即可将其变成类方法。
1 2 3 4 5 6 7
| @classmethod def validate(cls, path) -> bool: """ 验证path是否指向有效的Python解释器 \n :return: 是否有效 """ return InterpreterValidator(path).validate_itp()
|
这里采用了偷懒的写法:偷偷实例化一个 InterpreterValidator
并调用其 validate_itp()
判断方法,最后返回。无需担心——得益于引用计数,此处实例化的对象很快就会被自动销毁掉。而有了这个类方法,调用过程简化了:
1 2 3 4 5 6 7
| if __name__ == "__main__": iv = InterpreterValidator("/usr/bin/python") result = iv.validate_itp() result = InterpreterValidator.validate("/usr/bin/python")
|
module_installed()
假设已经确认该路径确实是一个有效的 Python 解释器,那么接下来很可能关心的一个问题是该解释器环境是否安装了某个(某些)模块/包。有了上面的思路,编写这个方法并不难,继续使用 python -c
子进程就好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| 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, ) if result.returncode != 0 and b"ModuleNotFoundError" in result.stderr: return False else: return True else: return False
|
有几条需要注意的地方:
- 参数中的module模块名必须是用于
import
导入的名称,有些库的常用名和导入名并不相同(比如 PyTorch
的导入名为 torch
);
- 调用
subprocess.run()
时需要将 stderr 重定向至 PIPE 以便于捕捉,在result.stderr中即为字节串形式的标准错误;
- 而同样也需将 stdout 重定向,以防某些模块的
__init__
中有输出而对主程序控制台造成干扰;
完整代码
最终版本的完整代码如下,使用 Black 与 isort 工具进行了代码格式化、加入类型注解并能够通过 Mypy 静态检查:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
|
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") ): try: 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 """
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__": iv = InterpreterValidator("/usr/bin/python") print(iv.module_installed("PySide6")) print(iv.module_installed("black"))
|