Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

search for needed package files locally instead of using pip every time #29

Open
TsXor opened this issue Jun 2, 2024 · 11 comments
Open

Comments

@TsXor
Copy link

TsXor commented Jun 2, 2024

Requirements of a package can be searched with importlib.metadata.requires and files can be searched with importlib.metadata.files, so it is possible to gather needed files without using pip to install into a virtual environment.

Here is a script in my project that gathers needed files into cache directory and uses zipapps to pack. Just to illustate how to search package files with the way mentioned above. This script works on Python 3.11, earlier versions may use backported importlib-metadata instead.

import os, sys, platform
from shutil import rmtree
from importlib.util import find_spec
from importlib.metadata import files as pkg_files, requires as pkg_requires
from packaging.requirements import Requirement
from pathlib import Path

import yaml
from zipapps import ZipApp

HERE = Path(__file__).parent
PROJ_ROOT = HERE.parent
sys.path.append(str(PROJ_ROOT))
PROJ_DIST = PROJ_ROOT / 'dist'
PYVER = sys.version_info

def module_exists(modname: str):
    try: return find_spec(modname) is not None
    except: return False

def slash_to_dot(modname: str):
    return modname.replace('/', '.').replace('\\', '.')

def top_name(modname: str):
    return modname.split('.', maxsplit=1)[0]

def get_module_path(modname: str):
    spec = find_spec(modname)
    if spec is None: raise ValueError
    if spec.origin is None: raise ValueError
    file_path = Path(spec.origin)
    if file_path.name == '__init__.py':
        return file_path.parent
    return file_path

def read_yaml(pth: Path):
    return yaml.load(pth.read_text(encoding='utf-8'), yaml.CLoader)

def check_req(req: Requirement, extras: set[str]):
    return not req.marker or req.marker.evaluate({'extra': ''}) \
        or any(req.marker.evaluate({'extra': extra}) for extra in extras)

def search_direct_deps(pkg: Requirement) -> list[Requirement]:
    try: reqexprs = pkg_requires(pkg.name)
    except: return []
    if reqexprs is None: return []
    return [req for req in map(Requirement, reqexprs) if check_req(req, pkg.extras)]

def search_all_deps(pkgs: list[Requirement]):
    result = dict[str, set[str]]()
    stack = [iter(pkgs)]
    while stack:
        meet = next(stack[-1], None)
        if meet is None:
            stack.pop(); continue
        met_extras = result.get(meet.name, None)
        if met_extras is None:
            result[meet.name] = meet.extras.copy()
            stack.append(iter(search_direct_deps(meet)))
        else:
            meet.extras -= met_extras
            if meet.extras:
                met_extras.update(meet.extras)
                stack.append(iter(search_direct_deps(meet)))
    return result

def copy_package(pkgname: str, dest: Path):
    pkgfiles = pkg_files(pkgname)
    if pkgfiles is None: return
    dest_abs = dest.resolve()
    for pkgfile in pkgfiles:
        file_abs = (dest_abs / pkgfile).resolve()
        if not file_abs.is_relative_to(dest_abs): continue
        file_abs.parent.mkdir(parents=True, exist_ok=True)
        file_abs.write_bytes(pkgfile.read_binary())


PROJ_DIST.mkdir(exist_ok=True)
config = read_yaml(HERE / f'{sys.argv[1]}.yaml')
main_name: str = config['main']
file_name = f'{main_name}_py{PYVER.major}{PYVER.minor}'
modules: list[str] = [main_name, *config['modules']]
print(f'递归查找包的依赖...')
packages = search_all_deps([Requirement(pkg.lower()) for pkg in config['site-packages']])
print(f'已找到依赖的包:{packages}')
module_paths = [get_module_path(name) for name in modules]
cache_path = PROJ_DIST / f'{file_name}.cache'
if cache_path.exists(): rmtree(cache_path)
cache_path.mkdir(exist_ok=True)
print('复制依赖的包到缓存目录...')
for pkg in packages: copy_package(pkg, cache_path)

ZipApp(
    compressed=True,
    cache_path=str(cache_path),
    includes=','.join(str(p) for p in module_paths),
    main=f'{main_name}.__main__',
    output=str(PROJ_DIST / f'{file_name}.pyz'),
    unzip='AUTO',
).build()

An example config file for this script:

main: mymod
modules:
  - local_module
site-packages:
  - ipython
  - pyyaml
  - pillow
  - prompt-toolkit
  - pycryptodome
  - requests
  - zipp
@ClericPy
Copy link
Owner

ClericPy commented Jun 2, 2024

importlib.metadata 这俩还真没研究过,我实际做的东西很少,就是实现了类似 shiv 的功能,然后加一个延迟安装的功能进去,使它运行时安装依赖,可以减轻打包体积和重复安装的浪费时间,一开始是给 hadoop 和 Serverless 里用的

我研究研究你说的metadata

@ClericPy
Copy link
Owner

ClericPy commented Jun 2, 2024

importlib.metadata 用起来挺现代的,就是向后兼容(包括老pip 元数据相关)不太好确定,zipapps 一开始面向无依赖+兼容3.6 是因为公司用的老服务器上只有 3.6 环境,兼容性风险还是挺大的。惰性安装也是为了跨平台+跨版本发布用的

倒是学到新知识了

@TsXor
Copy link
Author

TsXor commented Jun 3, 2024

importlib.metadata 用起来挺现代的,就是向后兼容(包括老pip 元数据相关)不太好确定,zipapps 一开始面向无依赖+兼容3.6 是因为公司用的老服务器上只有 3.6 环境,兼容性风险还是挺大的。惰性安装也是为了跨平台+跨版本发布用的

倒是学到新知识了

