导语: pipdeptree是TencentOS发行版软件包中维护python软件包使用的工具,提供了统计和管理python包依赖链的能力。作为发行版软件包的负责人,对该领域深挖且精通,是保证其稳定性和高可用性的必要条件。从从未接触过这个工具的初学者,经历了差不多一个月时间的努力,完成了重构工作,并收到了高星项目 `pipdeptree` 的邀请成为项目maintainer,需要的是大量的业余时间以及耐心,翻阅各种手册以及源码实现。
背景
伴随最近AI生态越来越大,而python最为AI的底座语言,相关的包、代码库当然是越来越多。所以解决python的依赖地狱问题是一个可以带来收益的优化点。这个收益主要来自两方面:
裁剪掉部分python包并不需要的依赖,可以让AI/python项目镜像体积更小,更轻便
优化环境中的多余依赖,也可以使开发环境更加干净,项目间的依赖关系更加明朗
调研
pipdeptree
工具的能力符合我们的要求,并且在TencentOS Server 4 已经集成了这个包,可以通过
dnf install python3-pipdeptree
安装。
同时我们也在制作python runtime、pytorch等镜像时,会通过
pipdeptree
进行优化,提供体积更小更轻便的镜像(如果大家有需求,可以与我们联系)。
pipdeptree
工具使用很简单 主要使用的是下面两条
pipdeptree -p ABC
以及
pipdeptree -j
至于他的原理,其实是使用了官方的
pkg_resources
库,其中核心的为下面两部分
Environment.from_paths(None).iter_installed_distributions
获取当前环境中所有的python包(dist)
from pip._internal.metadata import pkg_resources
dists = pkg_resources.Environment.from_paths(None).iter_installed_distributions(
local_only=local_only,
skip=(),
user_only=user_only,
)
2.
DistInfoDistribution.requires()
来获取相关包的依赖项
from pip._vendor.pkg_resources import DistInfoDistribution
def requires(self) -> list[Requirement]:
return self._obj.requires() # type: ignore[no-untyped-call,no-any-return]
最终生成的是环境中所有安装包的依赖树,例如
{
"package": {
"key": "adal",
"package_name": "adal",
"installed_version": "1.2.7"
},
"dependencies": [
{
"key": "cryptography",
"package_name": "cryptography",
"installed_version": "41.0.4",
"required_version": ">=1.1.0"
},
{
"key": "pyjwt",
"package_name": "PyJWT",
"installed_version": "2.6.0",
"required_version": ">=1.0.0,<3"
},
{
"key": "python-dateutil",
"package_name": "python-dateutil",
"installed_version": "2.8.2",
"required_version": ">=2.1.0,<3"
},
{
"key": "requests",
"package_name": "requests",
"installed_version": "2.28.2",
"required_version": ">=2.0.0,<3"
}
]
},
但是需要注意的是,pkg_resource生成依赖,并不会包括
tox.ini
也就是测试所需的依赖,这个问题需要进一步分析原因以及其合理性。
(上述问题,经测试并不是问题,因为tox中的为测试依赖,在rpm编译系统中应该只出现在编译环节,并不应该作为运行依赖,所以是依赖冗余,应该删除)需要一并注意的是,
pkg_resource
接口已经deprecated,推荐使用importlib.resources
代替,这里可以给上游提一提代码。见 : https://github.com/tox-dev/pipdeptree/pull/333/
同时,我们发行版自己打包python包的时候也会使用rpm的依赖生成方式,也就是
BuildRequires
、
Requires
。相比于python包本身的依赖列表,发行版打包过程中很容易引入多余的依赖,导致该软件包的依赖链产生了冗余。结合背景问题中提到的问题「python开发者有时候并不能完全准确的列出当前包所需的依赖」,有可能是代码变化之后原来依赖的不依赖了等情况。
综上,目前想到的优化点有两个:
通过pipdeptree,先裁剪掉环境中的多余的python包,这里就是依赖各个python包开发者自己对依赖的掌控
通过python调用解析工具,分析扫描这些python包里的代码是不是真的有用到这些依赖
尝试结果
第一步Demo
import json
import subprocess
import re
FILTERED_DEPENDENCIES = ['python3']
def extract_package_name(dep):
match = re.match(r'.*python3(?:\.\d+)?dist\(([^)]+)\).*', dep)
if match:
return match.group(1)
return dep.split(' ')[0]
def get_package_dependencies(package_name):
package_name = get_rpmname(package_name)
print(package_name)
try:
command = f'rpm-dep -i {package_name} -q'
subprocess.run(command.split())
parse_cmd = f"jq -r '.next[] | .pkg_name' dep_tree__{package_name}__install.json | sort | uniq"
output = subprocess.getoutput(parse_cmd)
dependencies = output.strip().split('\n')
return dependencies
except subprocess.CalledProcessError:
return []
def get_rpmname(py_name):
if not py_name.startswith("python-"):
# try python3dist(ABC)
command = f"dnf repoquery --whatprovides 'python3dist({py_name})' --latest-limit 1 --queryformat '%{{NAME}}' -q"
output = subprocess.getoutput(command)
# try python-ABC
if output == "":
# try lower case
command = f"dnf repoquery --whatprovides 'python3dist({py_name.lower()})' --latest-limit 1 --queryformat '%{{NAME}}' -q"
output = subprocess.getoutput(command)
# last chance
if output == "":
py_name = f"python3-{py_name}"
info_command = f'dnf info python3-{py_name}'
info_result = subprocess.run(info_command.split(), stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, text=True)
if info_result.returncode != 0:
py_name = "ERROR"
else:
py_name = output
else:
py_name = py_name[7:]
info_command = f'dnf info python3-{py_name}'
info_result = subprocess.run(info_command.split(), stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, text=True)
if info_result.returncode != 0:
py_name = "ERROR"
else:
py_name = f"python3-{py_name}"
return py_name
def check_dependencies(package_data):
package_name = package_data['package']['key']
local_dependencies = [get_rpmname(dep['key']) for dep in package_data['dependencies']]
repo_dependencies = get_package_dependencies(package_name)
missing_dependencies = list(set(repo_dependencies) - set(local_dependencies))
extra_dependencies = list(set(local_dependencies) - set(repo_dependencies))
#print(local_dependencies)
#print(repo_dependencies)
# 过滤 FILTERED_DEPENDENCIES 列表中的依赖项
missing_dependencies = [dep for dep in missing_dependencies if dep not in FILTERED_DEPENDENCIES]
extra_dependencies = [dep for dep in extra_dependencies if dep not in FILTERED_DEPENDENCIES]
print(missing_dependencies)
print(extra_dependencies)
return {
'package_name': get_rpmname(package_name),
'missing_dependencies': missing_dependencies,
'extra_dependencies': extra_dependencies
}
def main():
with open('packages.json', 'r') as file:
packages_data = json.load(file)
result = []
for package_data in packages_data:
package_result = check_dependencies(package_data)
result.append(package_result)
with open('result.json', 'w') as file:
json.dump(result, file, indent=2)
if __name__ == '__main__':
main()
根据最终结果分析,存在以下问题会导致结果不准确:
上一小节提到的,tox.ini也就是tox测试套依赖并不在依赖列表里,这会导致rpm依赖会比pipdep查找到的依赖多,比如python-oauth2client等
不是一个标准的python包,或者没有按python标准开发、写依赖,导致本身就没有写依赖,这也会导致rpm依赖会比pipdeptree查找到的依赖多,比如asciidoc等
第二步Demo
import ast
import importlib.metadata
import importlib.resources
import json
import os
import sys
import re
# 获取内置模块列表
builtin_modules = set(sys.builtin_module_names)
def get_standard_library_modules():
lib_path = os.path.dirname(os.__file__)
modules = []
def add_module(root, file):
module_path = os.path.relpath(os.path.join(root, file), lib_path)
module_name = os.path.splitext(module_path.replace(os.path.sep, '.'))[0]
if module_name.endswith('.__init__'):
module_name = module_name[:-9]
modules.append(module_name)
for root, dirs, files in os.walk(lib_path):
if 'site-packages' in dirs:
dirs.remove('site-packages')
if root == lib_path:
# 获取第一层的所有 .py 文件名
for file in files:
if file.endswith('.py'):
add_module(root, file)
# 处理带有 __init__.py 文件的目录链
if '__init__.py' in files:
add_module(root, '__init__.py')
return modules
# 添加一些常见的标准库模块
builtin_modules.update(get_standard_library_modules())
def parse_imports(file_path):
with open(file_path, 'r') as file:
content = file.read()
# 移除所有单行注释
content = re.sub(r'#.*', '', content)
# 移除所有多行注释
content = re.sub(r'""".*?"""', '', content, flags=re.DOTALL)
# 匹配 import 和 from import 语句
import_re = re.compile(r'(?:from\s+([.\w]+)(?:\s+import\s+[\w, ()]+)|import\s+([\w, ()]+))')
matches = import_re.findall(content)
imports = []
for match in matches:
# match 是一个元组,其中一个元素是空字符串,另一个元素是模块名
module_names = match[0] if match[0] else match[1]
# 如果模块名以'.'开头,说明是相对导入,我们忽略它
if not module_names.startswith('.'):
module_names = module_names.split(',')
for module_name in module_names:
# 处理别名导入的情况
module_name = module_name.strip().split(' as ')[0].split('.')[0]
if module_name not in builtin_modules and not module_name.startswith('_'):
imports.append(module_name)
return imports
def get_package_imports():
package_imports = {}
dists = importlib.metadata.distributions()
for dist in dists:
package_name = dist.metadata['Name']
try:
package_dir = importlib.resources.files(package_name)
if package_dir is not None:
package_imports[package_name] = {}
for root, dirs, files in os.walk(str(package_dir)):
for file in files:
if file.endswith('.py'):
file_path = os.path.join(root, file)
imports = parse_imports(file_path)
# 去重并筛除当前包名
imports = list(set(imports))
if package_name in imports:
imports.remove(package_name)
package_imports[package_name][file_path] = imports
except:
pass
return package_imports
# 获取所有包的import信息
package_imports = get_package_imports()
# 转换为JSON格式并打印
json_data = json.dumps(package_imports, indent=4)
print(json_data)
# 读取package.json文件
with open('packages.json', 'r') as file:
package_data = json.load(file)
# 检查每个包的imports是否都在dependencies中
for package in package_data:
package_name = package['package']['package_name']
if package_name in package_imports:
dependencies = {dep['package_name'] for dep in package['dependencies']}
for file_path, imports in package_imports[package_name].items():
for import_name in imports:
if import_name not in dependencies:
print(f'In package {package_name}, file {file_path} imports {import_name} which is not in dependencies.')
else:
print(f'In package {package_name}, file {file_path} imports {import_name} is found in pipdeptree.')
根据最终结果分析,存在以下问题会导致结果不准确:
ast无法区分import模块是当前路径下还是公有模块,比如
from .ABC import DEF
中,ABC并不是一个公共python模块,也就是ABC不应该被检查,但是ast解析后该模块与
from ABC import DEF
无异。所以会有误报。
该问题最终通过不使用ast解析模块,而是直接通过文本解析来完成,因为只涉及import/from import语句,较简单,所以可行
部分包名不标准,python包名和模块名不一致,如:
tooz==4.2.0
├── fasteners [required: >=0.7, installed: 0.19]
├── futurist [required: >=1.2.0, installed: 2.4.1]
├── msgpack [required: >=0.4.0, installed: 1.0.5]
├── oslo.serialization [required: >=1.10.0, installed: 5.0.0]
├── oslo.utils [required: >=4.7.0, installed: 6.0.1]
├── pbr [required: >=1.6, installed: 5.11.1]
├── stevedore [required: >=1.16.0, installed: 4.0.2]
├── tenacity [required: >=5.0.0, installed: 8.2.3]
└── voluptuous [required: >=0.8.9, installed: 0.13.1]
In package tooz, file /usr/lib/python3.11/site-packages/tooz/drivers/etcd3.py imports oslo_utils which is not in dependencies.
这也会导致python包路径获取不完整,因为通过
dists = importlib.metadata.distributions()
获取的包名(例如 pycryptodome )和实际的模块名(例如 Crypto )不一样,在
package_dir = importlib.resources.files(package_name)
这一步是靠先
import
来找文件的,所以会直接报错。
该问题可以使用importlib_metadata.packages_distributions来解决,这个API返回的是每个分发包的包名和可import模块的映射
当前发现的问题
依赖缺失
这种情况不一定是问题,因为部分模块只是被弱依赖,也就是没有他们也能正常运行。
可选模块
In package urllib3, file /usr/lib/python3.11/site-packages/urllib3/response.py imports brotli which is not in dependencies.
try:
try:
import brotlicffi as brotli
except ImportError:
import brotli
except ImportError:
brotli = None
还有比如测试代码,也会有一些依赖缺失,这些优先级不高
In package zake, file /usr/lib/python3.11/site-packages/zake/test.py imports testtools which is not in dependencies.
上游开发问题
还有就是真的是上游没有写好依赖,比如
urllib3
这个包。
In package urllib3, file /usr/lib/python3.11/site-packages/urllib3/contrib/appengine.py imports google which is not in dependencies.
In package urllib3, file /usr/lib/python3.11/site-packages/urllib3/contrib/socks.py imports socks which is not in dependencies.
In package urllib3, file /usr/lib/python3.11/site-packages/urllib3/contrib/pyopenssl.py imports OpenSSL which is not in dependencies.
In package urllib3, file /usr/lib/python3.11/site-packages/urllib3/contrib/pyopenssl.py imports idna which is not in dependencies.
In package urllib3, file /usr/lib/python3.11/site-packages/urllib3/contrib/pyopenssl.py imports cryptography which is not in dependencies.
In package urllib3, file /usr/lib/python3.11/site-packages/urllib3/contrib/ntlmpool.py imports ntlm which is not in dependencies.
再比如tox缺失了部份依赖
tox==4.10.0
├── cachetools [required: Any, installed: 5.3.1]
├── chardet [required: >=5.2, installed: 5.2.0]
├── colorama [required: >=0.4.6, installed: 0.4.6]
├── filelock [required: Any, installed: 3.12.4]
├── packaging [required: Any, installed: 23.1]
├── platformdirs [required: Any, installed: 2.5.4]
├── pluggy [required: Any, installed: 1.3.0]
├── pyproject-api [required: Any, installed: 1.5.1]
│ └── packaging [required: >=23, installed: 23.1]
└── virtualenv [required: >=20, installed: 20.21.1]
├── distlib [required: >=0.3.6,<1, installed: 0.3.7]
├── filelock [required: >=3.4.1,<4, installed: 3.12.4]
└── platformdirs [required: >=2.4,<4, installed: 2.5.4]
In package tox, file /usr/lib/python3.11/site-packages/tox/tox_env/python/virtual_env/package/pyproject.py imports tomli which is not in dependencies.
n package tox, file /usr/lib/python3.11/site-packages/tox/execute/local_sub_process/read_via_thread_unix.py imports select which is not in dependencies.
还有tooz等。
In package tooz, file /usr/lib/python3.11/site-packages/tooz/drivers/pgsql.py imports psycopg2 which is not in dependencies.
In package tooz, file /usr/lib/python3.11/site-packages/tooz/drivers/mysql.py imports pymysql which is not in dependencies.
依赖冗余
通过工具结果也发现了部分python包存在依赖冗余的情况,比如cheroot
{
"package_name": "python3-cheroot",
"missing_dependencies": [
"python3-six",
"python3-pyOpenSSL"
],
"extra_dependencies": []
},
结果显示存在2个冗余依赖,其中python3-six这个包是python3兼容python2兼容包,在当前版本cheroot已经完全适配。所以该依赖在上游已删除 https://github.com/cherrypy/cheroot/commit/f3170d40a699219345abb5813395ff39319fec86
而
pyOpenSSL
在
cheroot-10.0.0/stubtest_allowlist.txt
中,属于测试依赖,可以仅作为编译依赖,而在运行依赖裁剪。参考其他发行版如suse已采取过相同裁剪
https://build.opensuse.org/projects/openSUSE:Factory/packages/python-cheroot/files/python-cheroot.changes
参考:
https://gitee.com/opencloudos-stream/python-cheroot/pulls/3
循环依赖
Warning!! Cyclic dependencies found:
* sphinxcontrib-serializinghtml => Sphinx => sphinxcontrib-serializinghtml
* sphinxcontrib-htmlhelp => Sphinx => sphinxcontrib-htmlhelp
* sphinxcontrib-qthelp => Sphinx => sphinxcontrib-qthelp
* sphinxcontrib-applehelp => Sphinx => sphinxcontrib-applehelp
* sphinxcontrib-devhelp => Sphinx => sphinxcontrib-devhelp
* Sphinx => sphinxcontrib-applehelp => Sphinx
软件包存在循环依赖会使其编译构建受到依赖版本的影响,会让原本单一的依赖链变得复杂。
进阶:pipdeptree 基于新API重构
upstream pr:
https://github.com/tox-dev/pipdeptree/pull/333
背景
pipdeptree
这个项目在
tox
项目下,是除了tox本身最高星的项目,由现就职于
bloomberg
公司的gaborbernat开发,这个作者也是virtualenv, tox, platformdirs, filelock等一系列python中比较重要的社区的创始人,以及python-build等其他核心社区的maintainer,是python圈内比较知名的大佬。
要将已经deprecated的APi pkg_resources废除,替换为importlib.metadata以及packaging等,需要重构pipdeptree的核心逻辑,涉及到整个pipdeptree的代码树,所以比较复杂、坑也比较多。
pkg_resources 和 importlib.metadata 如何兼容:
基础类型
项目中每个python分发包,是一个
DistPackage
对象,他是
Package
的子类,后者是
pkg_resources.DistInfoDistribution
的子类
from pip._vendor.pkg_resources import DistInfoDistribution
DistInfoDistribution
可以通过
importlib.metadata.Distribution
替代。
但是需要注意的是
importlib.metadata.Distribution
不再有
key
以及
project_name
属性,所以涉及的地方需要替换为
metdadata.Distribution.metadata["Name"]
例如:
def __init__(self, obj: Distribution, req: ReqPackage | None = None) -> None:
super().__init__(obj.metadata["Name"])
每个分发包的依赖包,是一个
Requirement
对象
from pip._vendor.pkg_resources import Requirement
它可以通过
from packaging.requirements import Requirement
替代。
与上面一样,不再有
key
以及
project_name
属性,所以需要替代为
.name
,如
def __init__(self, obj: Requirement, dist: DistPackage | None = None) -> None:
super().__init__(obj.name)
基础类型下的属性和API
local_only and user_only
基础类型的替换,并不能完全解决问题,更多的问题是需要解决该基础类型支持的属性以及API。
iter_installed_distributions
这个API,他的作用是根据参入参数返回一个
DistInfoDistribution
列表
iter_installed_distributions(local_only: bool = True, skip: Container[str] = {'python', 'wsgiref', 'argparse'}, include_editables: bool = True, editables_only: bool = False, user_only: bool = False) -> Iterator[pip._internal.metadata.base.BaseDistribution] method of pip._internal.metadata.pkg_resources.Environment instance
Return a list of installed distributions.
涉及代码如下,我们需要解决的就是这三个参数,也就是
local_only
和
user_only
怎么通过新API来区分。
from pip._internal.metadata import pkg_resources
dists = pkg_resources.Environment.from_paths(None).iter_installed_distributions(
local_only=local_only,
skip=(),
user_only=user_only,
)
local_only
作用是区分虚拟环境和全局环境
(myenv) [root@linux ~]# python3 -c "import sys;print(sys.path)"
['', '/usr/lib64/python311.zip', '/usr/lib64/python3.11', '/usr/lib64/python3.11/lib-dynload', '/root/myenv/lib64/python3.11/site-packages', '/root/myenv/lib/python3.11/site-packages']
(myenv) [root@linux ~]# python3 -c "import sys;print(sys.prefix)"
/root/myenv
(myenv) [root@linux ~]# python3 -c "import sys;print(sys.base_prefix)"
/usr
(myenv) [root@linux ~]#
pip
中的判断逻辑如下
def _running_under_venv() -> bool:
"""Checks if sys.base_prefix and sys.prefix match.
This handles PEP 405 compliant virtual environments.
"""
return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
def _running_under_legacy_virtualenv() -> bool:
"""Checks if sys.real_prefix is set.
This handles virtual environments created with pypa's virtualenv.
"""
# pypa/virtualenv case
return hasattr(sys, "real_prefix")
def running_under_virtualenv() -> bool:
"""True if we're running inside a virtual environment, False otherwise."""
return _running_under_venv() or _running_under_legacy_virtualenv()
所以我们直接简化这部分逻辑,判断
sys.prefix
与
sys.base_prefix
是否相同,如果不相同则说明当前处在虚拟环境中,如果相同则说明处在系统环境。然后通过
site.getsitepackages()
获取指定前缀下的python路径(site-packages)。
in_venv = sys.prefix != sys.base_prefix
if local_only and in_venv:
venv_site_packages = site.getsitepackages([sys.prefix])
return list(distributions(path=venv_site_packages))
最后通过
importlib.metadata.distributions
函数找到该python路径下的所有python分发包,并返回一个
Distribution
列表 这个API是对
Distribution.discover
的封装
def distributions(**kwargs):
"""Get all ``Distribution`` instances in the current environment.
:return: An iterable of ``Distribution`` instances.
"""
return Distribution.discover(**kwargs)
后者会将接收到的内容用
Context
封装后,最终通过
sys.meta_path
里面的元数据查找器来查找系统中的所有分发包。
[root@linux ~]# python3 -c "import sys; print(sys.meta_path)"
[<_distutils_hack.DistutilsMetaFinder object at 0x7f124cd15bd0>, < class '_frozen_importlib.BuiltinImporter'>, < class '_frozen_importlib.FrozenImporter'>, < class '_frozen_importlib_external.PathFinder'>]
user_only
的作用,则是区分用户环境和全局环境 所以这里我们直接通过
site.getusersitepackages()
获取用户的site_packages目录然后一样
distributions
获取分发包列表。
if user_only:
return list(distributions(path=[site.getusersitepackages()]))
依赖获取API Distribution.requires VS DistInfoDistribution.requires()
DistInfoDistribution.requires() 会直接返回
pip._vendor.pkg_resources.Requirement
类型。而Distribution.requires :
https://github.com/python/cpython/blob/3.12/Lib/importlib/metadata/__init__.py#L558
则会返回纯字符串。
@property
def requires(self):
"""Generated requirements specified for this Distribution"""
reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
return reqs and list(reqs)
所以我们需要对获取的字符串进行处理,再将其转换为
packaging.requirements.Requirement
对象。
def requires(self) -> list[Requirement]:
req_list = []
req_name_list = []
if self._obj.requires:
for r in self._obj.requires:
req = Requirement(r)
is_extra_req = req.marker and contains_extra(str(req.marker))
if not is_extra_req and req.name not in req_name_list:
req_list.append(req)
req_name_list.append(req.name)
return req_list
并且需要注意的是,
Distribution.requires
现在返回的依赖中会包含
marker
,例如下面示例
"pytest ; extra == 'tests'"
根据讨论1: https://github.com/tox-dev/pipdeptree/pull/333#discussion_r1527662006
以及
讨论2: https://github.com/tox-dev/pipdeptree/pull/333#discussion_r1527881146
我们目前只需要保证主要的依赖就可以,后续如果需要支持
marker
再继续拓展。
备注:
这里的
marker
主要的含义就是,安装包的时候可能需要的某些附加功能,例如:如果需要安装CT3的markdown相关功能 需要在安装的时候指定
pip install CT3[markdown]
因为CT3的METADATA中指定了
Provides-Extra: filters
Requires-Dist: markdown ; extra == 'filters'
Provides-Extra: markdown
Requires-Dist: markdown ; extra == 'markdown'
Marker
包含了
extra
以及其他的例如python版本限定
python_version < 3.11
等。不同的
extra
会有单独的依赖,这就意味着比如CT3这个模块,他是可以不支持markdown能力的,所以主模块并不是一定需要该依赖,这里也是为什么社区建议先不考虑
extra
的原因。如果这里需要将
extra
也列出来,有以下几个办法:
断当前系统中的主包是如何安装的
判断如果extra在系统中,就认为此功能已启用,就将其列到依赖中
FrozenRequirement.from_dist兼容
as_frozen_repr
函数中使用的
metadata.pkg_resources.Distribution
这个API传入
FrozenRequirement.from_dist
时会使用到以下几个属性,但是
DistPackage
以及
importlib.metadata.Distribution
都没有,所以这里我们需要自己实现。
@property
def editable(self) -> bool:
return bool(self.editable_project_location)
@property
def direct_url(self) -> DirectUrl | None:
direct_url_metadata_name = "direct_url.json"
result = None
try:
j_content = self._obj.read_text(direct_url_metadata_name)
except FileNotFoundError: # pragma: no cover
return result
try:
if j_content:
result = DirectUrl.from_json(j_content)
except (
UnicodeDecodeError,
json.JSONDecodeError,
DirectUrlValidationError,
):
return result
return result
@property
def raw_name(self) -> str:
return self.project_name
@property
def editable_project_location(self) -> str | None:
direct_url = self.direct_url
if direct_url and direct_url.is_local_editable():
from pip._internal.utils.urls import url_to_path # noqa: PLC2701, PLC0415
return url_to_path(direct_url.url)
result = None
egg_link_path = egg_link_path_from_sys_path(self.raw_name)
if egg_link_path:
with Path(egg_link_path).open("r") as f:
result = f.readline().rstrip()
return result
这部分的兼容参考这里的讨论:
https://github.com/tox-dev/pipdeptree/pull/333#discussion_r1533235445
。我们需要做的是获取python包对应的direct_url.json文件,然后将其中的数据解析出来赋值给
DistPackage
的成员,具体实现参考了
pip
中的
direct_url
实现,见 源码:
https://github.com/pypa/pip/blob/f5e4ee104e7b171a7cfb2843c9c602abf7a4e346/src/pip/_internal/metadata/base.py#L289
。
同时还需要自己实现
editable_project_location
接口,这个接口的实现参考 链接:
https://github.com/pypa/pip/blob/f5e4ee104e7b171a7cfb2843c9c602abf7a4e346/src/pip/_internal/utils/egg_link.py#L33
Python分发包的可编辑模式和DirectUrl模块
这里简单介绍一下Python中的
DirectUrl
模块,这个模块主要是对
direct_url.json
文件的解析和使用。首先,并不是所有python包都会有
direct_url.json
文件,常见的是通过
URLs
也就是链接安装的分发包才会有,这个链接可以是本地链接,也可以是远端链接。见 Direct URL 介绍:
https://packaging.python.org/en/latest/specifications/direct-url-data-structure/
# pip install -e munkres-1.1.4/
然后你就会在python目录下看到他的
.dist-info
目录中存在
direct_url.json
# ls /usr/local/lib/python3.11/site-packages/munkres-1.1.4.dist-info/
INSTALLER LICENSE.md METADATA RECORD REQUESTED WHEEL direct_url.json top_level.txt
这个文件中记录了这个分发包的真正的路径
url
以及他是否属于
可编辑
模式
editable
。
{"dir_info": {"editable": true}, "url": "file:///data/gitee/python-munkres/munkres-1.1.4"}
Python中的
editable
,指的是直接将项目源码链接到python目录(通常为site-package),常用于项目开发阶段,不用每次修改代码后再走安装流程,修改的代码会直接生效。除了上面说的通过pip install URLs之外,还有另一种
.egg-link
机制。当你在python源码路径中执行
python3 setup.py develop
的时候,会出现下面一段日志。
running egg_info
writing munkres.egg-info/PKG-INFO
writing dependency_links to munkres.egg-info/dependency_links.txt
writing top-level names to munkres.egg-info/top_level.txt
reading manifest file 'munkres.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
adding license file 'LICENSE.md'
writing manifest file 'munkres.egg-info/SOURCES.txt'
running build_ext
Creating /usr/local/lib/python3.11/site-packages/munkres.egg-link (link to .)
munkres 1.1.4 is already the active version in easy-install.pth
Installed /data/gitee/python-munkres/munkres-1.1.4
Processing dependencies for munkres==1.1.4
Finished processing dependencies for munkres==1.1.4
他会在
/usr/local/lib/python3.11/site-packages/
下创建一个
munkres.egg-link
,这个
.egg-link
文件中写的就是项目源码的路径
# cat /usr/local/lib/python3.11/site-packages/munkres.egg-link
/data/gitee/python-munkres/munkres-1.1.4
.
所以源码中
self.location
的值就可以通过读取该
.egg-link
文件获得。这样,这段逻辑就可以重写为下面这样。
@property
def editable_project_location(self) -> str | None:
if self.direct_url:
from pip._internal.utils.urls import url_to_path # noqa: PLC2701, PLC0415
return url_to_path(self.direct_url)
egg_link_path = egg_link_path_from_sys_path(self.raw_name)
if egg_link_path:
with Path(egg_link_path).open("r") as f:
location = f.readline().rstrip()
return location
return None
版本以及比较运算符
pkg_resources.Requirement
有一个参数
specs
,返回的是
a>=1.2
中的
>=1.2
也就是版本控制字段。见代码:
https://github.com/pypa/pip/blob/f5e4ee104e7b171a7cfb2843c9c602abf7a4e346/src/pip/_vendor/pkg_resources/__init__.py#L3153
替换为
packaging.requirements.Requirement
的
specifier
字段,参考手册:
https://packaging.pypa.io/en/stable/specifiers.html
。但是需要注意的是,这里返回的是
SpecifierSet
对象,需要转成str类型处理。
@property
def version_spec(self) -> str | None:
result = None
specs = sorted(map(str, self._obj.specifier), reverse=True)
if specs:
result = ",".join(specs)
return result
但是进行版本对比的时候最好还是使用对象,使用自带的对比方法,如
if ver_spec:
req_obj = SpecifierSet(ver_spec)
else:
return False
return self.installed_version not in req_obj
测试用例修复
Mock介绍
Mock
模块,是python单测里经常用到的模块,它可以模仿一个假的函数或者对象。
模仿函数
例如下面这样,规定了
foo.read_text
这个函数的返回值为
json_text
,也就是只要执行了
foo.read_text
,他的返回值一定是
json_text
foo.read_text = Mock(return_value=json_text)
模仿对象
Mock还可以模仿一个对象,制定了模仿对象的属性之后,其他代码中如果调用
foo.metadata["Name"]
,返回的就是
foo
。
foo = Mock(metadata={"Name": "foo"}, version="20.4.1")
所以,我们通过这种方式,模拟出了一个完整的流程,从对象的属性,到函数方法,这样,测试的目的函数就可以返回我们期望的内容。例如:
def test_dist_package_render_as_root_with_frozen() -> None:
json_text = '{"dir_info": {"editable": true}, "url": "file:///A/B/foo"}'
foo = Mock(metadata={"Name": "foo"}, version="20.4.1")
foo.read_text = Mock(return_value=json_text)
dp = DistPackage(foo)
is_frozen = True
expect = "# Editable install with no version control (foo===20.4.1)\n-e /A/B/foo"
assert dp.render_as_root(frozen=is_frozen) == expect
Mock不支持name属性设置
因为Mock类本身限制问题,
Mock(name=XXX)
的声明无效,即调用其
name
属性时会得到一个
Mock.name
对象。所以这里需要
MagicMock
的介入,比如
- result = ReqPackage(mocker.MagicMock(key="setuptools")).installed_version
+ r = MagicMock()
+ r.name = "setuptools"
+ result = ReqPackage(r).installed_version
python虚拟环境测试不通过
如这里:
https://github.com/tox-dev/pipdeptree/pull/333/#issuecomment-2018311809
所说,通过
virtualenv.cli_run
去运行一个虚拟环境,并且在其中执行命令时,需要通过
pytest.CaptureFixture
去捕捉结果,但是代码中只捕获了正常输出,丢弃了异常输出,导致实际报错的命令并没有显示错误。
out, _ = capfd.readouterr()
最后定位为新增了新的API依赖packaging,导致虚拟环境缺少依赖命令执行错误。需要在测试环境中安装packaging以解决。
- expected = {"pip", "setuptools", "wheel"}
+ expected = {"packaging", "pip", "setuptools", "wheel"}
进一步优化:因为python的测试环境最好不与外网联通,所以在测试环境里
pip install packaging
的方式非常的且不稳定。
所以,我们决定对工具的
--python
选项逻辑进行重构。通过将外部环境(运行pipdeptree的环境)中的
packaging
「偷"进测试空间。
packaging_src = getsourcefile(sys.modules["packaging"])
assert packaging_src is not None
packaging_root = Path(packaging_src).parent
copytree(packaging_root, dest / "packaging")
cmd = [str(py_path), "-m", "pipdeptree", *argv]
env = os.environ.copy()
return call(cmd, cwd=project, env=env)
充分利用
python -m
会将当前目录加入
sys.path
这一特性。如下:
By default, as initialized upon program startup, a potentially unsafe path is prepended to [`sys.path`](https://docs.python.org/3/library/sys.html#sys.path) (before the entries inserted as a result of [`PYTHONPATH`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH)):
- `python -m module` command line: prepend the current working directory.
- `python script.py` command line: prepend the script’s directory. If it’s a symbolic link, resolve symbolic links.
- `python -c code` and `python` (REPL) command lines: prepend an empty string, which means the current working directory.
模拟虚拟环境
因为pipdeptree中
--local-only
选项的存在,我们需要模拟工具在虚拟环境中的执行。Python的虚拟环境,其实就是将当前的
sys.prefix
设置为自定义目录,然后使用虚拟环境中的一套python套件。所以,我们通过以下方式模拟了
pipdeptree
在虚拟环境中执行的效果。通过
monkeypatch
来设置一些环境变量,例如
sys.prefix
以及传入的参数
argv
。
deftest_local_only(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capfd: pytest.CaptureFixture[str],
) -> None:
prefix = str(tmp_path / "venv")
result = virtualenv.cli_run([str(tmp_path / "venv"), "--activators", ""])
pip_path = str(result.creator.exe.parent / "pip")
subprocess.run(
[pip_path, "install", "wrapt", "--prefix", prefix],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
cmd = [str(result.creator.exe.parent / "python3")]
monkeypatch.chdir(tmp_path)
cmd += ["--local-only"]
monkeypatch.setattr(sys, "prefix", [str(tmp_path / "venv")])
monkeypatch.setattr(sys, "argv", cmd)
main()
out, _ = capfd.readouterr()
found = {i.split("==")[0] for i in out.splitlines()}
expected = {"wrapt", "pip", "setuptools", "wheel"}
if sys.version_info >= (3, 12):
expected -= {"setuptools", "wheel"} # pragma: no cover
assert found == expected
总结
完成上述核心代码的重构后,得到了包括 gaborbernat 在内的两位核心maintainer的认可。最终接收了邀请,成为该项目的一员。
扫码添加 「
鹅厂架构师小客服
」 ,加入【
鹅厂架构师圈
】,与技术爱好者、技术关注者分享交流,共同进步成长,欢迎大家!↓↓↓
关于我们
技术分享:关注微信公众号 【鹅厂架构师】