Skip to content

Ubuntu+Python镜像构造脚本

这个 Python 脚本是一个交互式工具,用于生成 Dockerfile 和 Makefile,以便快速构建一个定制化的 Python 开发环境的 Docker 镜像。它会引导用户选择基础 Ubuntu 版本、Python 版本、包管理器、软件源以及其他额外的软件包。

执行以下命令:

shell
wget https://alman.netlify.app/code/dup.py

# default
python3 dup.py --ubuntu 24 --ubuntu-mirror 3 --python 12 --pip-mirror 2 --pm 2 --extra-python-pkg 1 --extra-packages make --force

# 生成一个基于 Ubuntu 22.04,使用阿里云源,安装 Python 3.10,使用清华 pip 源和 uv 包管理工具的 Dockerfile
python3 dup.py --ubuntu 22 --ubuntu-mirror 3 --python 10 --pip-mirror 2 --pm 2

# 生成一个基于 Ubuntu 20.04,安装 Python 开发依赖,安装 make 和 curl 的 Dockerfile,并强制覆盖现有文件
python3 dup.py --ubuntu 20 --python 2 --extra-packages make curl --force

# selenium
python3 dup.py --ubuntu 22 --ubuntu-mirror 3 --python 11 --pip-mirror 2 --pm 2 --extra-python-pkg 2 --extra-packages make --force

脚本代码

python
#!/usr/bin/env python3

import argparse
import os
import sys

class DockerfileGenerator:
    # Color constants
    YELLOW = '\033[1;33m'
    CYAN = '\033[1;36m'
    BLUE = '\033[1;34m'
    RED = '\033[1;31m'
    NC = '\033[0m'

    def __init__(self, args=None):
        self.args = args
        self.ubuntu_choice = None
        self.ubuntu_mirror_choice = None
        self.pip_mirror = None
        self.pip_mirror_choice = None # Store the choice number
        self.python_choice = None
        self.py_pm_choice = None
        self.py_pm_choice_key = None # Store the choice key
        self.extra_python_pkg_choice = None
        self.selected_extra_packages = []

    def _select_from_menu(self, options_map, order_list, prompt, invalid_msg, arg_value=None):
        """
        选择菜单选项,优先使用命令行参数值,否则进入交互模式。
        """
        if arg_value is not None and str(arg_value) in options_map:
            choice = str(arg_value)
            print(f"{self.BLUE}使用命令行参数选择: {options_map[choice]}{self.NC}\n")
            return choice

        while True:
            print(f"{self.YELLOW}{prompt}{self.NC}")
            for idx in order_list:
                print(f"{self.CYAN}{idx}) {options_map[idx]}{self.NC}")

            choice = input(f"请输入你的选择({' '.join(map(str, order_list))}): ")
            if choice in options_map:
                print(f"{self.BLUE}你选择了{options_map[choice]}{self.NC}\n")
                return choice
            print(f"{self.RED}{invalid_msg}{self.NC}")

    def _write_to_dockerfile(self, content):
        """将内容写入 Dockerfile 文件"""
        with open("Dockerfile", "a") as f:
            f.write(f"{content}\n")

    def select_ubuntu_version(self):
        ubuntu_map = {
            "20": "ubuntu:20.04",
            "22": "ubuntu:22.04",
            "24": "ubuntu:24.04"
        }
        ubuntu_label_map = {
            "20": "Ubuntu 20.04 LTS(Focal Fossa)",
            "22": "Ubuntu 22.04 LTS(Jammy Jellyfish)",
            "24": "Ubuntu 24.04 LTS(Noble Numbat)"
        }
        choice = self._select_from_menu(ubuntu_label_map, list(ubuntu_map.keys()), "选择Ubuntu版本:", "无效的选择,请重新选择。", arg_value=self.args.ubuntu if self.args else None)

        with open("Dockerfile", "w") as f:
            f.write(f"FROM {ubuntu_map[choice]}\n")
        self.ubuntu_choice = choice

    def select_ubuntu_mirror(self):
        mirror_map = {
            "1": "官方源(不修改)",
            "2": "mirrors.ustc.edu.cn",
            "3": "mirrors.aliyun.com"
        }
        choice = self._select_from_menu(mirror_map, ["1", "2", "3"], "选择Ubuntu的源:", "无效的选择,请重新选择。", arg_value=self.args.ubuntu_mirror if self.args else None)

        if choice != "1":
            self._write_to_dockerfile("\n#设置mirror源")
            mirror_url = mirror_map[choice].split("(")[0] # Extract URL part
            self._write_to_dockerfile(f"RUN sed -i 's/archive.ubuntu.com/{mirror_url}/g' /etc/apt/sources.list")
        self._write_to_dockerfile("RUN apt update")
        self.ubuntu_mirror_choice = choice

    def set_default_timezone(self):
        self._write_to_dockerfile("\n#设置时区")
        self._write_to_dockerfile("RUN DEBIAN_FRONTEND=noninteractive TZ=Asia/Shanghai apt-get -y install tzdata")
        self._write_to_dockerfile("ENV TZ=Asia/Shanghai")
        print(f"{self.BLUE}设置时区为: Asia/Shanghai{self.NC}")

    def select_python_version(self):
        python_versions = {
            "1": "不安装",
            "2": "安装开发依赖",
            "7": "Python3.7",
            "8": "Python3.8",
            "9": "Python3.9",
            "10": "Python3.10",
            "11": "Python3.11",
            "12": "Python3.12",
            "13": "Python3.13",
        }

        ubuntu_python_map = {
            "20": "Python3.8",
            "22": "Python3.10",
            "24": "Python3.12",
        }

        # Mark system-自带 versions
        if self.ubuntu_choice in ubuntu_python_map.keys():
            v = ubuntu_python_map[self.ubuntu_choice]
            for ver in python_versions.keys():
                if python_versions[ver] == v and "(系统自带)" not in python_versions[ver]:
                    python_versions[ver] = f"{v} (系统自带)"


        choice = self._select_from_menu(python_versions, list(python_versions.keys()), "选择Python的版本:", "无效的选择,请重新选择。", arg_value=self.args.python if self.args else None)


        self._write_to_dockerfile("\n#安装Python")
        if choice == "1":
            pass
        elif choice == "2":
            self._write_to_dockerfile("RUN apt install -y python3 python3-dev python3-pip")
        else:
            version = choice

            if version == "11" and self.ubuntu_choice == "24":
                self._write_to_dockerfile("RUN apt install -y software-properties-common")
                self._write_to_dockerfile("RUN add-apt-repository ppa:deadsnakes/ppa")
                self._write_to_dockerfile("RUN apt update")
                self._write_to_dockerfile("RUN apt install -y python3.11-distutils")
            
            self._write_to_dockerfile(f"RUN apt install -y python3.{version} python3.{version}-dev python3-pip")
            self._write_to_dockerfile(f"RUN ln -sf /usr/bin/python3.{version} /usr/bin/python3")

        self.python_choice = choice


    def select_python_pip_mirror(self):
        mirror_map = {
            "1": "不使用",
            "2": "https://pypi.tuna.tsinghua.edu.cn/simple",
            "3": "https://mirrors.sustech.edu.cn/pypi/web/simple",
        }
        choice = self._select_from_menu(mirror_map, list(mirror_map.keys()), "选择pip源:", "无效的选择,请重新选择。", arg_value=self.args.pip_mirror if self.args else None)
        self.pip_mirror_choice = choice
        self.pip_mirror = "" if choice == "1" else mirror_map[choice]
        return self.pip_mirror


    def select_python_pm(self):
        pm_map = {
            "1": "pip",
            "2": "uv (推荐)",
            "3": "poetry",
            "4": "pdm",
        }
        choice = self._select_from_menu(pm_map, list(pm_map.keys()), "选择Python包管理工具:", "无效的选择,请重新选择。", arg_value=self.args.pm if self.args else None)
        self.py_pm_choice_key = choice
        self.py_pm_choice = pm_map[choice].replace(' (推荐)','')

        if choice != "1":
            self._write_to_dockerfile(f"\n#安装{self.py_pm_choice}")
            mirror_arg = f" -i {self.pip_mirror}" if self.pip_mirror else ""
            # self._write_to_dockerfile(f"RUN python3 -m pip install {self.py_pm_choice} --break-system-packages{mirror_arg}")
            self._write_to_dockerfile(f"RUN python3 -m pip install {self.py_pm_choice} {mirror_arg}")


    def select_python_extra(self):
        dockerfile_command_selenium = """# Install curl and other dependencies required for later steps
RUN apt-get install -y \
curl \
wget \
unzip \
libglib2.0-0 \
libnss3 \
libgconf-2-4 \
libfontconfig1 \
fonts-wqy-zenhei

# Add Google Chrome repository and install Chromium
RUN curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
apt-get install -y ./google-chrome-stable_current_amd64.deb && \
rm google-chrome-stable_current_amd64.deb && \
apt-get clean

RUN export CHROME_VERSION=$(google-chrome --version | awk '{print $3}') && \
echo "Chrome version installed: $CHROME_VERSION" && \
wget -q https://storage.googleapis.com/chrome-for-testing-public/$CHROME_VERSION/linux64/chromedriver-linux64.zip -O /tmp/chromedriver.zip

RUN unzip /tmp/chromedriver.zip -d /usr/local/bin/
RUN ls -al /usr/local/bin/
RUN rm /tmp/chromedriver.zip && chmod +x /usr/local/bin/chromedriver-linux64/chromedriver"""

        dockerfile_command_dotnet = """RUN apt install -y make
RUN apt-get install -y --no-install-recommends ca-certificates curl software-properties-common
RUN curl -sSL https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
RUN add-apt-repository "$(curl -sSL https://packages.microsoft.com/config/ubuntu/20.04/prod.list)"
RUN apt-get update && apt-get install -y --no-install-recommends dotnet-sdk-6.0
ENV PYTHONNET_RUNTIME=dotnet"""


        python_extra_pkg_map = {
            "1": "不安装(推荐)",
            "2": "安装selenium和chromedriver",
            "3": "安装.NET SDK"
        }
        choice = self._select_from_menu(python_extra_pkg_map, list(python_extra_pkg_map.keys()), "选择额外安装的Python相关包:", "无效的选择,请重新选择。", arg_value=self.args.extra_python_pkg if self.args else None)
        self.extra_python_pkg_choice = choice
        choice_label = python_extra_pkg_map[choice]

        if choice == '2':
            self._write_to_dockerfile(f"\n#{choice_label}")
            self._write_to_dockerfile(dockerfile_command_selenium)
            print(f"{self.BLUE}{choice_label}{self.NC}")
        elif choice == '3':
            self._write_to_dockerfile(f"\n#{choice_label}")
            # The original code always wrote dotnet command regardless of choice 2 or 3
            # Let's assume it meant to write dotnet command ONLY for choice 3
            self._write_to_dockerfile(dockerfile_command_dotnet)
            print(f"{self.BLUE}{choice_label}{self.NC}")


    def read_dockerfile(self):
        print(f"{self.BLUE}Dockerfile已生成,内容如下:{self.NC}")
        with open("Dockerfile", "r") as f:
            print(f.read())

    def install_extra_packages(self):
        extra_packages_map = {
            "1": "不安装",
            "2": "make",
            "3": "curl",
            "4": "wget",
            "5": "libpq-dev (for pgsql)"
        }
        extra_packages_keys = list(extra_packages_map.keys())
        # Map name back to key for args lookup
        extra_packages_names = {v.split(" ")[0]:k for k,v in extra_packages_map.items() if k != '1'}

        # Handle arguments for extra packages
        if self.args and self.args.extra_packages:
            selected_by_arg = []
            invalid_packages = []
            for pkg_name in self.args.extra_packages:
                if pkg_name in extra_packages_names:
                    selected_by_arg.append(extra_packages_map[extra_packages_names[pkg_name]].split(" ")[0])
                else:
                    invalid_packages.append(pkg_name)

            if invalid_packages:
                 print(f"{self.RED}无效的额外包名通过参数提供: {', '.join(invalid_packages)}. 请从以下选择: {', '.join([extra_packages_map[k].split(' ')[0] for k in extra_packages_keys if k != '1'])}{self.NC}")
            self.selected_extra_packages = selected_by_arg
            if self.selected_extra_packages:
                print(f"{self.BLUE}使用命令行参数选择额外包: {', '.join(self.selected_extra_packages)}{self.NC}\n")

        # If no arguments or arguments were insufficient, fall back to interactive
        if not self.selected_extra_packages or (self.args and not self.args.extra_packages):
            print(f"{self.YELLOW}选择额外安装的包 (选择1结束):{self.NC}")
            for idx in extra_packages_keys:
                 print(f"{self.CYAN}{idx}) {extra_packages_map[idx]}{self.NC}")

            while True:
                choice = input(f"请输入你的选择({' '.join(extra_packages_keys)}): ")

                if choice == "1":
                    break

                if choice in extra_packages_map:
                    package_name = extra_packages_map[choice].split(" ")[0]
                    if package_name not in self.selected_extra_packages:
                        self.selected_extra_packages.append(package_name)
                        print(f"{self.BLUE}已选择的包: {', '.join(self.selected_extra_packages)}{self.NC}")
                    else:
                         print(f"{self.YELLOW}包 '{package_name}' 已选择。{self.NC}")
                else:
                    print(f"{self.RED}无效的选择,请重新选择。{self.NC}")


        if self.selected_extra_packages:
            self._write_to_dockerfile("\n#安装额外的包")
            self._write_to_dockerfile(f"RUN apt install -y {' '.join(self.selected_extra_packages)}")


    def clear_apt_cache(self):
        self._write_to_dockerfile("\n#清理apt缓存")
        self._write_to_dockerfile("RUN apt clean")
        self._write_to_dockerfile("RUN rm -rf /var/lib/apt/lists/*")
        self._write_to_dockerfile("RUN rm -rf /var/cache/apt/archives/*")
        print(f"{self.BLUE}已清理apt缓存{self.NC}")

    @property
    def image_tag(self):
        """根据选择生成镜像标签"""
        tag_parts = [f'u{self.ubuntu_choice}']
        if self.python_choice:
            # Map python_choice back to a simple identifier if needed
            py_ident = self.python_choice
            if py_ident == '1':
                py_ident = 'none'
            elif py_ident == '2':
                 py_ident = 'dev'
            else:
                 py_ident = f'py{py_ident}' # e.g., py7, py12
            tag_parts.append(py_ident)

        if self.py_pm_choice:
            # Use the cleaned PM name (uv, poetry, pdm)
            tag_parts.append(self.py_pm_choice)

        # Add identifiers for other major choices if they are not defaults
        if self.ubuntu_mirror_choice and self.ubuntu_mirror_choice != '1':
            tag_parts.append(f'ubm{self.ubuntu_mirror_choice}') # ubm2, ubm3

        if self.pip_mirror_choice and self.pip_mirror_choice != '1':
             tag_parts.append(f'pm{self.pip_mirror_choice}') # pm2, pm3

        if self.extra_python_pkg_choice and self.extra_python_pkg_choice != '1':
             tag_parts.append(f'pext{self.extra_python_pkg_choice}') # pext2, pext3

        if self.selected_extra_packages:
             # Add a hash or abbreviated list for extra packages if too long
             extra_pkgs_str = "_".join(sorted(self.selected_extra_packages)).replace('-', '') # make_curl_wget -> makecurlwget
             if extra_pkgs_str:
                 tag_parts.append(f'extpkgs-{extra_pkgs_str}')


        return '-'.join(tag_parts).lower() # Use hyphens for clarity


    def dockerfile_is_exists(self):
        return os.path.exists("Dockerfile")

    def gen(self):
        """生成 Dockerfile 文件"""
        if self.dockerfile_is_exists():
            if not (self.args and self.args.force): # Allow forcing with argument
                 choice = input(f"{self.RED}Dockerfile已存在,是否覆盖?(y/n): {self.NC}")
                 if choice.lower() != 'y':
                    print(f"{self.RED}已取消操作{self.NC}")
                    return False
                 else:
                     os.remove("Dockerfile")
                     print(f"{self.YELLOW}已删除旧的Dockerfile{self.NC}")
            else:
                 os.remove("Dockerfile")
                 print(f"{self.YELLOW}Dockerfile已存在,通过命令行参数覆盖。{self.NC}")


        self.select_ubuntu_version()
        self.select_ubuntu_mirror()
        self.set_default_timezone()

        self.select_python_version()
        if self.python_choice != "1":
            self.select_python_pip_mirror()
            self.select_python_pm()
            self.select_python_extra()

        self.install_extra_packages()
        self.clear_apt_cache()

        self.read_dockerfile()

        return True

