想尝试使用 Agent 生成 DockerFile,安装指定 PyPI 包,本篇有多处是基于 GPT-5 的指导下完成。

1、Docekrfile 模板

# ====== Dockerfile 模板(非 root,多阶段+小镜像,安装指定 PyPI 包)======
DOCKERFILE_TPL = """\
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app

# 依赖层:仅安装目标包(可选额外系统依赖)
FROM base AS deps
# 常见构建依赖(按需补充)
RUN apt-get update && apt-get install -y --no-install-recommends \\
    build-essential gcc \\
    && rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/root/.cache/pip pip install --no-cache-dir {package_spec}

# 运行层
FROM base AS runtime
USER appuser
COPY --from=deps /usr/local /usr/local
WORKDIR /app
{extra_copy}
# 暴露端口(如果是服务型包可以调整)
{expose_line}
# 由 Agent 决定最终 CMD(JSON 形式)
CMD {cmd_json}
"""
1️⃣基础层(base)

FROM python:3.11-slim AS base

使用体积小、官方维护的 Python 镜像;slim 兼顾体积与兼容性。

ENV

PYTHONDONTWRITEBYTECODE=1:不生成 .pyc 文件。

PYTHONUNBUFFERED=1:实时日志输出。PIP_NO_CACHE_DIR=1:安装后不留 pip 缓存。

RUN adduser …

创建非 root 用户 appuser,保障容器安全。

WORKDIR /app

统一工作目录,便于部署与维护。

2️⃣依赖层(deps)

FROM base AS deps

单独阶段安装依赖,避免运行镜像被编译文件污染。

RUN apt-get … build-essential gcc

安装编译依赖,仅在构建阶段使用。

RUN –mount=type=cache … pip install {package_spec}

使用 BuildKit 缓存加速安装,{package_spec} 为 PyPI 包名或版本。

3️⃣运行层(runtime)

FROM base AS runtime

轻量运行镜像,不含编译工具。

USER appuser:非 root 启动。

COPY –from=deps /usr/local /usr/local

拷入依赖层安装好的 Python 包与可执行文件。

WORKDIR /app + {extra_copy}

可插入额外文件(如配置、启动脚本)。

{expose_line}:可选 EXPOSE 8000 等端口声明。

CMD {cmd_json}

Agent 生成的启动命令(exec JSON 形式,如 ["python","-m","pkg"])。

优点:更安全、信号处理正确、无 shell 注入风险。


2、本地执行器

