私有pypi搭建

  1. nexus3功能强大,支持docker、pypi、java、npm等等私有仓库的搭建,但是内存占用巨大,即使只跑一个仓库。
  2. devpi专注pypi,缺点是不支持动态缓存包(启动后会提前下载上游源的索引元数据,这个过程耗时长,cpu、内存等资源消耗都很大),除非添加了--offline-mode
  3. proxpi仅支持自动缓存用户通过它拉取的包,不支持用户手动上传包,无法满足离线内网的需要。

  1. 离线内网使用devpi
  2. 联网局域网使用propi
1
2
pip install devpi-server devpi-web
devpi-server --host 0.0.0.0 --port 3141 --offline-mode

可以使用docker替代,下面的docker构建在codespace中完成,以下实现基于项目rexzhang/devpi-docker魔改而来 Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
FROM python:3.10-alpine
WORKDIR /app
COPY requirements.txt .

RUN \
    # install depends
    apk add --no-cache --virtual .build-deps build-base libffi-dev \
    && pip install --no-cache-dir -r requirements.txt \
    && apk del .build-deps \
    && find /usr/local/lib/python*/ -type f -name '*.py[cod]' -delete \
    && find /usr/local/lib/python*/ -type d -name "__pycache__" -delete \
    # prepare data path
    && mkdir /data


VOLUME /data
EXPOSE 3141
COPY entrypoint.sh supervisord.conf Dockerfile .
CMD /app/entrypoint.sh

entrypoint.sh脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/bin/sh

# devpi-init
if [ ! -f "/data/.serverversion" ];
then
  echo "Init devpi-server"
  devpi-init --no-root-pypi --serverdir /data
fi

# devpi-server
echo "Start supervisor"
supervisord -c /app/supervisord.conf
# 等待日志文件生成
echo "Waiting for /tmp/devpi-server.log to be created..."
while [ ! -f /tmp/devpi-server.log ]; do
    sleep 1
done
tail -f /tmp/devpi-server.log

requirements.txt

1
2
3
4
devpi-server
devpi-client
devpi-web
supervisor

supervisord.conf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[unix_http_server]
file=/tmp/supervisor.sock   ; the path to the socket file

[supervisord]
logfile=/tmp/supervisord.log ; main log file; default $CWD/supervisord.log
logfile_maxbytes=50MB        ; max main logfile bytes b4 rotation; default 50MB
logfile_backups=10           ; # of main logfile backups; 0 means none, default 10
loglevel=info                ; log level; default info; others: debug,warn,trace
pidfile=/tmp/supervisord.pid ; supervisord pidfile; default supervisord.pid
nodaemon=false               ; start in foreground if true; default false
silent=false                 ; no logs to stdout if true; default false
minfds=1024                  ; min. avail startup file descriptors; default 1024
minprocs=200                 ; min. avail process descriptors;default 200

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL  for a unix socket

[program:devpi-server]
command=/usr/local/bin/devpi-server --host 0.0.0.0 --port 3141 --serverdir /data --offline-mode
user = root
priority = 999
startsecs = 5
redirect_stderr = True
autostart = True
stdout_logfile = /tmp/devpi-server.log
1
2
3
4
5
6
7
8
devpi use http://192.168.2.128:3141
devpi login root --password=
# 创建一个自定义索引名,默认的root/pypi不支持上传用户的包
devpi index -c root/local
# 切换到创建的自定义索引
devpi use root/local
# 上传当前目录下所有包(*.whl、*.tar.gz)到服务器
devpi upload --from-dir .
1
pip install <包名> --index-url http://192.168.2.128:3141/root/local/ --trusted-host 192.168.2.128

执行以下docker cli启动

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
docker run -itd --restart unless-stopped \
  -p 3141:5000 \
  # 缓存的包映射到宿主机目录
  -v /tmp/proxpi:/var/cache/proxpi \
  # 复用宿主机的时间和时区
  -v /etc/localtime:/etc/localtime \
  -v /etc/timezone:/etc/timezone \
  -e PROXPI_LOGGING_LEVEL=WARNING \
  # 指定包文件的缓存目录,该目录仅储存包文件,proxpi有对应的索引数据[(保存在内存内)](https://github.com/EpicWink/proxpi/issues/33#issuecomment-1692957116)记录了哪个包储存在哪个路径,不通过proxpi直接从该路径下删除包文件,proxpi的索引数据中未作相应更改,将会导致proxpi出错:它在本地缓存目录中找不到包,也不会到上游去下载,直到重启它。
  -e PROXPI_CACHE_DIR=/var/cache/proxpi \
  # 上游镜像
  -e PROXPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ \
  # 禁止下载上游索引元数据
  -e PROXPI_INDEX_TTL=0 \
  --name proxpi epicwink/proxpi:latest