class MakefileGenerator:
    def __init__(self, tag_name, py_pm_choice):
        self.py_pm_choice = py_pm_choice
        self.makefile_content = f"""IMAGE_TAG := {tag_name}
WORK_DIR  := /root/project
CACHE_DIR := /root/.cache
OUT_WORK_DIR := $(PWD)/project
OUT_CACHE_DIR := $(PWD)/__env_cache__

DOCKER_RUN := docker run \\
--rm -it \\
-v $(OUT_WORK_DIR):$(WORK_DIR) \\
-v $(OUT_CACHE_DIR):$(CACHE_DIR) \\
-w $(WORK_DIR) \\
--net=host \\
$(IMAGE_TAG)

build:
	@docker build . -t $(IMAGE_TAG)
bash:
	@$(DOCKER_RUN) /bin/bash
"""

        self.makefile_content_uv = f"""
version:
	@$(DOCKER_RUN) uv version
	@$(DOCKER_RUN) uv run python -V
init:
	@$(DOCKER_RUN) uv init
install:
	@$(DOCKER_RUN) uv sync
add:
	@$(DOCKER_RUN) uv add $(name)
remove:
	@$(DOCKER_RUN) uv remove $(name)
run:
	@$(DOCKER_RUN) uv run $(name)
"""

        self.makefile_content_poetry = f"""
version:
	@$(DOCKER_RUN) poetry run python -V
init:
	@$(DOCKER_RUN) poetry init
install:
	@$(DOCKER_RUN) poetry install --no-root
add:
	@$(DOCKER_RUN) poetry add $(name)
remove:
	@$(DOCKER_RUN) poetry remove $(name)
run:
	@$(DOCKER_RUN) poetry run python $(name)
tests_env:
	@$(DOCKER_RUN) poetry add --group dev pytest pytest-asyncio
tests:
	@$(DOCKER_RUN) poetry run pytest tests.py
"""

        self.makefile_content_pdm = f"""
version:
	@$(DOCKER_RUN) pdm run python -V
init:
	@$(DOCKER_RUN) pdm init
install:
	@$(DOCKER_RUN) pdm install --no-self
add:
	@$(DOCKER_RUN) pdm add $(name)
run:
	@$(DOCKER_RUN) pdm run python $(name)
remove:
	@$(DOCKER_RUN) pdm remove $(name)
"""

    def gen(self):
        """生成 Makefile 文件"""
        if self.py_pm_choice == "uv":
            makefile_content = self.makefile_content + self.makefile_content_uv
        elif self.py_pm_choice == "poetry":
            makefile_content = self.makefile_content + self.makefile_content_poetry
        elif self.py_pm_choice == "pdm":
             makefile_content = self.makefile_content + self.makefile_content_pdm
        else: # Default or pip
             makefile_content = self.makefile_content # No extra PM commands for pip


        with open("Makefile", "w") as f:
            f.write(makefile_content)
        print(f"{DockerfileGenerator.BLUE}Makefile已生成,内容如下:{DockerfileGenerator.NC}")
        print(makefile_content)