def run_cmd(cmd: List[str], cwd: Path = WORKDIR, timeout: int = 600) -> Tuple[int, str, str]:
    # subprocess.Popen(...)启动子进程;stdout/stderr=PIPE 捕获输出;text=True 表示返回字符串(非字节)。
    proc = subprocess.Popen(cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    try:
        # proc.communicate(timeout=timeout)阻塞等待子进程结束(或直到超时),返回 (out, err)。
        out, err = proc.communicate(timeout=timeout)
        # 正常返回退出码、标准输出、标准错误。
        return proc.returncode, out, err
    except subprocess.TimeoutExpired:
        # 超时则杀掉子进程,再次 communicate() 把缓冲区读干净。
        proc.kill()
        out, err = proc.communicate()
        # 约定 124 作为超时码(常见约定),若 err 为空则给个 "Timeout"。
        return 124, out, err or "Timeout"

主要是实现在本地进行指令操作,涉及到的函数只要有

subprocess.Popen:启动子进程。

proc.communicate: 是 subprocess.Popen 对象中最核心的方法之一,它负责与子进程交互、读取输出、等待执行结束。大概流程如下:

的作用相当于执行以下几步的组合操作:

1️⃣向子进程的标准输入 (stdin) 写入数据(如果提供了 input 参数);

2️⃣从标准输出 (stdout) 和 标准错误 (stderr) 读取全部输出内容;

3️⃣等待子进程结束;

4️⃣返回输出结果(作为一个 (stdout_str, stderr_str) 元组)。

proc.communicate(input=None, timeout=None)

input:可选参数,字符串或字节流。如果想给子进程的标准输入(stdin)传内容,就传它。

timeout:允许的最长等待时间(秒)。超时会抛出 subprocess.TimeoutExpired 异常。

可以安全读取,防止子进程输出很多,导致缓冲区塞满造成死锁。


3、工具函数

# 工具函数
def propose_cmd_local(package: str, previous_error: str) -> Dict[str, Any]:
    """
    本地启发式:
    - 继续用 candidates 猜 CMD
    - 额外:为常见服务型包自动设置 expose 端口
    - 额外:基于错误日志,必要时提供 extra_copy(拷贝一个最小示例脚本)
    """
    # 1) 先选一个最可能成功的 CMD
    candidates: List[List[str]] = []
    if not previous_error:
        candidates = [
            ["python", "-m", package],
            [package, "--help"],
            ["python", "-c", f"import {package}; print({package}.__doc__ or 'ok')"]
        ]
    else:
        if ("No module named" in previous_error) or ("ModuleNotFoundError" in previous_error):
            candidates = [[package, "--help"], ["python", "-m", package]]
        elif ("command not found" in previous_error) or ("executable file not found" in previous_error):
            candidates = [["python", "-m", package], [package]]
        else:
            candidates = [["python", "-m", package], [package, "--help"]]

    cmd = candidates[0]

    # 2) 针对常见“服务型包”设置默认端口(可按需扩充)
    default_ports = {
        "uvicorn": 8000,
        "fastapi": 8000,
        "starlette": 8000,
        "flask": 5000,
        "streamlit": 8501,
        "gradio": 7860,
        "mlflow": 5000,
        "tensorboard": 6006,
        "jupyter": 8888,
        "jupyterlab": 8888,
        "ray": 8265,
    }
    expose = default_ports.get(package, None)

    # 3) 基于错误日志,给出一个最小可跑 demo 的 extra_copy(可选)
    extra_copy = ""
    # 常见“找不到脚本/文件”的情况,给一个最小示例
    if previous_error:
        err = previous_error.lower()
        # 如果提示找不到 app.py / main.py / hello.py,就提供一个轻量 demo
        if any(x in err for x in ["no such file or directory", "file not found", "can't open file"]):
            if package in ("uvicorn", "fastapi", "starlette"):
                # 给一个极简 FastAPI 应用,并让 CMD 使用它(通常 LLM 会改 CMD,这里仅兜底)
                extra_copy = (
                    'COPY <<EOF /app/app.py\n'
                    'from fastapi import FastAPI\n'
                    'app = FastAPI()\n'
                    '@app.get("/")\n'
                    'def root():\n'
                    '    return {"msg": "ok"}\n'
                    'EOF\n'
                )
                # 如果当前 cmd 看起来没有指向 app:app,可以保守不改 CMD,交给下一轮 LLM 调整
                # 也可以在这里直接强改为 uvicorn 启动(按需开启):
                # cmd = ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", str(expose or 8000)]
                expose = expose or 8000
            elif package == "streamlit":
                extra_copy = (
                    'COPY <<EOF /app/hello.py\n'
                    'import streamlit as st\n'
                    'st.title("Hello Streamlit")\n'
                    'st.write("It works!")\n'
                    'EOF\n'
                )
                expose = expose or 8501
                # 同理,这里也可考虑改 CMD 为 ["streamlit","run","hello.py","--server.address","0.0.0.0"]
            elif package == "gradio":
                extra_copy = (
                    'COPY <<EOF /app/app.py\n'
                    'import gradio as gr\n'
                    'def echo(x):\n'
                    '    return x\n'
                    'demo = gr.Interface(fn=echo, inputs="text", outputs="text")\n'
                    'if __name__ == "__main__":\n'
                    '    demo.launch(server_name="0.0.0.0", server_port=7860)\n'
                    'EOF\n'
                )
                expose = expose or 7860

    return {"cmd": cmd, "expose": expose, "extra_copy": extra_copy}

这里选择了三条指令作为尝试安装package

1)、python -m pkg:直接执行这个pkg,如过没有main则会失败

2)、pkg --help:尝试运行该包的 CLI(命令行界面),查看它的帮助信息。

3)、["python", "-c", f"import {package}; print({package}.__doc__ or 'ok')"]:启动 Python 解释器;尝试 import <package>;如果能导入成功,打印包的 __doc__ 文档字符串(或“ok”);退出。

用 -c 参数执行字符串形式的脚本,也就是后面import那些

检查包是否能被导入(验证安装成功);确认模块名是否与 PyPI 名一致(有的包安装名和导入名不同,例如 bs4 vs beautifulsoup4);输出结果可用于日志分析。

