Skip to main content

基础使用

简介

官方网站

PDM - Python Development Master 这是一款国人开发的工具。

PDM 是一个新的 Python 的包管理器,也许你还未知晓它的存在,但实际上PDM 已经诞生两年,并在 2021 年发布 1.0 版本,目前最高的版本是 2.1。

在刚听到 PDM 时,我下意识认为它是 Python Development Manager,又一个和 Pipenv 和 Poetry 一样换汤不换药的虚拟环境管理工具。

一直到我翻到了作者的博客,才知道 PDM 的全称是 Python Development Master,比我想像的还要牛逼一个档次。

值得一提的是,PDM 的作者是 PyPa 成员、Pipenv 目前主要的维护者之一,最重要的是,他是中国人,因此这是一款国人开发的工具。

特性

  • PEP 582 本地项目库目录,支持安装与运行命令,完全不需要虚拟环境。
  • 一个简单且相对快速的依赖解析器,特别是对于大的二进制包发布。
  • 兼容 PEP 517 的构建后端,用于构建发布包(源码格式与 wheel 格式)
  • 拥有灵活且强大的插件系统
  • PEP 621 元数据格式
  • 像 pnpm 一样的中心化安装缓存,节省磁盘空间

安装

  • windows
(Invoke-WebRequest -Uri https://raw.githubusercontent.com/pdm-project/pdm/main/install-pdm.py -UseBasicParsing).Content | python
  • linux
curl -sSL https://raw.githubusercontent.com/pdm-project/pdm/main/install-pdm.py | python3
  • pip
python -m pip install pdm # 安装到全局的python环境中

pip install --user pdm
  • 校验安装
# 检查是否安装成功
pdm -v

基础使用

常用指令

# 初始化项目
pdm init

# 在初始化项目时会把机器上的所有 Python 版本都扫描出来了,会让选择项目的 Python 版本。
# 完成之后,PDM 会将你的选择以 toml 格式写入 pyproject.toml 配置文件中。
pdm -h

# 打印项目环境配置
pdm config
pdm config <key> <value> # 修改配置

# 项目配置
pdm info -v
pdm info --env
pdm info --packages

pdm list
pdm list --graph # 以 requirements.txt 的格式列出已安装的包
pdm list --freeze # 以 json 的格式列出已安装的包,但必须与 --graph 同时使用
pdm list --graph --json

pdm add <pkg>
pdm remove <pkg>

pdm update
pdm update -d # 更新依赖
pdm update -dG test pytest # 按组更新依赖
pdm update <pkg>
pdm update -G security -G http # 按组更新
pdm update -G security cryptography

# python 环境切换
pdm use python3.9 # 这里的3.9必须 pdm以扫描记录

# 初始化一个pdm项目
pdm init
pdm install


# 将 pyproject.toml 转成 setup.py
pdm export -f setuppy -o setup.py
# 将 pdm.lock 转成 requirements.txt
pdm export -o requirements.txt

其他常用的flag:

  • --save-compatible:项目依赖可兼容的版本
  • --save-wildcard:保存通配符版本(暂不明白)
  • --save-exact:保存有指定确切版本的包
  • --save-minimum:保持最小版本的包
  • --update-reuse:尽量只更新命令行中指定的包,其依赖包能不更新则不更新
  • --update-eager:更新某个包顺带更新其依赖包(递归升级)
  • --prerelease:允许提前释放(暂不明白)
  • --unconstrained:忽略包版本的约束,可将包升级至最新版本
  • --top:仅更新有在 pyproject.toml 的包
  • --dry-run:试运行,而不去修改 lock 文件
  • --no-sync:只更新 lock 文件,但不更新包

其他包管理转换成pdm

pdm import -f {file} 

配置文件

config.toml

config.toml

以下配置文件存放位置:

  • 默认配置文件:

    C:\ProgramData\pdm\pdm\config.toml
  • 用户配置文件:

    C:\Users\{用户名}\AppData\Local\pdm\pdm\config.toml
  • 项目配置文件

    {项目目录}\pyproject.toml

pyproject.toml

配置文件以pyproject.toml命名,支持多种书写格式

编写格式:

# 格式1
[tool.pdm.scripts]
start = "python main.py"

# 格式2
[tool.pdm.scripts]
start = {cmd = "python main.py"}
  • 添加注释

    使用格式2才可以在文件中添加注释

[tool.pdm.scripts]
start = {cmd = [
"flask",
"run",
# Important comment here about always using port 54321
"-p", "54321"
]}

script

[tool.pdm.scripts]
start.cmd = "flask run -p 54321"
start.env_file = ".env"
[tool.pdm.scripts]
start = {
cmd = ["cmd1", "cmd2"]
shell
env_file = ".env"
}

配置源

命令配置

pdm config pypi.url https://pypi.tuna.tsinghua.edu.cn/simple
[[tool.pdm.source]]
url = "https://private.pypi.org/simple"
verify_ssl = true
name = "pypi"
[[tool.pdm.source]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
verify_ssl = true
name = "tuna"

文献

安装脚本

from __future__ import annotations

import argparse
import dataclasses
import io
import json
import os
import platform
import re
import shutil
import site
import subprocess
import sys
import urllib.request
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Sequence

if sys.version_info < (3, 7):
sys.exit("Python 3.7 or above is required to install PDM.")

_plat = platform.system()
MACOS = _plat == "Darwin"
WINDOWS = _plat == "Windows"
REPO = "https://github.com/pdm-project/pdm"
JSON_URL = "https://pypi.org/pypi/pdm/json"

FOREGROUND_COLORS = {
"black": 30,
"red": 31,
"green": 32,
"yellow": 33,
"blue": 34,
"magenta": 35,
"cyan": 36,
"white": 37,
}


def _call_subprocess(args: list[str]) -> int:
try:
return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True).returncode
except subprocess.CalledProcessError as e:
print(f"An error occurred when executing {args}:", file=sys.stderr)
print(e.output.decode("utf-8"), file=sys.stderr)
sys.exit(e.returncode)


def _echo(text: str) -> None:
sys.stdout.write(text + "\n")


if WINDOWS:
import winreg

def _get_win_folder_with_ctypes(csidl_name: str) -> str:
import ctypes

csidl_const = {
"CSIDL_APPDATA": 26,
"CSIDL_COMMON_APPDATA": 35,
"CSIDL_LOCAL_APPDATA": 28,
}[csidl_name]

buf = ctypes.create_unicode_buffer(1024)
ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)

# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in buf:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
buf2 = ctypes.create_unicode_buffer(1024)
if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
buf = buf2

return buf.value

def _get_win_folder_from_registry(csidl_name: str) -> str:
"""This is a fallback technique at best. I'm not sure if using the
registry for this guarantees us the correct answer for all CSIDL_*
names.
"""
shell_folder_name = {
"CSIDL_APPDATA": "AppData",
"CSIDL_COMMON_APPDATA": "Common AppData",
"CSIDL_LOCAL_APPDATA": "Local AppData",
}[csidl_name]

key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders",
)
dir, _ = winreg.QueryValueEx(key, shell_folder_name)
return dir

try:
from ctypes import windll # noqa: F401

_get_win_folder = _get_win_folder_with_ctypes
except ImportError:
_get_win_folder = _get_win_folder_from_registry

def _remove_path_windows(target: Path) -> None:
value = os.path.normcase(target)

with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as root:
with winreg.OpenKey(root, "Environment", 0, winreg.KEY_ALL_ACCESS) as env_key:
try:
old_value, type_ = winreg.QueryValueEx(env_key, "PATH")
paths = [os.path.normcase(item) for item in old_value.split(os.pathsep)]
if value not in paths:
return

new_value = os.pathsep.join(p for p in paths if p != value)
winreg.SetValueEx(env_key, "PATH", 0, type_, new_value)
except FileNotFoundError:
return


def _add_to_path(target: Path) -> None:
value = os.path.normcase(target)

if WINDOWS:
with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as root:
with winreg.OpenKey(root, "Environment", 0, winreg.KEY_ALL_ACCESS) as env_key:
try:
old_value, type_ = winreg.QueryValueEx(env_key, "PATH")
if value in [os.path.normcase(item) for item in old_value.split(os.pathsep)]:
return
except FileNotFoundError:
old_value, type_ = "", winreg.REG_EXPAND_SZ
new_value = os.pathsep.join([old_value, value]) if old_value else value
winreg.SetValueEx(env_key, "PATH", 0, type_, new_value)