def main():
    parser = argparse.ArgumentParser(description="Generate Dockerfile and Makefile interactively or via arguments.")
    parser.add_argument("--ubuntu", choices=["20", "22", "24"], help="Specify Ubuntu version (20, 22, 24)")
    parser.add_argument("--ubuntu-mirror", choices=["1", "2", "3"], help="Specify Ubuntu mirror (1: official, 2: USTC, 3: Aliyun)")
    parser.add_argument("--python", choices=["1", "2", "7", "8", "9", "10", "11", "12", "13"], help="Specify Python version or option (1: none, 2: dev, 7-13: specific version)")
    parser.add_argument("--pip-mirror", choices=["1", "2", "3"], help="Specify pip mirror (1: none, 2: Tsinghua, 3: SUSTech)")
    parser.add_argument("--pm", choices=["1", "2", "3", "4"], help="Specify Python package manager (1: pip, 2: uv, 3: poetry, 4: pdm)")
    parser.add_argument("--extra-python-pkg", choices=["1", "2", "3"], help="Specify extra Python related packages (1: none, 2: selenium/chromedriver, 3: .NET SDK)")
    parser.add_argument("--extra-packages", nargs='*', help="Specify additional apt packages (e.g., make curl wget libpq-dev)")
    parser.add_argument("-f", "--force", action="store_true", help="Force overwrite existing Dockerfile and Makefile")


    args = parser.parse_args()

    dg = DockerfileGenerator(args=args)
    if dg.gen():
        print(f"{DockerfileGenerator.BLUE}镜像标签: {dg.image_tag}{DockerfileGenerator.NC}")

        # Generate Makefile only if Dockerfile was successfully generated
        mg = MakefileGenerator(dg.image_tag, dg.py_pm_choice)
        mg.gen()

        # Generate and print the shortcut command based on the final choices
        command_parts = [sys.argv[0]] # script name

        # Add arguments based on final choices (from args or interactive)
        if dg.ubuntu_choice:
            command_parts.append(f"--ubuntu {dg.ubuntu_choice}")
        if dg.ubuntu_mirror_choice:
             command_parts.append(f"--ubuntu-mirror {dg.ubuntu_mirror_choice}")
        if dg.python_choice:
             command_parts.append(f"--python {dg.python_choice}")
        if dg.pip_mirror_choice:
             command_parts.append(f"--pip-mirror {dg.pip_mirror_choice}")
        if dg.py_pm_choice_key: # Use the key stored from selection
             command_parts.append(f"--pm {dg.py_pm_choice_key}")
        if dg.extra_python_pkg_choice:
             command_parts.append(f"--extra-python-pkg {dg.extra_python_pkg_choice}")
        if dg.selected_extra_packages:
             command_parts.append("--extra-packages")
             command_parts.extend(dg.selected_extra_packages) # These are already names

        if args.force:
             command_parts.append("--force")


        print("\n")
        print(f"{DockerfileGenerator.YELLOW}快捷命令参数提示:{DockerfileGenerator.NC}")
        print(f"{' '.join(command_parts)}")


if __name__ == "__main__":
    main()

Released under the MIT License.