__doc__ 是模块的文档字符串(模块顶端三引号注释),读取它不影响包执行,输出简短,不会阻塞或报错,如果没有文档,就打印 'ok' 说明导入成功。

脚本的启发式策略是:

  1. 首先试模块执行(python -m pkg);

  2. 不行再试命令行;

  3. 最后再试最简单的 import 验证。

4)另外两个参数

expose

检测出 Web 服务包时

在 Dockerfile 中暴露端口

extra_copy

包运行需要额外文件时

在 Dockerfile 中添加 COPY 指令

这里是在本地进行的简单的修改,没有把参数上传让 LLM 修改。


4、docker 创建与运行

def docker_build(image_tag: str, dockerfile_path: Path) -> Tuple[bool, str]:
    # cwd=dockerfile_path.parent,工作目录为 Dockerfile 所在目录
    code, out, err = run_cmd(["docker", "build", "-t", image_tag, "-f", str(dockerfile_path), "."], cwd=dockerfile_path.parent, timeout=1800)
    ok = (code == 0)
    # code=0 时则成功
    return ok, out + "\n" + err

def docker_run(image_tag: str, cmd: List[str] = None, timeout: int = 60) -> Tuple[int, str]:
    base = ["docker", "run", "--rm"]
    if cmd:
        base += [image_tag] + cmd
    else:
        base += [image_tag]
    code, out, err = run_cmd(base, timeout=timeout)
    return code, out + "\n" + err

1)创建Docker的函数

将Docker创建指令与地址传回run_cmd,作为subprocess.Popen的参数,启动子进程。

· Docker 的命令是:docker build -t <镜像名> -f <Dockerfile路径> .

· path.parent的作用:

假设 dockerfile_path = Path("/tmp/pypi_streamlit_1234/Dockerfile")

则 dockerfile_path.parent == Path("/tmp/pypi_streamlit_1234")

2)运行 Docker 的函数

根据是否传入了 CMD 有两种结果

传入 cmd

实际执行命令

解释

cmd=None(默认)

docker run --rm <image_tag>

不加额外参数,直接运行镜像的默认启动命令(即 Dockerfile 里的 CMD)

cmd=["python","-m","xxx"]

docker run --rm <image_tag> python -m xxx

临时覆盖 Dockerfile 里的 CMD,执行新的命令


5、System Prompt 设计

SYSTEM_PROMPT = """You are a DevOps agent that generates runnable Dockerfiles for PyPI packages.
Rules:
- Prefer EXEC JSON CMD array, no shell forms.
- Avoid root at runtime (already handled by base image).
- If the package exposes a console_script, use it with --help (non-blocking).
- Otherwise try: python -m <package>.
- If previous run failed, infer a better command from the error/logs.
Return only by calling the tool 'propose_cmd' with {"package": "...", "previous_error": "..."}.
"""

1)Prefer EXEC JSON CMD array, no shell forms.

Exec 形式就是 JSON 数组,CMD ["python", "-m", "http.server", "8000"],相当于执行了python -m http.server 8000。

Exec 形式特点:直接由内核启动程序,不经过shell(/bin/sh),每个参数都是独立字符串,不会被 shell 解析,信号(SIGTERM、SIGINT)会正确传递到子进程,安全、可靠、可控,是官方推荐形式。

这样设计可以防止在 shell 命令中注入字符串展开风险。

2) Avoid root at runtime (already handled by base image).

运行期避免 root(最小权限)。虽然基础镜像阶段已处理(创建 appuser、USER appuser),但这里再次强调,防止模型建议任何需要升权/写系统目录的做法(如 sudo、写 /root)。

3) If the package exposes a console_script, use it with --help (non-blocking).

优先走 console_script + --help 的短命令。

console_script 是在 Python 包的 setup.py 或 pyproject.toml 中,很多项目会定义一个 “入口点”:

# setup.py
entry_points={
    'console_scripts': [
        'black = black:patched_main',
        'pytest = pytest:main',
        'uvicorn = uvicorn.main:main',
    ],
}

这表示当用户 pip install black 后,系统会自动生成一个命令行可执行文件:

  • /usr/local/bin/black

  • /usr/local/bin/pytest

  • /usr/local/bin/uvicorn

这些文件就是 “console_script”。

只需在命令行输入:

black --help
pytest --help
uvicorn --help

就能执行对应包里的主函数。

· Otherwise try: python -m <package>.

退而求其次:模块形式执行。