1
pip install <包名> --index-url http://192.168.2.128:3141/index/ --trusted-host 192.168.2.128
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import os

import requests
from joblib import Parallel, delayed

url = "http://12.15.9.250:8088/service/rest/internal/ui/upload/local-pypi"

# Headers from the curl command
headers = {
    "Accept": "application/json, text/plain, */*",
    "Accept-Language": "zh-CN,zh;q=0.9",
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
    "Cookie": "psession=7a2db103-73a2-405a-9e22-20a1e88fef1d; NX-ANTI-CSRF-TOKEN=0.4838417271560995; night=0; NXSESSIONID=fe82694f-3f13-40af-8f39-6c7ae4d33b99",
    "NX-ANTI-CSRF-TOKEN": "0.4838417271560995",
    "Origin": "http://12.15.9.250:8088",
    "Pragma": "no-cache",
    "Referer": "http://12.15.9.250:8088/",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
    "X-Nexus-UI": "true",
}


def upload_file(file_path):
    """
    上传单个文件至指定URL
    :param file_path: 文件绝对路径
    """
    with open(file_path, "rb") as f:
        files = {
            "asset0": (
                os.path.basename(file_path),
                f,
                "application/octet-stream",
            )
        }
        response = requests.post(url, headers=headers, files=files, verify=False)

    resjson = response.json()
    if isinstance(resjson, list):
        print(os.path.basename(file_path), "\t", resjson)


def process_directory(directory):
    """
    批量处理指定目录下的所有 .whl 和 .tar.gz 文件
    :param directory: 要处理的目录路径
    """
    file_paths = [os.path.join(directory, f) for f in os.listdir(directory)]

    # 使用 joblib 进行并行化处理,n_jobs=20 表示使用最多20个线程
    Parallel(n_jobs=20)(delayed(upload_file)(path) for path in file_paths)


process_directory("packages")
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import os
import re
import subprocess

# 正则表达式匹配形如 `8-4-4-4-12.properties` 的文件名
pattern = r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.properties$"

# 存储失败的文件路径
failed_files = []

# 遍历当前目录及其子目录
for root, _, files in os.walk("."):
    for file in files:
        if re.match(pattern, file):
            properties_file = os.path.join(root, file)

            # 读取 properties 文件内容
            with open(properties_file, "r", encoding="utf-8") as f:
                lines = f.readlines()

            # 确保文件内容足够长,避免索引超出范围
            if len(lines) > 3:
                name_line = lines[3].strip()  # 读取第 4 行
                # 提取键值对的值部分
                if "=" in name_line:
                    blob_name = name_line.split("=", 1)[1].strip()

                    # 判断 blob_name 是否以 `.whl` 或 `.gz` 结尾
                    if blob_name.endswith((".whl", ".gz")):
                        base_name = os.path.basename(blob_name)  # 提取文件名
                        name = os.path.splitext(file)[0]  # 从 properties 文件名去掉后缀
                        bytename = name + ".bytes"  # 假设同目录存在一个 .bytes 文件

                        # 检查 .bytes 文件是否存在
                        bytename_path = os.path.join(root, bytename)
                        if os.path.exists(bytename_path):
                            new_file_path = os.path.join(root, base_name)
                            print(f"将文件重命名: {bytename_path} -> {new_file_path}")

                            # 重命名文件
                            os.rename(bytename_path, new_file_path)

                            # 执行 devpi upload
                            try:
                                # 使用绝对路径上传
                                abs_path = os.path.abspath(new_file_path)
                                result = subprocess.run(
                                    ["devpi", "upload", abs_path],
                                    check=True,
                                    capture_output=True,
                                    text=True,
                                )

                                # 检查输出内容判断是否成功
                                if "file_upload" in result.stdout:
                                    print(f"成功上传: {abs_path}")

                                    # 上传成功后删除文件
                                    os.remove(abs_path)
                                    print(f"已删除文件: {abs_path}")
                                else:
                                    print(
                                        f"上传失败: {abs_path}, 输出: {result.stdout}"
                                    )
                                    failed_files.append(abs_path)
                            except subprocess.CalledProcessError as e:
                                print(f"上传失败: {abs_path}, 错误: {e.stderr}")
                                failed_files.append(abs_path)
                        else:
                            print(f"未找到对应的 .bytes 文件: {bytename_path}")
                else:
                    print(f"第 4 行不包含 '=',跳过文件: {properties_file}")
            else:
                print(f"文件内容不足 4 行,跳过文件: {properties_file}")

# 输出所有失败的文件路径
if failed_files:
    print("\n以下文件上传失败,未被删除:")
    for failed_file in failed_files:
        print(failed_file)