_echo(
"Post-install: {} is added to PATH env, please restart your terminal "
"to take effect".format(colored("green", value))
)
else:
paths = [os.path.normcase(p) for p in os.getenv("PATH", "").split(os.pathsep)]
if value in paths:
return
_echo(
"Post-install: Please add {} to PATH by executing:\n {}".format(
colored("green", value),
colored("cyan", f"export PATH={value}:$PATH"),
)
)


def support_ansi() -> bool:
if WINDOWS:
return (
os.getenv("ANSICON") is not None
or os.getenv("WT_SESSION") is not None
or "ON" == os.getenv("ConEmuANSI")
or "xterm" == os.getenv("Term")
)

if not hasattr(sys.stdout, "fileno"):
return False

try:
return os.isatty(sys.stdout.fileno())
except io.UnsupportedOperation:
return False


def colored(color: str, text: str, bold: bool = False) -> str:
if not support_ansi():
return text
codes = [FOREGROUND_COLORS[color]]
if bold:
codes.append(1)

return "\x1b[{}m{}\x1b[0m".format(";".join(map(str, codes)), text)


@dataclasses.dataclass
class Installer:
location: str | None = None
version: str | None = None
prerelease: bool = False
additional_deps: Sequence[str] = ()
skip_add_to_path: bool = False
output_path: str | None = None

def __post_init__(self):
self._path = self._decide_path()
self._path.mkdir(parents=True, exist_ok=True)
if self.version is None:
self.version = self._get_latest_version()

def _get_latest_version(self) -> str:
resp = urllib.request.urlopen(JSON_URL)
metadata = json.load(resp)

def version_okay(v: str) -> bool:
return self.prerelease or all(p.isdigit() for p in v.split("."))

def sort_version(v: str) -> tuple:
parts = []
for part in v.split("."):
if part.isdigit():
parts.append(int(part))
else:
digit, rest = re.match(r"^(\d*)(.*)", part).groups()
if digit:
parts.append(int(digit))
parts.append(rest)
return tuple(parts)

installable_versions = {
k for k, v in metadata["releases"].items() if version_okay(k) and not v[0].get("yanked")
}
releases = sorted(installable_versions, key=sort_version, reverse=True)

return releases[0]

def _decide_path(self) -> Path:
if self.location is not None:
return Path(self.location).expanduser().resolve()

if WINDOWS:
const = "CSIDL_APPDATA"
path = os.path.normpath(_get_win_folder(const))
path = os.path.join(path, "pdm")
elif MACOS:
path = os.path.expanduser("~/Library/Application Support/pdm")
else:
path = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
path = os.path.join(path, "pdm")

return Path(path)

def _make_env(self) -> Path:
venv_path = self._path / "venv"

_echo(
"Installing {} ({}): {}".format(
colored("green", "PDM", bold=True),
colored("yellow", self.version),
colored("cyan", "Creating virtual environment"),
)
)

try:
import venv

venv.create(venv_path, clear=False, with_pip=True)
except (ModuleNotFoundError, subprocess.CalledProcessError):
try:
import virtualenv
except ModuleNotFoundError:
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
url = f"https://bootstrap.pypa.io/virtualenv/{python_version}/virtualenv.pyz"
with TemporaryDirectory(prefix="pdm-installer-") as tempdir:
virtualenv_zip = Path(tempdir) / "virtualenv.pyz"
urllib.request.urlretrieve(url, virtualenv_zip)
_call_subprocess([sys.executable, str(virtualenv_zip), str(venv_path)])
else:
virtualenv.cli_run([str(venv_path)])

return venv_path

def _install(self, venv_path: Path) -> None:
_echo(
"Installing {} ({}): {}".format(
colored("green", "PDM", bold=True),
colored("yellow", self.version),
colored("cyan", "Installing PDM and dependencies"),
)
)

if WINDOWS:
venv_python = venv_path / "Scripts/python.exe"
else:
venv_python = venv_path / "bin/python"

# Re-install the venv pip to ensure it's not DEBUNDLED
# See issue/685
try:
_call_subprocess([str(venv_python), "-m", "ensurepip"])
except SystemExit:
pass
_call_subprocess([str(venv_python), "-m", "pip", "install", "-IU", "pip"])