原因:许多包没有 CLI,但支持 python -m 包名 作为入口(如标准库 http.server 的用法);

与上一条的关系:形成“CLI 优先、模块次之”的启发式,覆盖面广、尝试成本低。

4) If previous run failed, infer a better command from the error/logs.

如果之前的运行失败,则从错误/日志中推断出更好的命令。

5)Return only by calling the tool 'propose_cmd' with {"package": "...", "previous_error": "..."} .

必须用 Function Calling 工具返回(结构化输出),且仅能调用这个工具,并带上两个参数。


6、主函数,循环逻辑

def main(package: str):
    work = Path(tempfile.mkdtemp(prefix=f"pypi_{package}_"))
    dockerfile_path = work / "Dockerfile"

    print(f"📦 Target PyPI package: {package}")
    print(f"🗂  Workdir: {work}")

    previous_error = ""
    for step in range(1, MAX_STEPS + 1):
        print(f"\n===== Attempt {step}/{MAX_STEPS} =====")
        # 1) 让 LLM(或本地启发式)给一版 CMD(和可选 expose)
        plan = plan_cmd_with_llm(package, previous_error)
        # 兜底:如果计划不含 cmd,用模块执行。
        cmd = plan.get("cmd", ["python", "-m", package])
        expose = plan.get("expose", None)
        print(f"🔧 Proposed CMD: {cmd}  (expose={expose})")

        # 2) 渲染 Dockerfile
        dockerfile_text = make_dockerfile(package, cmd, expose=expose)
        dockerfile_path.write_text(dockerfile_text, encoding="utf-8")
        print("📝 Dockerfile generated.")

        # 3) 构建镜像
        image_tag = f"{IMAGE_PREFIX}:{package}-{step}"
        print(f"🏗  docker build -t {image_tag}")
        ok, build_log = docker_build(image_tag, dockerfile_path)
        if not ok:
            # 构建失败,多数是系统依赖或包名错误;把日志反馈回去迭代
            print("❌ Build failed. Will refine.")
            previous_error = f"[BUILD FAIL]\n{tail(build_log, 1000)}"
            continue

        # 4) 运行容器(若 CMD 是阻塞型长跑服务,建议先尝试 --help 或短命令)
        print(f"🚀 docker run {image_tag}")
        code, run_log = docker_run(image_tag, timeout=45)
        if code == 0:
            print("✅ Run succeeded!")
            print("====== OUTPUT ======")
            print(tail(run_log, 1200))
            print("====================")
            print(f"🎉 Success with image: {image_tag}")
            return
        else:
            print("⚠️ Run failed, try to refine CMD.")
            previous_error = f"[RUN FAIL code={code}]\n{tail(run_log, 1500)}"

    print("\n⛔ Reached max attempts. Last error/logs:")
    print(previous_error)

1)cmd = plan.get("cmd", ["python", "-m", package])
expose = plan.get("expose", None)

plan 是返回的一个字典

plan = {"cmd": ["pytest", "--help"], "extra_copy": ""} 

plan["cmd"] ——> ["pytest", "--help"] 但如果 cmd 这个键不存在,则会报错KeyError: 'cmd'

所以选择更安全的方式:通过 get 直接获取

plan.get("cmd", ["python", "-m", package]) ——> 如果 cmd 不存在则返回默认值["python", "-m", package] 

2)work = Path(tempfile.mkdtemp(prefix=f"pypi_{package}_"))

· tempfile 是 Python 标准库中专门用来创建临时文件或临时目录的模块。mkdtemp() 会创建一个 唯一的临时文件夹,返回该文件夹的绝对路径字符串。

临时目录会自动放在系统的默认临时目录下(例如 macOS/Linux /tmp/,Windows 通常是 %TEMP%)。

import tempfile
tempfile.mkdtemp(prefix="pypi_numpy_")

返回值可能为:'/tmp/pypi_numpy_abc1234'   

"abc1234" 是系统自动生成的随机后缀,用来避免重复。

· prefix=f"pypi_{package}_":用了 f-string 格式化语法,prefix 指定临时文件夹名称前缀。

如果 package 是 "fastapi",则创建的目录形如:/tmp/pypi_fastapi_h8s7bq9z

· 用 Path() 把它转换成一个 Path 对象,Path 对象有很多内置方法。方便后续的优雅书写

dockerfile_path = work / "Dockerfile" 直接进行拼接