对于较低版本,可以使用移植的importlib-metadata,但是pypi页面显示这个包最早也只能支持到3.8。

我提出这个issue主要是想优化内置依赖的打包。在打包端已经安装需要的包时,就不需要再重新下载一遍了。

@TsXor
Copy link
Author

TsXor commented Jun 3, 2024

另外,当前版本的zipapps不会复制元数据,而用importlib-metadata查找到的包文件是包含元数据的,这样查找可以修复#28

@ClericPy
Copy link
Owner

ClericPy commented Jun 3, 2024

另外,当前版本的zipapps不会复制元数据,而用importlib-metadata查找到的包文件是包含元数据的,这样查找可以修复#28

非惰性安装走的 pip install -t 的方式,没想到元数据居然不带进去,这有点麻烦

importlib-metadata 这个库破坏了 zipapps 一开始的 “无第三方依赖” 的初衷,而且收益和风险不成比例,暂时不打算带进去

你提到的避免重新下载,如果一开始就在 venv 里打包的话,直接把 site-packages 目录带过去可以勉强做一下,惰性安装其实多次安装也是通过 md5 避免重复安装的(这部分在模块化重构)

一开始做 zipapps 简单理解就是

  1. 它要足够小,所以纯 py 代码无依赖实现的,方便 pyz 自己,然后在共享盘(EFS)里直接引用
  2. shiv 当初的版本会解压所有内容,也就是 zipapps 里的 -u=* 的模式,实际上多数情况只有 .so/.pyd 这些无法直接在 pyz 文件里被调用,所以做了 -u=AUTO 模式来替换
  3. 公司里遇到个情况就是打包好的项目被不同系统、不同python版本调用,所以做了个惰性安装的功能,使同一个 pyz 可以根据系统和版本重新安装对应的 pypi 库,解决不兼容的问题
  4. shiv 早期不能指定缓存目录,而且同一个 app 因为打包的 build id 变了导致 home 目录下的 .shiv 文件夹超过 10GB,把系统盘弄崩了,所以加上了 -up 自定义指定路径的方式,以及一些别名解压到相对路径或临时目录

以上都是工作中遇到的麻烦事情,各种 bug 层出不穷的。至于 importlib-metadata,暂时还是遵循奥卡姆剃刀原则,毕竟这个库的主要工作是打包和发布,重新通过 pip 安装相对来说比较干净,我就遇到过同事乱改 site-packages 源码导致的线上 P0 问题,通宵查了一夜都没解决。

所以提速的话可以提前都做成 wheel,或者提前 pip install -t 到特定目录用的时候 includes 进去,依赖 metadata 虽然自动化程度高了,还是有一定风险

@TsXor
Copy link
Author

TsXor commented Jun 3, 2024

它要足够小,所以纯 py 代码无依赖实现的,方便 pyz 自己,然后在共享盘(EFS)里直接引用

一定要追求无依赖的话,可以复制并固定importlib-metadata中的一部分。分发包的元数据规范和Python版本没什么关系。

我就遇到过同事乱改 site-packages 源码导致的线上 P0 问题,通宵查了一夜都没解决。

What can I say.

所以提速的话可以提前都做成 wheel,或者提前 pip install -t 到特定目录用的时候 includes 进去,依赖 metadata 虽然自动化程度高了,还是有一定风险

我大概应该将这个issue开头放的那段脚本做成一种类似独立可选插件的东西。毕竟直接从已安装的包中提取所需包的文件确实是一个需求,我不希望每个人都拿开头那个脚本魔改一个自己的版本。

@ClericPy
Copy link
Owner

ClericPy commented Jun 4, 2024

另外,当前版本的zipapps不会复制元数据,而用importlib-metadata查找到的包文件是包含元数据的,这样查找可以修复#28

刚才偶然发现,我这边 zipapps 打包的时候会清理 *.dist-info __pycache__ 这两个 pip 安装后的垃圾文件,这东西你平时会用到?

@TsXor
Copy link
Author

TsXor commented Jun 4, 2024

刚才偶然发现,我这边 zipapps 打包的时候会清理 *.dist-info __pycache__ 这两个 pip 安装后的垃圾文件,这东西你平时会用到?

*.dist-info就是元数据。破案了,不是pip -t不带元数据,是当垃圾文件删了。

@ClericPy
Copy link
Owner

ClericPy commented Jun 4, 2024

刚才偶然发现,我这边 zipapps 打包的时候会清理 *.dist-info __pycache__ 这两个 pip 安装后的垃圾文件,这东西你平时会用到?

*.dist-info就是元数据。破案了,不是pip -t不带元数据,是当垃圾文件删了。

需要保留吗,需要的话我留个可选项

@TsXor
Copy link
Author

TsXor commented Jun 4, 2024

需要保留吗,需要的话我留个可选项

包的元数据大部分情况下是不会影响运行的,这就是为什么它会被当做垃圾文件。
但是,importlib.metadataimportlib.resourcessetuptools.pkg_resources这些模块对包信息的查找需要保留元数据,而且在运行时搜索元数据是有实际用途的,比如用入口点组实现插件机制
所以可以加个选项来指定保留哪些包的元数据。

@ClericPy
Copy link
Owner

ClericPy commented Jun 4, 2024

需要保留吗,需要的话我留个可选项

包的元数据大部分情况下是不会影响运行的,这就是为什么它会被当做垃圾文件。 但是,importlib.metadataimportlib.resourcessetuptools.pkg_resources这些模块对包信息的查找需要保留元数据,而且在运行时搜索元数据是有实际用途的,比如用入口点组实现插件机制。 所以可以加个选项来指定保留哪些包的元数据。

嗯,一直没用过metadata做什么事情,我加个默认值那两个文件夹,不需要清理就传入 [] 就行了,今天发个版带上它

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants