现需要实现一个工具类,功能为验证给定路径是否为有效的 Python 解释器可执行文件(不一定是主程序所使用的解释器),并获取该解释器版本信息、是否安装某模块/包等信息。该工具类将赋予主程序类似 PyCharm 中选取 Python 解释器的功能。
快速编写了百余行代码完成基本设计需求,记录如下,旨在抛砖引玉。
设计名为 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 为面向对象的文件系统路径模块。这个从3.4新引入的标准库,相比 os.path 提供了更高级更面向对象的路径操作。用字符串或 os.PathLike 类型的路径创建 pathlib.Path
对象,后续可以很方便地获取该路径对象是否存在、是否为文件、绝对路径……等。
subprocess 是子进程管理模块,可以在 Python 主进程中以子进程的形式创建并运行一个外部命令/程序。对于一般使用,直接调用 subprocess.run()
函数即可,并可获取子进程退出码等信息。
在 self.__init__()
中已经将待判断的路径转为 pathlib.Path
对象并保存在实例属性 self._itp_path
中,直接处理该对象即可。此处使用的若干种判断方式按顺序逐渐严格:
python
起始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
(末尾的三条完全一样的返回语句看起来并不优雅,暂时还没想到该如何解决。)
使用 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")
假设已经确认该路径确实是一个有效的 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
有几条需要注意的地方:
import
导入的名称,有些库的常用名和导入名并不相同(比如 PyTorch
的导入名为 torch
);subprocess.run()
时需要将 stderr 重定向至 PIPE 以便于捕捉,在result.stderr中即为字节串形式的标准错误;__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"))