dockerfile_path.write_text(dockerfile_text, encoding="utf-8") .write_text()  写入文本到文件

7、代码附录

#!/usr/bin/env python3
# autodocker_pypi_agent.py
# 功能:给定一个 PyPI 包名,自动生成 Dockerfile,构建并运行;
# 若运行失败,LLM 根据错误日志改写 CMD,重复尝试,直到成功或达到最大次数。
##### 大部分位置都已经写定,LLM 只负责少量关键槽位(CMD/EXPOSE)→ 降低幻觉面

import os, json, subprocess, shlex, sys, tempfile, re, textwrap, time
from pathlib import Path
from typing import Any, Dict, List, Tuple
from openai import OpenAI
from openai import APIStatusError

# ====== 配置 ======
MODEL = os.getenv("MODEL_NAME", "deepseek-chat")    # 也可用 deepseek-chat
BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.deepseek.com/v1")
API_KEY = os.getenv("DEEPSEEK_API_KEY")
MAX_STEPS = int(os.getenv("MAX_STEPS", "10"))
WORKDIR = Path.cwd().resolve()
IMAGE_PREFIX = "pypi-auto"

if not API_KEY:
    print("❌ 请先设置 DEEPSEEK_API_KEY")
    sys.exit(1)

client = OpenAI(api_key=API_KEY, base_url=BASE_URL)

# ====== Dockerfile 模板(非 root,多阶段+小镜像,安装指定 PyPI 包)======
DOCKERFILE_TPL = """\
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app

# 依赖层:仅安装目标包(可选额外系统依赖)
FROM base AS deps
# 常见构建依赖(按需补充)
RUN apt-get update && apt-get install -y --no-install-recommends \\
    build-essential gcc \\
    && rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/root/.cache/pip pip install --no-cache-dir {package_spec}

# 运行层
FROM base AS runtime
USER appuser
COPY --from=deps /usr/local /usr/local
WORKDIR /app
{extra_copy}
# 暴露端口(如果是服务型包可以调整)
{expose_line}
# 由 Agent 决定最终 CMD(JSON 形式)
CMD {cmd_json}
"""

# ====== Tools(供 LLM 调用)的 schema ======
TOOLS_SPEC = [
    {
        "type": "function",
        "function": {
            "name": "propose_cmd",
            "description": "Propose a CMD (as JSON array) to run the given PyPI package inside container.",
            "parameters": {
                "type": "object",
                "properties": {
                    "package": {"type": "string", "description": "PyPI package name"},
                    "previous_error": {"type": "string", "description": "stderr/stdout logs from previous run, if any"},
                },
                "required": ["package"]
            }
        }
    }
]

