当前位置: 欣欣网 > 码农

pipdeptree - 从初学者到maintainer

2024-04-29码农

导语: 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 库,其中核心的为下面两部分

    1. Environment.from_paths(None).iter_installed_distributions 获取当前环境中所有的python包(dist)

    from pip._internal.metadata import pkg_resourcesdists = 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 DistInfoDistributiondef 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" } ] },

    1. 但是需要注意的是,pkg_resource生成依赖,并不会包括 tox.ini 也就是测试所需的依赖,这个问题需要进一步分析原因以及其合理性。
      (上述问题,经测试并不是问题,因为tox中的为测试依赖,在rpm编译系统中应该只出现在编译环节,并不应该作为运行依赖,所以是依赖冗余,应该删除)

    2. 需要一并注意的是, 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 jsonimport subprocessimport reFILTERED_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_namedef 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 astimport importlib.metadataimport importlib.resourcesimport jsonimport osimport sysimport 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 importsdef 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 brotliexcept 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_resourcesdists = 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: filtersRequires-Dist: markdown ; extra == 'filters'Provides-Extra: markdownRequires-Dist: markdown ; extra == 'markdown'

    Marker 包含了 extra 以及其他的例如python版本限定 python_version < 3.11 等。不同的 extra 会有单独的依赖,这就意味着比如CT3这个模块,他是可以不支持markdown能力的,所以主模块并不是一定需要该依赖,这里也是为什么社区建议先不考虑 extra 的原因。如果这里需要将 extra 也列出来,有以下几个办法:

    1. 断当前系统中的主包是如何安装的

    2. 判断如果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_infowriting munkres.egg-info/PKG-INFOwriting dependency_links to munkres.egg-info/dependency_links.txtwriting top-level names to munkres.egg-info/top_level.txtreading 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_extCreating /usr/local/lib/python3.11/site-packages/munkres.egg-link (link to .)munkres 1.1.4 is already the active version in easy-install.pthInstalled /data/gitee/python-munkres/munkres-1.1.4Processing dependencies for munkres==1.1.4Finished 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 Nonepackaging_root = Path(packaging_src).parentcopytree(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 coverassert found == expected

    总结

    完成上述核心代码的重构后,得到了包括 gaborbernat 在内的两位核心maintainer的认可。最终接收了邀请,成为该项目的一员。

    扫码添加 「 鹅厂架构师小客服 」 ,加入【 鹅厂架构师圈 】,与技术爱好者、技术关注者分享交流,共同进步成长,欢迎大家!↓↓↓

    关于我们

    技术分享:关注微信公众号 【鹅厂架构师】