if self.version:
if self.version.upper() == "HEAD":
req = f"git+{REPO}.git@main#egg=pdm"
else:
req = f"pdm=={self.version}"
else:
req = "pdm"
args = [req] + [d for d in self.additional_deps if d]
pip_cmd = [str(venv_python), "-Im", "pip", "install", *args]
_call_subprocess(pip_cmd)

def _make_bin(self, venv_path: Path) -> Path:
if self.location:
bin_path = self._path / "bin"
else:
userbase = Path(site.getuserbase())
bin_path = userbase / ("Scripts" if WINDOWS else "bin")

_echo(
"Installing {} ({}): {} {}".format(
colored("green", "PDM", bold=True),
colored("yellow", self.version),
colored("cyan", "Making binary at"),
colored("green", str(bin_path)),
)
)
bin_path.mkdir(parents=True, exist_ok=True)
if WINDOWS:
script = bin_path / "pdm.exe"
target = venv_path / "Scripts" / "pdm.exe"
else:
script = bin_path / "pdm"
target = venv_path / "bin" / "pdm"

if script.exists():
script.unlink()
try:
script.symlink_to(target)
except OSError:
shutil.copy(target, script)
return bin_path

def _post_install(self, venv_path: Path, bin_path: Path) -> None:
if WINDOWS:
script = bin_path / "pdm.exe"
else:
script = bin_path / "pdm"
subprocess.check_call([str(script), "--help"])
print()
_echo(
"Successfully installed: {} ({}) at {}".format(
colored("green", "PDM", bold=True),
colored("yellow", self.version),
colored("cyan", str(script)),
)
)
if not self.skip_add_to_path:
_add_to_path(bin_path)
self._write_output(venv_path, script)

def _write_output(self, venv_path: Path, script: Path) -> None:
if not self.output_path:
return
print("Writing output to", colored("green", self.output_path))
output = {
"pdm_version": self.version,
"pdm_bin": str(script),
"install_python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"install_location": str(venv_path),
}
with open(self.output_path, "w") as f:
json.dump(output, f, indent=2)

def install(self) -> None:
venv = self._make_env()
self._install(venv)
bin_dir = self._make_bin(venv)
self._post_install(venv, bin_dir)

def uninstall(self) -> None:
_echo(
"Uninstalling {}: {}".format(
colored("green", "PDM", bold=True),
colored("cyan", "Removing venv and script"),
)
)
if self.location:
bin_path = self._path / "bin"
else:
userbase = Path(site.getuserbase())
bin_path = userbase / ("Scripts" if WINDOWS else "bin")

if WINDOWS:
script = bin_path / "pdm.exe"
else:
script = bin_path / "pdm"

shutil.rmtree(self._path / "venv")
script.unlink()

if WINDOWS:
_remove_path_windows(bin_path)

print()
_echo("Successfully uninstalled")


def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"-v",
"--version",
help="Specify the version to be installed, or HEAD to install from the main branch",
default=os.getenv("PDM_VERSION"),
)
parser.add_argument(
"--prerelease",
action="store_true",
help="Allow prereleases to be installed",
default=os.getenv("PDM_PRERELEASE"),
)
parser.add_argument(
"--remove",
action="store_true",
help="Remove the PDM installation",
default=os.getenv("PDM_REMOVE"),
)
parser.add_argument(
"-p",
"--path",
help="Specify the location to install PDM",
default=os.getenv("PDM_HOME"),
)
parser.add_argument(
"-d",
"--dep",
action="append",
default=os.getenv("PDM_DEPS", "").split(","),
help="Specify additional dependencies, can be given multiple times",
)
parser.add_argument(
"--skip-add-to-path",
action="store_true",
help="Do not add binary to the PATH.",
default=os.getenv("PDM_SKIP_ADD_TO_PATH"),
)
parser.add_argument("-o", "--output", help="Output file to write the installation info to")

options = parser.parse_args()
installer = Installer(
location=options.path,
version=options.version,
prerelease=options.prerelease,
additional_deps=options.dep,
skip_add_to_path=options.skip_add_to_path,
output_path=options.output,
)
if options.remove:
installer.uninstall()
else:
installer.install()


if __name__ == "__main__":
main()