# ====== 本地实现的“工具”分发(这里只需要 LLM 规划 CMD,本地不执行外部命令)======
def dispatch_tool_call(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
    if name == "propose_cmd":
        return propose_cmd_local(arguments.get("package", ""), arguments.get("previous_error", ""))
    return {"cmd": ["python", "-m", arguments.get("package", "")], "expose": None, "extra_copy": ""}

# 工具函数
def propose_cmd_local(package: str, previous_error: str) -> Dict[str, Any]:
    """
    本地启发式:
    - 继续用 candidates 猜 CMD
    - 额外:为常见服务型包自动设置 expose 端口
    - 额外:基于错误日志,必要时提供 extra_copy(拷贝一个最小示例脚本)
    """
    # 1) 先选一个最可能成功的 CMD
    candidates: List[List[str]] = []
    if not previous_error:
        candidates = [
            ["python", "-m", package],
            [package, "--help"],
            ["python", "-c", f"import {package}; print({package}.__doc__ or 'ok')"]
        ]
    else:
        if ("No module named" in previous_error) or ("ModuleNotFoundError" in previous_error):
            candidates = [[package, "--help"], ["python", "-m", package]]
        elif ("command not found" in previous_error) or ("executable file not found" in previous_error):
            candidates = [["python", "-m", package], [package]]
        else:
            candidates = [["python", "-m", package], [package, "--help"]]

    cmd = candidates[0]

    # 2) 针对常见“服务型包”设置默认端口(可按需扩充)
    default_ports = {
        "uvicorn": 8000,
        "fastapi": 8000,
        "starlette": 8000,
        "flask": 5000,
        "streamlit": 8501,
        "gradio": 7860,
        "mlflow": 5000,
        "tensorboard": 6006,
        "jupyter": 8888,
        "jupyterlab": 8888,
        "ray": 8265,
    }
    expose = default_ports.get(package, None)

    # 3) 基于错误日志,给出一个最小可跑 demo 的 extra_copy(可选)
    extra_copy = ""
    # 常见“找不到脚本/文件”的情况,给一个最小示例
    if previous_error:
        err = previous_error.lower()
        # 如果提示找不到 app.py / main.py / hello.py,就提供一个轻量 demo
        if any(x in err for x in ["no such file or directory", "file not found", "can't open file"]):
            if package in ("uvicorn", "fastapi", "starlette"):
                # 给一个极简 FastAPI 应用,并让 CMD 使用它(通常 LLM 会改 CMD,这里仅兜底)
                extra_copy = (
                    'COPY <<EOF /app/app.py\n'
                    'from fastapi import FastAPI\n'
                    'app = FastAPI()\n'
                    '@app.get("/")\n'
                    'def root():\n'
                    '    return {"msg": "ok"}\n'
                    'EOF\n'
                )
                # 如果当前 cmd 看起来没有指向 app:app,可以保守不改 CMD,交给下一轮 LLM 调整
                # 也可以在这里直接强改为 uvicorn 启动(按需开启):
                # cmd = ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", str(expose or 8000)]
                expose = expose or 8000
            elif package == "streamlit":
                extra_copy = (
                    'COPY <<EOF /app/hello.py\n'
                    'import streamlit as st\n'
                    'st.title("Hello Streamlit")\n'
                    'st.write("It works!")\n'
                    'EOF\n'
                )
                expose = expose or 8501
                # 同理,这里也可考虑改 CMD 为 ["streamlit","run","hello.py","--server.address","0.0.0.0"]
            elif package == "gradio":
                extra_copy = (
                    'COPY <<EOF /app/app.py\n'
                    'import gradio as gr\n'
                    'def echo(x):\n'
                    '    return x\n'
                    'demo = gr.Interface(fn=echo, inputs="text", outputs="text")\n'
                    'if __name__ == "__main__":\n'
                    '    demo.launch(server_name="0.0.0.0", server_port=7860)\n'
                    'EOF\n'
                )
                expose = expose or 7860

    return {"cmd": cmd, "expose": expose, "extra_copy": extra_copy}

# ====== 本地执行器 ======
def run_cmd(cmd: List[str], cwd: Path = WORKDIR, timeout: int = 600) -> Tuple[int, str, str]:
    # subprocess.Popen(...)启动子进程;stdout/stderr=PIPE 捕获输出;text=True 表示返回字符串(非字节)。
    proc = subprocess.Popen(cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    try:
        # proc.communicate(timeout=timeout)阻塞等待子进程结束(或直到超时),返回 (out, err)。
        out, err = proc.communicate(timeout=timeout)
        # 正常返回退出码、标准输出、标准错误。
        return proc.returncode, out, err
    except subprocess.TimeoutExpired:
        # 超时则杀掉子进程,再次 communicate() 把缓冲区读干净。
        proc.kill()
        out, err = proc.communicate()
        # 约定 124 作为超时码(常见约定),若 err 为空则给个 "Timeout"。
        return 124, out, err or "Timeout"

def docker_build(image_tag: str, dockerfile_path: Path) -> Tuple[bool, str]:
    # cwd=dockerfile_path.parent,工作目录为 Dockerfile 所在目录
    code, out, err = run_cmd(["docker", "build", "-t", image_tag, "-f", str(dockerfile_path), "."], cwd=dockerfile_path.parent, timeout=1800)
    ok = (code == 0)
    # code=0 时则成功
    return ok, out + "\n" + err

def docker_run(image_tag: str, cmd: List[str] = None, timeout: int = 60) -> Tuple[int, str]:
    base = ["docker", "run", "--rm"]
    if cmd:
        base += [image_tag] + cmd
    else:
        base += [image_tag]
    code, out, err = run_cmd(base, timeout=timeout)
    return code, out + "\n" + err

# ====== 通过 LLM 规划或修正 CMD(带 function calling)======
# If previous run failed, infer a better command from the error/logs. 如果之前的运行失败,则从错误/日志中推断出更好的命令。
SYSTEM_PROMPT = """You are a DevOps agent that generates runnable Dockerfiles for PyPI packages.
Rules:
- Prefer EXEC JSON CMD array, no shell forms.
- Avoid root at runtime (already handled by base image).
- If the package exposes a console_script, use it with --help (non-blocking).
- Otherwise try: python -m <package>.
- If previous run failed, infer a better command from the error/logs.
Return only by calling the tool 'propose_cmd' with {"package": "...", "previous_error": "..."}.
"""

def plan_cmd_with_llm(package: str, previous_error: str) -> Dict[str, Any]:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": f"Package: {package}\nPrevious error/logs:\n{previous_error or '(none)'}"}
    ]
    try:
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS_SPEC,
            tool_choice="auto",
            temperature=0.2,
        )
    except APIStatusError as e:
        if e.status_code == 402:
            print("❌ API 402 Insufficient Balance:用本地启发式 fallback。")
            return propose_cmd_local(package, previous_error)
        raise

    msg = resp.choices[0].message
    if msg.tool_calls:
        # 只取第一条 tool_call
        tc = msg.tool_calls[0]
        name = tc.function.name
        args = {}
        try:
            args = json.loads(tc.function.arguments or "{}")
        except Exception:
            args = {}
        result = dispatch_tool_call(name, args)
        return result
    else:
        # 模型没按要求走 tool,fallback
        return propose_cmd_local(package, previous_error)

