如何使用Poetry(1.2+)管理Python虚拟环境

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第3天, 点击查看活动详情

一个项目最重要的就是跑起来, 大家基本会同时在本地开发多个项目, 而每个项目用到的环境都是不一样的, 如果这些项目都共用一份依赖那么会导致多个项目的依赖发生冲突以及导致线上服务不稳定,所以就需要用到虚拟环境隔离。在 Python 中提供了名为 venv 的虚拟环境管理包用于做多个项目的环境隔离,它提供了很多基础的功能,但是还有很多功能都需要开发者手动操作非常不方便,这时候就可以用到 Poetry 啦。

本文是 保障Python项目质量的工具 文章中 项目环境管理-Poetry 的拓展版,由于 Poetry 在1.2后发生了一些大变化,所以本文的内容不保证能支持 Poetry 1.2以下的版本。

1.最初的开始

在未依赖任何外部工具时,通常都会使用自带的工具来初始化项目的环境,如下:

➜  ~ cd demo
➜  demo  
➜  demo  python3 -m venv .venv
➜  demo  source .venv/bin/activate
➜  demo  python3 -m pip install --upgrade pip setuptools wheel
➜  demo  python3 -m pip install -r requirements.dev.txt

这几个命令中,由venv完成初始化和使用虚拟环境,再由pip命令来安装包含测试环境的依赖。 这些工具都能正常的使用,但是却有几个弊端:

  • 直接使用python3命令,无法确定准确的Python的版本,导致本地的Python版本与其他人或者服务器的版本不同步。
  • 每次都要显式的进入和切换虚拟换(Pycharm会默认读取到虚拟环境)。
  • 通过pip来管理依赖,无法完成依赖管理,也无法自动的对依赖进行分组,如区分测试依赖和正式依赖等。
  • 此外对于打包,推送包之类的功能还需要开发者去手动编辑文件再通过繁杂的命令去处理,这是非常麻烦的,而Poetry对很多重复且需要开发者手动操作都步骤都统一起来,提供一些命令方便开发者去操作。

    Poetry的安装非常简单,一条命令就可以搞定了,不同系统的安装教程官方已经说得很详细,具体见官方安装文档

    2.如何使用Poetry初始化项目环境

    如果这是第一个项目,那么可以使用poetry new {项目名}的命令来创建项目,如下:

    ➜  ~ poetry new demo
    Created package demo in demo
    

    这时Poetry会在当前目录创建一个demo目录,demo目录里面的结构如下:

    ➜  ~ cd demo
    ➜  demo tree
    ├── demo
    │   └── __init__.py
    ├── pyproject.toml
    ├── README.md
    └── tests
        └── __init__.py
    2 directories, 4 files
    

    可以看到Poetry会帮忙创建一个最小项目的结构,其中demo是我们本次要开发的目录,README.md是一个项目描述文档,但它是空的,等着我们去填写,tests则是一个测试用例的目录,而pyproject.tomlPython项目相关的一些配置,poetry会预写一些项目的最小信息,如下:

    [tool.poetry]
    name = "demo"
    version = "0.1.0"
    description = ""
    authors = ["so1n <qaz6803609@163.com>"]
    readme = "README.md"
    [tool.poetry.dependencies]
    python = "^3.7"
    [build-system]
    requires = ["poetry-core"]
    build-backend = "poetry.core.masonry.api"
    

    其中作者信息是通过git config里面的配置获取的。

    如果是在已有项目下使用Poetry则可以通过poetry init命令,这样Poetry就会通过一个交互式命令行来协助开发者创建项目信息和虚拟环境,不过在创建之前要先确保系统上拥有自己想要的Python版本,Poetry是不会负责Python版本的管理的,需要开发者通过手动处理,pyenv,conda等命令来完成这一步操作,具体的poetry init操作如动图: 可以看到如果没有填写值得话,Poetry会默认为你填写一些值,我在某些选项中填入了一些自己的值,最后pyproject.toml会生成如下内容:

    [tool.poetry]
    name = "demo"
    version = "0.0.1"
    description = "My demo project"
    authors = ["so1n <qaz6803609@163.com>"]
    license = "Apache"
    readme = "README.md"
    [tool.poetry.dependencies]
    python = "^3.9.10"
    [build-system]
    requires = ["poetry-core"]
    build-backend = "poetry.core.masonry.api"
    

    至此,项目初始化完毕,可以开始编写我们的项目了,但是当用PyCharm打开项目的时候会发现有如图的提醒:

    这个提醒是PyCharm找不到Poetry的可执行文件,需要通过我们指定Poetry的路径才可以执行,这时候可以通过命令:

    which poetry
    

    来获取Poetry的可执行路径并填写到弹窗里面,之后PyCharm就会调用Poetry进行虚拟环境初始化,并在当前路径创建.venv文件夹,通过poetry env info获取当前项目的虚拟环境:

    ➜  demo poetry env info 
    Virtualenv
    Python:         3.9.10
    Implementation: CPython
    Path:           /home/so1n/demo/.venv
    Executable:     /home/so1n/demo/.venv/bin/python
    Valid:          True
    System
    Platform:   linux
    OS:         posix
    Python:     3.9.10
    Path:       /usr/local
    Executable: /usr/local/bin/python3.9
    
  • 1.如果想在已有的项目下使用Poetry则需要先删除当前的虚拟环境,并通过命令poetry env use 3.9.10
  • 2.如果创建的虚拟环境版本不是自己想要的,且是通过pyenv管理Python版本,那么可以通过python-poetry.org/blog/announ…
  • 3.如果第二点仍然无法解决问题,可以采用如下命令显示的指导Poetry使用到正确的Python版本:
    pyenv local 3.9.10
    poetry env use $(pyenv which python)
    

    3.编写与运行项目

    虚拟环境初始化完成后就可以开始编写项目了,这个demo项目很简单,就是获取一个网站当前的状态,需要用到一个名为httpx的包,这时可以通过命令:

    poetry add httpx
    

    来安装这个包,当命令执行完毕后,可以发现pyproject.toml文件新增了一行关于httpx包的版本描述:

    [tool.poetry.dependencies]
    python = "^3.9.10"
    poetry = "^1.2.2"
    httpx = "^0.23.1"   # <------ 新增
    

    这段描述意味着httpx的版本会锁定在0.23.1这个版本中。

    安装好依赖后,在demo/__init__.py编写如下代码:

    import httpx
    async def get_status_code() -> int:
        async with httpx.AsyncClient() as client:
            resp: httpx.Response = await client.get("https://so1n.me")
            return resp.status_code
    def main():
        import asyncio
        print(asyncio.run(get_status_code()))
    if __name__ == "__main__":
        main()
    

    这段代码非常简单,就是请求https://so1n.me并打印对应的HTTP状态码,接着可以在终端通过命令直接运行,不用再通激活虚拟环境,运行结果如下:

    ➜  demo poetry run python demo/__init__.py
    

    不过这段命令比较长,经常这样输入会比较麻烦,这时可以采用Poetry的脚本功能,只需要向pyproject.toml文件追加如下内容:

    [tool.poetry.scripts]
    demo = 'demo.__init__:main'
    

    这段内容中的demo = 'demo.__init__:main'以等号分成两边,左边的demo是脚本key,这意味着在tool.poetry.scripts中不能出现相同的Key,而等号右边的demo.__init__:main则代表要执行demo目录下的__init__.pymain函数。 接下来可以通过poetry run {脚本key}语法来执行我们想要跑的代码,执行效果如下:

    ➜  demo poetry run demo
    

    除此之外,还可以通过poetry install的方式来让Poetry创建我们的demo脚本:

    ➜  demo poetry install
    Installing dependencies from lock file
    No dependencies to install or update
    Installing the current project: demo (0.0.1)
    ➜  demo poetry run which demo
    /home/so1n/demo/.venv/bin/demo
    ➜  demo cat /home/so1n/demo/.venv/bin/demo
    #!/home/so1n/demo/.venv/bin/python
    import sys
    from demo.__init__ import main
    if __name__ == '__main__':
        sys.exit(main())
    

    可以看到Poetry/home/so1n/demo/.venv/bin/demo路径下创建了一个脚本文件,如果这时候如果通过poetry shell进入带有当前虚拟环境的交互shell,则可以通过demo直接执行我们的代码,如下:

    ➜  demo poetry shell
    Spawning shell within /home/so1n/demo/.venv
    ➜  demo . /home/so1n/demo/.venv/bin/activate
    (demo-py3.9.10) ➜  demo pwd
    /home/so1n/demo/demo
    (demo-py3.9.10) ➜  demo demo  
    

    4.运行测试用例

    项目编写完成后需要确保我们的代码符合规范以及需要补充对应的测试用例,这是一个良好的习惯,在Python生态中,常用的测试框架是pytest,常用的检查代码规范则是通过pre-commit去执行的。

    可以通过保障Python项目质量的工具了解有什么提升代码质量的工具以及如何使用pre-commit

    poetry可以通过如下方式安装pytest, pytest-asynciopre-commit:

    poetry add --group=dev pre-commit pytest pytest-asyncio
    

    执行完命令后可以发现pyproject.toml新增了如下内容:

    [tool.poetry.group.dev.dependencies]
    pytest = "^7.2.0"
    pre-commit = "^2.20.0"
    pytest-asyncio = "^0.20.2"
    

    这块内容表示Poetry托管的虚拟环境安装了pytestpre-commit的依赖,但是他们是属于dev组的,在正式情况下不会被使用,这对于导出依赖时非常有作用。

    接下来先检查项目的代码格式,首先创建.pre-commit-config.yaml文件,并输入如下内容

    repos:
      - repo: https://github.com/pre-commit/mirrors-mypy
        rev: v0.910
        hooks:
          - id: mypy
      - repo: https://github.com/PyCQA/isort
        rev: 5.9.3
        hooks:
          - id: isort
      - repo: https://github.com/psf/black
        rev: 21.7b0
        hooks:
          - id: black
      - repo: https://github.com/PyCQA/flake8
        rev: 3.9.2
        hooks:
          - id: flake8
      - repo: https://github.com/myint/autoflake
        rev: v1.4
        hooks:
          - id: autoflake
            args: ['--in-place', '--remove-all-unused-imports', '--remove-unused-variable', '--ignore-init-module-imports']
      - repo: https://github.com/pre-commit/pre-commit-hooks
        rev: v3.2.0
        hooks:
          - id: check-ast
          - id: check-byte-order-marker
          - id: check-case-conflict
          - id: check-docstring-first
          - id: check-executables-have-shebangs
          - id: check-json
          - id: check-yaml
          - id: debug-statements
          - id: detect-private-key
          - id: end-of-file-fixer
          - id: trailing-whitespace
          - id: mixed-line-ending
    

    然后执行如下命令:

    ➜  demo git init
    ➜  demo git:(master) ✗ poetry run pre-commit run --all-file
    

    其中第一个命令是为项目进行git初始化,第二个命令是执行代码检查。

    如果出现ModuleNotFoundError: No module named '_sqlite3'错误,可以访问No module named _sqlite3了解如何解决。

    执行完代码检查后,在tests目录里面创建一个名为test_demo.py文件,并输入如下内容:

    import pytest
    from demo import get_status_code
    pytestmark = pytest.mark.asyncio
    class TestDemo:
        async def test_demo(self):
            assert 200 == await get_status_code()
    

    接着运行poetry run pytest --capture=no -v执行测试结果:

    ➜  demo git:(master) ✗ poetry run pytest --capture=no -v
    ============================ test session starts =============================
    platform linux -- Python 3.9.10, pytest-7.2.0, pluggy-1.0.0 -- /home/so1n/demo/.venv/bin/python
    cachedir: .pytest_cache
    rootdir: /home/so1n/demo
    plugins: anyio-3.6.2, asyncio-0.20.2
    asyncio: mode=strict
    collected 1 item                                                             
    tests/test_demo.py::TestDemo::test_demo PASSED
    ============================= 1 passed in 0.32s ==============================
    

    发现测试正常,接下来可以生成项目的依赖,使其他没有使用Poetry的环境也可以读取到项目的依赖,对应的命令如下:

    poetry export -o requirements.txt --without-hashes --with-credentials
    poetry export -o requirements-dev.txt --without-hashes --with-credentials --with dev
    

    其中第一条命令是生成正常使用下的依赖,第二条命令是包含测试环境的依赖。

    5.打包与发布

    如果这个项目想发到PyPi供别人使用,那么可以先打包项目再发布到PyPi中,这时候需要先修改pyproject.tomlversion的值,比如把它改为0.0.2,再通过poetry builldpoetry publish命令发布。 当然,除了发布到PyPi外,还可能把项目的代码发布到Github中,同时为了让开发者快速的找到对应的版本,往往会给当前的commit打上对应的tag,这就代表着我们必须保证version中的值与tag是一致的,此外,项目中demo/__init__.py也需要添加如下内容:

    __version__ = "0.0.2"
    

    使得别人在引用这个项目时知道项目的版本号是多少,这样一来每次做项目升级的时候需要同时修改三处地方的版本号,非常折腾,为了省心省力,可以采用Poetry的一个插件--poetry-dynamic-versioning来解决这个问题。

    首先是通过命令:

    poetry self add "poetry-dynamic-versioning[plugin]"
    

    Poetry安装插件,然后向pyproject.toml添加如下内容:

    [tool.poetry-dynamic-versioning]
    enable = true       # 代表启用该插件
    metadata=false      # 生成的版本号不带上其他数据
    vcs = "git"         # 指定的版本控制系统为git
    format = "v{base}"  # 指定版本号的生成规则
    

    接着把pyproject.toml文件中的build-system块替换为如下内容:

    [build-system]
    requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"]
    build-backend = "poetry_dynamic_versioning.backend"
    

    最后把pyproject.tomldemo/__init__.py中的版本号改为"0.0.0"(这是poetry-dynamic-versioning的一个动态版本号占位符)

    一切准备就绪后可以执行如下命令进行代码提交以及打上对应的版本tag:

    ➜  demo git:(master) ✗ git add *
    ➜  demo git:(master) ✗ git commit -m"Add, First commit"
    ➜  demo git:(master) ✗ git tag v0.0.2
    

    接下来执行poetry build对项目打包:

    ➜  demo git:(master) ✗ poetry build
    Building demo (v0.0.2)
      - Building sdist
      - Built demo-0.0.2.tar.gz
      - Building wheel
      - Built demo-0.0.2-py3-none-any.whl
    

    通过命令可以发现包的版本正好是我们提交的tagv0.0.2中的0.0.2,如果找到包里面的demo/__init__.py的文件,可以发现文件中存在如下一行代码:

    __version__ = "v0.0.2"
    

    其中__version__的值与tag一样。

    打包完成后就可以把包传到PyPi了,不过在第一次上传之前需要通过如下命令配置自己的PyPi账户信息:

    # my-token可以在`pypi.org`网站中设置
    poetry config pypi-token.pypi my-token
    # 或者通过如下命令配置自己的账号密码
    poetry config http-basic.pypi <username> <password>
    

    最后通过poetry publish命令即可把包传到PyPi

    6.Poetry的pre-commit

    从上面的流程可以发现Poetry为我们带来方便的虚拟环境管理和依赖管理,但有些时候仍然需要我们调用poetry update来确定依赖有及时更新以及通过poetry export来导出依赖,这时可以通过pre-commit-config来确保提交代码的时候能执行自动执行poetry updatepoetry export等语法。

    第一步先向.pre-commit-config.yaml追加如下内容:

      - repo: https://github.com/python-poetry/poetry
        rev: ''  # add version here
        hooks:
          - id: poetry-check
          - id: poetry-lock
          - id: poetry-export
            args: [ "-f", "requirements.txt", "-o", "requirements.txt", "--without-hashes", "--with-credentials"]
          - id: poetry-export
            args: [ "-f", "requirements.txt", "-o", "requirements-dev.txt", "--without-hashes", "--with-credentials", "--with", "dev"]
    

    并执行如下命令:

    poetry run pre-commit install
    poetry run pre-commit run --all-file
    

    验证pre-commit是否正常执行。

    至此就可以享受Poetry为我们带来的便利,只要简单几部操作就可以完成虚拟环境的使用和依赖管理,但是本文还有一些内容没有介绍,可以通过官方文档了解Poetry的更多功能。

  •