# ====== 生成 Dockerfile 文本 ======
def make_dockerfile(package: str, cmd: List[str], expose: int = None, extra_copy: str = "") -> str:
    # 如果传了端口号(如 8000),生成一行 EXPOSE 8000;否则为空字符串。
    expose_line = f"EXPOSE {expose}" if expose else ""
    extra_copy = extra_copy or ""
    return DOCKERFILE_TPL.format(
        package_spec=shlex.quote(package),
        expose_line=expose_line,
        cmd_json=json.dumps(cmd),
        extra_copy=extra_copy
    )

# ====== 主流程 ======
def main(package: str):
    work = Path(tempfile.mkdtemp(prefix=f"pypi_{package}_"))
    dockerfile_path = work / "Dockerfile"

    print(f"📦 Target PyPI package: {package}")
    print(f"🗂  Workdir: {work}")

    previous_error = ""
    for step in range(1, MAX_STEPS + 1):
        print(f"\n===== Attempt {step}/{MAX_STEPS} =====")
        # 1) 让 LLM(或本地启发式)给一版 CMD(和可选 expose)
        plan = plan_cmd_with_llm(package, previous_error)
        # 兜底:如果计划不含 cmd,用模块执行。
        cmd = plan.get("cmd", ["python", "-m", package])
        expose = plan.get("expose", None)
        print(f"🔧 Proposed CMD: {cmd}  (expose={expose})")

        # 2) 渲染 Dockerfile
        dockerfile_text = make_dockerfile(package, cmd, expose=expose)
        dockerfile_path.write_text(dockerfile_text, encoding="utf-8")
        print("📝 Dockerfile generated.")

        # 3) 构建镜像
        image_tag = f"{IMAGE_PREFIX}:{package}-{step}"
        print(f"🏗  docker build -t {image_tag}")
        ok, build_log = docker_build(image_tag, dockerfile_path)
        if not ok:
            # 构建失败,多数是系统依赖或包名错误;把日志反馈回去迭代
            print("❌ Build failed. Will refine.")
            previous_error = f"[BUILD FAIL]\n{tail(build_log, 1000)}"
            continue

        # 4) 运行容器(若 CMD 是阻塞型长跑服务,建议先尝试 --help 或短命令)
        print(f"🚀 docker run {image_tag}")
        code, run_log = docker_run(image_tag, timeout=45)
        if code == 0:
            print("✅ Run succeeded!")
            print("====== OUTPUT ======")
            print(tail(run_log, 1200))
            print("====================")
            print(f"🎉 Success with image: {image_tag}")
            return
        else:
            print("⚠️ Run failed, try to refine CMD.")
            previous_error = f"[RUN FAIL code={code}]\n{tail(run_log, 1500)}"

    print("\n⛔ Reached max attempts. Last error/logs:")
    print(previous_error)

def tail(s: str, n: int) -> str:
    # 取字符串后 n 字符,防止日志过长
    return s[-n:] if len(s) > n else s

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法: python autodocker_pypi_agent.py <pypi_package_name>")
        sys.exit(1)
    pkg = sys.argv[1].strip()
    main(pkg)
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