Skip to content

第35章 应用打包与部署

学习目标

完成本章学习后,你将能够:

  1. 理解打包原理:Python应用打包的工作机制
  2. 使用PyInstaller打包:创建独立可执行文件
  3. 使用Nuitka编译:将Python编译为原生代码
  4. 配置打包选项:图标、版本信息、资源文件
  5. 实现代码保护:代码混淆、加密、许可证管理
  6. 使用Docker部署:容器化Python应用
  7. 配置CI/CD流程:自动化构建和部署
  8. 管理应用发布:版本管理、更新机制

35.1 打包基础

35.1.1 打包概述

┌─────────────────────────────────────────────────────────────────────┐
│                     Python应用打包流程                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐         │
│  │  Python源码  │───►│  依赖分析    │───►│  资源收集    │         │
│  │  (.py文件)   │    │  (imports)   │    │  (数据文件)  │         │
│  └──────────────┘    └──────────────┘    └──────────────┘         │
│         │                   │                   │                  │
│         └───────────────────┼───────────────────┘                  │
│                             ▼                                      │
│                    ┌──────────────┐                                │
│                    │  打包引擎    │                                │
│                    │              │                                │
│                    │ • PyInstaller│                                │
│                    │ • Nuitka     │                                │
│                    │ • cx_Freeze  │                                │
│                    │ • py2exe     │                                │
│                    └──────────────┘                                │
│                             │                                      │
│                             ▼                                      │
│         ┌───────────────────────────────────────────────┐          │
│         │              输出格式                         │          │
│         │                                               │          │
│         │  ┌─────────┐  ┌─────────┐  ┌─────────┐      │          │
│         │  │ 单文件  │  │ 目录    │  │ 安装包  │      │          │
│         │  │ (.exe)  │  │ (dist/) │  │ (.msi)  │      │          │
│         │  └─────────┘  └─────────┘  └─────────┘      │          │
│         └───────────────────────────────────────────────┘          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

35.1.2 项目结构规范

python
import os
from pathlib import Path
from typing import List, Dict, Optional
from dataclasses import dataclass
import json
import shutil


@dataclass
class ProjectConfig:
    name: str
    version: str
    author: str
    description: str
    entry_point: str
    python_version: str = "3.10"
    license: str = "MIT"

    def to_dict(self) -> Dict:
        return {
            "name": self.name,
            "version": self.version,
            "author": self.author,
            "description": self.description,
            "entry_point": self.entry_point,
            "python_version": self.python_version,
            "license": self.license
        }

    @classmethod
    def from_dict(cls, data: Dict) -> "ProjectConfig":
        return cls(
            name=data.get("name", ""),
            version=data.get("version", "0.1.0"),
            author=data.get("author", ""),
            description=data.get("description", ""),
            entry_point=data.get("entry_point", "main.py"),
            python_version=data.get("python_version", "3.10"),
            license=data.get("license", "MIT")
        )


class ProjectStructure:
    STANDARD_STRUCTURE = {
        "src": ["__init__.py", "main.py", "config.py"],
        "tests": ["__init__.py", "test_main.py"],
        "docs": [],
        "resources": [],
        "config": [],
        "": ["README.md", "LICENSE", "requirements.txt", ".gitignore", "setup.py"]
    }

    def __init__(self, project_path: Path):
        self.project_path = Path(project_path)

    def create_structure(self, config: ProjectConfig) -> None:
        self.project_path.mkdir(parents=True, exist_ok=True)

        for directory, files in self.STANDARD_STRUCTURE.items():
            dir_path = self.project_path / directory if directory else self.project_path
            dir_path.mkdir(exist_ok=True)

            for file in files:
                file_path = dir_path / file
                if not file_path.exists():
                    self._create_file(file_path, file, config)

    def _create_file(self, file_path: Path, template_name: str, config: ProjectConfig) -> None:
        templates = {
            "README.md": self._readme_template(config),
            "requirements.txt": "",
            ".gitignore": self._gitignore_template(),
            "setup.py": self._setup_template(config),
            "__init__.py": "",
            "main.py": self._main_template(config),
            "config.py": self._config_template(),
            "test_main.py": self._test_template(config)
        }

        content = templates.get(template_name, "")
        file_path.write_text(content, encoding="utf-8")

    def _readme_template(self, config: ProjectConfig) -> str:
        return f"""# {config.name}

{config.description}

## Installation

```bash
pip install -r requirements.txt

Usage

bash
python src/main.py

Author

License

{config.license} """

def _gitignore_template(self) -> str:
    return """__pycache__/

*.py[cod] *$py.class *.so .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg .env .venv env/ venv/ ENV/ .idea/ .vscode/ *.log """

def _setup_template(self, config: ProjectConfig) -> str:
    return f'''from setuptools import setup, find_packages

setup( name="{config.name}", version="{config.version}", author="{config.author}", description="{config.description}", packages=find_packages(), python_requires=">={config.python_version}", install_requires=[], ) '''

def _main_template(self, config: ProjectConfig) -> str:
    return '''def main():
print("Hello, World!")

if name == "main": main() '''

def _config_template(self) -> str:
    return '''from dataclasses import dataclass

@dataclass class Config: debug: bool = False log_level: str = "INFO"

config = Config() '''

def _test_template(self, config: ProjectConfig) -> str:
    return '''import unittest

class TestMain(unittest.TestCase): def test_example(self): self.assertTrue(True)

if name == "main": unittest.main() '''

class DependencyManager: def init(self, project_path: Path): self.project_path = Path(project_path) self.requirements_file = self.project_path / "requirements.txt"

def get_installed_packages(self) -> Dict[str, str]:
    import pkg_resources
    return {pkg.key: pkg.version for pkg in pkg_resources.working_set}

def freeze_requirements(self) -> None:
    packages = self.get_installed_packages()
    content = "\n".join(f"{name}=={version}" for name, version in sorted(packages.items()))
    self.requirements_file.write_text(content)

def read_requirements(self) -> List[str]:
    if not self.requirements_file.exists():
        return []
    return [
        line.strip()
        for line in self.requirements_file.read_text().splitlines()
        if line.strip() and not line.startswith("#")
    ]

def install_requirements(self) -> None:
    import subprocess
    requirements = self.read_requirements()
    for req in requirements:
        subprocess.run(["pip", "install", req], check=True)

def add_requirement(self, package: str, version: Optional[str] = None) -> None:
    requirements = self.read_requirements()
    req_str = f"{package}=={version}" if version else package
    if req_str not in requirements:
        requirements.append(req_str)
        self.requirements_file.write_text("\n".join(requirements))

def remove_requirement(self, package: str) -> None:
    requirements = self.read_requirements()
    requirements = [r for r in requirements if not r.startswith(package)]
    self.requirements_file.write_text("\n".join(requirements))

## 35.2 PyInstaller打包

### 35.2.1 基本使用

```python
import subprocess
import sys
from pathlib import Path
from typing import List, Optional, Dict
from dataclasses import dataclass
from enum import Enum


class OutputMode(Enum):
    SINGLE_FILE = "onefile"
    DIRECTORY = "onedir"


@dataclass
class PyInstallerConfig:
    name: str
    entry_point: str
    mode: OutputMode = OutputMode.SINGLE_FILE
    console: bool = True
    icon: Optional[str] = None
    add_data: List[str] = None
    hidden_imports: List[str] = None
    exclude_modules: List[str] = None
    upx: bool = False
    clean: bool = True
    noconfirm: bool = True

    def __post_init__(self):
        if self.add_data is None:
            self.add_data = []
        if self.hidden_imports is None:
            self.hidden_imports = []
        if self.exclude_modules is None:
            self.exclude_modules = []


class PyInstallerBuilder:
    def __init__(self, config: PyInstallerConfig):
        self.config = config

    def build_command(self) -> List[str]:
        cmd = ["pyinstaller"]

        if self.config.mode == OutputMode.SINGLE_FILE:
            cmd.append("--onefile")
        else:
            cmd.append("--onedir")

        if not self.config.console:
            cmd.append("--noconsole")

        if self.config.icon:
            cmd.extend(["--icon", self.config.icon])

        for data in self.config.add_data:
            cmd.extend(["--add-data", data])

        for hidden in self.config.hidden_imports:
            cmd.extend(["--hidden-import", hidden])

        for exclude in self.config.exclude_modules:
            cmd.extend(["--exclude-module", exclude])

        if self.config.upx:
            cmd.append("--upx-dir")

        if self.config.clean:
            cmd.append("--clean")

        if self.config.noconfirm:
            cmd.append("--noconfirm")

        cmd.extend(["--name", self.config.name])
        cmd.append(self.config.entry_point)

        return cmd

    def build(self) -> bool:
        cmd = self.build_command()
        print(f"Running: {' '.join(cmd)}")

        try:
            result = subprocess.run(cmd, check=True, capture_output=True, text=True)
            print(result.stdout)
            return True
        except subprocess.CalledProcessError as e:
            print(f"Build failed: {e.stderr}")
            return False

    def get_output_path(self) -> Path:
        if self.config.mode == OutputMode.SINGLE_FILE:
            return Path("dist") / f"{self.config.name}.exe"
        else:
            return Path("dist") / self.config.name


class PyInstallerSpecGenerator:
    def __init__(self, config: PyInstallerConfig):
        self.config = config

    def generate_spec(self) -> str:
        return f'''# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(
    ['{self.config.entry_point}'],
    pathex=[],
    binaries=[],
    datas={self._format_add_data()},
    hiddenimports={self.config.hidden_imports},
    hookspath=[],
    hooksconfig={{}},
    runtime_hooks=[],
    excludes={self.config.exclude_modules},
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='{self.config.name}',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx={self.config.upx},
    upx_exclude=[],
    runtime_tmpdir=None,
    console={self.config.console},
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    {f'icon="{self.config.icon}"' if self.config.icon else ''}
)
'''

    def _format_add_data(self) -> str:
        if not self.config.add_data:
            return "[]"
        items = [f'("{src}", "{dest}")' for src, dest in self.config.add_data]
        return "[" + ", ".join(items) + "]"

    def save_spec(self, filepath: str) -> None:
        spec_content = self.generate_spec()
        Path(filepath).write_text(spec_content, encoding="utf-8")


class PyInstallerOptimizer:
    @staticmethod
    def analyze_imports(entry_point: str) -> Dict[str, List[str]]:
        import modulefinder

        finder = modulefinder.ModuleFinder()
        finder.run_script(entry_point)

        imports = {
            "standard": [],
            "third_party": [],
            "local": []
        }

        stdlib_path = Path(sys.prefix) / "Lib"

        for name, module in finder.modules.items():
            if module.__file__:
                module_path = Path(module.__file__)

                if module_path.is_relative_to(stdlib_path):
                    imports["standard"].append(name)
                elif "site-packages" in str(module_path):
                    imports["third_party"].append(name)
                else:
                    imports["local"].append(name)

        return imports

    @staticmethod
    def get_recommended_excludes() -> List[str]:
        return [
            "tkinter",
            "unittest",
            "pydoc",
            "doctest",
            "test",
            "tests",
            "pytest",
            "IPython",
            "jupyter",
            "notebook",
            "sphinx",
            "docutils"
        ]

    @staticmethod
    def estimate_size(project_path: Path) -> int:
        total_size = 0
        for path in project_path.rglob("*"):
            if path.is_file():
                total_size += path.stat().st_size
        return total_size

35.2.2 高级配置

python
class VersionInfoBuilder:
    @staticmethod
    def create_version_info(
        version: str,
        company: str,
        copyright_: str,
        name: str,
        description: str,
        internal_name: str = None,
        original_filename: str = None,
        product_name: str = None
    ) -> str:
        version_parts = version.split(".")
        while len(version_parts) < 4:
            version_parts.append("0")
        major, minor, patch, build = version_parts

        internal_name = internal_name or name.replace(" ", "")
        original_filename = original_filename or f"{internal_name}.exe"
        product_name = product_name or name

        return f'''VSVersionInfo(
  ffi=FixedFileInfo(
    filevers=({major}, {minor}, {patch}, {build}),
    prodvers=({major}, {minor}, {patch}, {build}),
    mask=0x3f,
    flags=0x0,
    OS=0x40004,
    fileType=0x1,
    subtype=0x0,
    date=(0, 0)
  ),
  kids=[
    StringFileInfo(
      [
        StringTable(
          u'080404b0',
          [
            StringStruct(u'CompanyName', u'{company}'),
            StringStruct(u'FileDescription', u'{description}'),
            StringStruct(u'FileVersion', u'{version}'),
            StringStruct(u'InternalName', u'{internal_name}'),
            StringStruct(u'LegalCopyright', u'{copyright_}'),
            StringStruct(u'OriginalFilename', u'{original_filename}'),
            StringStruct(u'ProductName', u'{product_name}'),
            StringStruct(u'ProductVersion', u'{version}')
          ]
        )
      ]
    ),
    VarFileInfo([VarStruct(u'Translation', [2052, 1200])])
  ]
)
'''

    @staticmethod
    def save_version_file(filepath: str, content: str) -> None:
        Path(filepath).write_text(content, encoding="utf-8")


class ResourceHandler:
    def __init__(self, project_path: Path):
        self.project_path = Path(project_path)
        self.resources: List[Dict] = []

    def add_resource(
        self,
        source: str,
        destination: str,
        resource_type: str = "data"
    ) -> None:
        self.resources.append({
            "source": source,
            "destination": destination,
            "type": resource_type
        })

    def add_directory(
        self,
        source_dir: str,
        destination_dir: str
    ) -> None:
        source_path = self.project_path / source_dir
        for file_path in source_path.rglob("*"):
            if file_path.is_file():
                relative = file_path.relative_to(source_path)
                self.add_resource(
                    str(file_path),
                    str(Path(destination_dir) / relative),
                    "file"
                )

    def get_pyinstaller_data(self) -> List[str]:
        return [
            f"{r['source']};{r['destination']}"
            for r in self.resources
        ]

    def copy_to_dist(self, dist_path: Path) -> None:
        for resource in self.resources:
            source = Path(resource["source"])
            dest = dist_path / resource["destination"]

            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, dest)


class HookGenerator:
    @staticmethod
    def create_hook(package_name: str, hidden_imports: List[str]) -> str:
        imports_str = ", ".join(f"'{imp}'" for imp in hidden_imports)
        return f'''from PyInstaller.utils.hooks import collect_hidden_imports

hiddenimports = [{imports_str}]

def get_hidden_imports():
    return hiddenimports
'''

    @staticmethod
    def create_data_hook(package_name: str, data_files: List[Tuple[str, str]]) -> str:
        datas_str = ", ".join(f"('{src}', '{dest}')" for src, dest in data_files)
        return f'''from PyInstaller.utils.hooks import collect_data_files

datas = [{datas_str}]

def get_datas():
    return datas
'''

    @staticmethod
    def save_hook(hook_dir: Path, package_name: str, content: str) -> None:
        hook_dir.mkdir(parents=True, exist_ok=True)
        hook_file = hook_dir / f"hook-{package_name}.py"
        hook_file.write_text(content, encoding="utf-8")

35.3 Nuitka编译

35.3.1 基本使用

python
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum


class NuitkaMode(Enum):
    STANDALONE = "standalone"
    ONEFILE = "onefile"
    MODULE = "module"
    PACKAGE = "package"


@dataclass
class NuitkaConfig:
    name: str
    entry_point: str
    mode: NuitkaMode = NuitkaMode.ONEFILE
    output_dir: str = "dist"
    python_version: Optional[str] = None
    follow_imports: bool = True
    follow_stdlib: bool = False
    include_packages: List[str] = None
    include_modules: List[str] = None
    exclude_modules: List[str] = None
    enable_plugin: List[str] = None
    disable_plugin: List[str] = None
    windows_icon: Optional[str] = None
    windows_company_name: Optional[str] = None
    windows_product_name: Optional[str] = None
    windows_file_version: Optional[str] = None
    windows_product_version: Optional[str] = None
    console: bool = True
    lto: bool = False

    def __post_init__(self):
        if self.include_packages is None:
            self.include_packages = []
        if self.include_modules is None:
            self.include_modules = []
        if self.exclude_modules is None:
            self.exclude_modules = []
        if self.enable_plugin is None:
            self.enable_plugin = []
        if self.disable_plugin is None:
            self.disable_plugin = []


class NuitkaBuilder:
    def __init__(self, config: NuitkaConfig):
        self.config = config

    def build_command(self) -> List[str]:
        cmd = [
            sys.executable,
            "-m",
            "nuitka",
            f"--{self.config.mode.value}",
            "--output-dir=" + self.config.output_dir,
            "--output-filename=" + self.config.name
        ]

        if self.config.follow_imports:
            cmd.append("--follow-imports")

        if self.config.follow_stdlib:
            cmd.append("--follow-stdlib")

        for pkg in self.config.include_packages:
            cmd.append(f"--include-package={pkg}")

        for mod in self.config.include_modules:
            cmd.append(f"--include-module={mod}")

        for mod in self.config.exclude_modules:
            cmd.append(f"--nofollow-import-to={mod}")

        for plugin in self.config.enable_plugin:
            cmd.append(f"--enable-plugin={plugin}")

        for plugin in self.config.disable_plugin:
            cmd.append(f"--disable-plugin={plugin}")

        if self.config.windows_icon:
            cmd.append(f"--windows-icon-from-ico={self.config.windows_icon}")

        if self.config.windows_company_name:
            cmd.append(f"--windows-company-name={self.config.windows_company_name}")

        if self.config.windows_product_name:
            cmd.append(f"--windows-product-name={self.config.windows_product_name}")

        if self.config.windows_file_version:
            cmd.append(f"--windows-file-version={self.config.windows_file_version}")

        if self.config.windows_product_version:
            cmd.append(f"--windows-product-version={self.config.windows_product_version}")

        if not self.config.console:
            cmd.append("--disable-console")

        if self.config.lto:
            cmd.append("--lto=yes")

        cmd.append(self.config.entry_point)

        return cmd

    def build(self) -> bool:
        cmd = self.build_command()
        print(f"Running: {' '.join(cmd)}")

        try:
            result = subprocess.run(cmd, check=True, capture_output=True, text=True)
            print(result.stdout)
            return True
        except subprocess.CalledProcessError as e:
            print(f"Build failed: {e.stderr}")
            return False


class NuitkaPluginManager:
    KNOWN_PLUGINS = {
        "tkinter": "tk-inter",
        "pyqt5": "pyqt5",
        "pyqt6": "pyqt6",
        "pyside2": "pyside2",
        "pyside6": "pyside6",
        "pygame": "pygame",
        "matplotlib": "matplotlib",
        "numpy": "numpy",
        "pandas": "pandas",
        "torch": "torch",
        "tensorflow": "tensorflow"
    }

    @classmethod
    def detect_plugins(cls, imports: List[str]) -> List[str]:
        detected = []
        for imp in imports:
            for pkg, plugin in cls.KNOWN_PLUGINS.items():
                if pkg in imp.lower():
                    detected.append(plugin)
        return list(set(detected))

    @classmethod
    def get_plugin_options(cls, plugin: str) -> List[str]:
        options = {
            "tk-inter": ["--enable-plugin=tk-inter"],
            "pyqt5": ["--enable-plugin=pyqt5", "--include-qt-plugins=sensible"],
            "pyqt6": ["--enable-plugin=pyqt6", "--include-qt-plugins=sensible"],
            "matplotlib": ["--enable-plugin=matplotlib", "--include-data-files=matplotlib"],
            "numpy": ["--enable-plugin=numpy"]
        }
        return options.get(plugin, [f"--enable-plugin={plugin}"])

35.4 代码保护

35.4.1 代码混淆

python
import ast
import random
import string
from typing import Dict, Set, Optional


class NameObfuscator(ast.NodeTransformer):
    def __init__(self):
        self.name_mapping: Dict[str, str] = {}
        self.builtins: Set[str] = set(dir(__builtins__))
        self.preserved: Set[str] = {
            "__name__", "__file__", "__doc__", "__package__",
            "__init__", "__main__", "__all__", "__version__"
        }

    def _generate_name(self) -> str:
        chars = string.ascii_letters + string.digits
        return "_" + "".join(random.choices(chars, k=8))

    def _should_rename(self, name: str) -> bool:
        return (
            name not in self.builtins and
            name not in self.preserved and
            not name.startswith("__")
        )

    def visit_Name(self, node: ast.Name) -> ast.Name:
        if self._should_rename(node.id):
            if node.id not in self.name_mapping:
                self.name_mapping[node.id] = self._generate_name()
            node.id = self.name_mapping[node.id]
        return node

    def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
        if self._should_rename(node.name):
            if node.name not in self.name_mapping:
                self.name_mapping[node.name] = self._generate_name()
            node.name = self.name_mapping[node.name]

        for arg in node.args.args:
            if self._should_rename(arg.arg):
                if arg.arg not in self.name_mapping:
                    self.name_mapping[arg.arg] = self._generate_name()
                arg.arg = self.name_mapping[arg.arg]

        self.generic_visit(node)
        return node

    def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef:
        if self._should_rename(node.name):
            if node.name not in self.name_mapping:
                self.name_mapping[node.name] = self._generate_name()
            node.name = self.name_mapping[node.name]
        self.generic_visit(node)
        return node


class CodeObfuscator:
    def __init__(self):
        self.obfuscator = NameObfuscator()

    def obfuscate(self, source_code: str) -> str:
        tree = ast.parse(source_code)
        modified_tree = self.obfuscator.visit(tree)
        ast.fix_missing_locations(modified_tree)
        return ast.unparse(modified_tree)

    def obfuscate_file(self, input_path: str, output_path: str) -> None:
        source = Path(input_path).read_text(encoding="utf-8")
        obfuscated = self.obfuscate(source)
        Path(output_path).write_text(obfuscated, encoding="utf-8")


class StringEncoder:
    @staticmethod
    def encode_string(s: str, key: int = 42) -> str:
        return "".join(chr(ord(c) ^ key) for c in s)

    @staticmethod
    def decode_string(s: str, key: int = 42) -> str:
        return "".join(chr(ord(c) ^ key) for c in s)

    @staticmethod
    def encode_string_literal(s: str, key: int = 42) -> str:
        encoded = StringEncoder.encode_string(s, key)
        encoded_bytes = encoded.encode("utf-8")
        hex_string = encoded_bytes.hex()
        return f'_decode("{hex_string}", {key})'

    @staticmethod
    def generate_decoder_function() -> str:
        return '''
def _decode(hex_str, key):
    import codecs
    encoded = codecs.decode(hex_str, 'hex').decode('utf-8')
    return ''.join(chr(ord(c) ^ key) for c in encoded)
'''

35.4.2 许可证管理

python
import hashlib
import hmac
import json
import base64
from datetime import datetime, timedelta
from typing import Optional, Dict
from dataclasses import dataclass
from enum import Enum


class LicenseType(Enum):
    TRIAL = "trial"
    STANDARD = "standard"
    PROFESSIONAL = "professional"
    ENTERPRISE = "enterprise"


@dataclass
class LicenseInfo:
    user_name: str
    email: str
    license_type: LicenseType
    expiration_date: Optional[datetime]
    features: Dict[str, bool]
    machine_id: Optional[str] = None

    def to_dict(self) -> Dict:
        return {
            "user_name": self.user_name,
            "email": self.email,
            "license_type": self.license_type.value,
            "expiration_date": self.expiration_date.isoformat() if self.expiration_date else None,
            "features": self.features,
            "machine_id": self.machine_id
        }

    @classmethod
    def from_dict(cls, data: Dict) -> "LicenseInfo":
        return cls(
            user_name=data["user_name"],
            email=data["email"],
            license_type=LicenseType(data["license_type"]),
            expiration_date=datetime.fromisoformat(data["expiration_date"]) if data.get("expiration_date") else None,
            features=data.get("features", {}),
            machine_id=data.get("machine_id")
        )


class LicenseGenerator:
    def __init__(self, secret_key: str):
        self.secret_key = secret_key.encode()

    def generate_license(
        self,
        user_name: str,
        email: str,
        license_type: LicenseType,
        days_valid: Optional[int] = None,
        features: Optional[Dict[str, bool]] = None,
        machine_id: Optional[str] = None
    ) -> str:
        expiration = None
        if days_valid:
            expiration = datetime.now() + timedelta(days=days_valid)

        license_info = LicenseInfo(
            user_name=user_name,
            email=email,
            license_type=license_type,
            expiration_date=expiration,
            features=features or {},
            machine_id=machine_id
        )

        payload = json.dumps(license_info.to_dict(), sort_keys=True)
        signature = hmac.new(
            self.secret_key,
            payload.encode(),
            hashlib.sha256
        ).hexdigest()

        license_data = {
            "payload": payload,
            "signature": signature
        }

        encoded = base64.b64encode(json.dumps(license_data).encode()).decode()
        return encoded

    def generate_trial_license(self, user_name: str, email: str, days: int = 30) -> str:
        return self.generate_license(
            user_name=user_name,
            email=email,
            license_type=LicenseType.TRIAL,
            days_valid=days,
            features={"basic": True}
        )


class LicenseValidator:
    def __init__(self, secret_key: str):
        self.secret_key = secret_key.encode()

    def validate_license(self, license_key: str) -> tuple[bool, Optional[LicenseInfo], str]:
        try:
            decoded = base64.b64decode(license_key.encode()).decode()
            license_data = json.loads(decoded)

            payload = license_data["payload"]
            signature = license_data["signature"]

            expected_signature = hmac.new(
                self.secret_key,
                payload.encode(),
                hashlib.sha256
            ).hexdigest()

            if not hmac.compare_digest(signature, expected_signature):
                return False, None, "Invalid license signature"

            license_info = LicenseInfo.from_dict(json.loads(payload))

            if license_info.expiration_date:
                if datetime.now() > license_info.expiration_date:
                    return False, license_info, "License has expired"

            if license_info.machine_id:
                current_machine_id = self.get_machine_id()
                if license_info.machine_id != current_machine_id:
                    return False, license_info, "License not valid for this machine"

            return True, license_info, "License is valid"

        except Exception as e:
            return False, None, f"License validation error: {str(e)}"

    @staticmethod
    def get_machine_id() -> str:
        import platform
        import uuid

        system_info = f"{platform.node()}-{platform.machine()}-{uuid.getnode()}"
        return hashlib.sha256(system_info.encode()).hexdigest()[:16]

    def check_feature(self, license_info: LicenseInfo, feature: str) -> bool:
        return license_info.features.get(feature, False)


class LicenseManager:
    def __init__(self, secret_key: str, license_file: str = "license.key"):
        self.generator = LicenseGenerator(secret_key)
        self.validator = LicenseValidator(secret_key)
        self.license_file = license_file
        self._cached_license: Optional[LicenseInfo] = None

    def save_license(self, license_key: str) -> bool:
        valid, _, message = self.validator.validate_license(license_key)
        if valid:
            Path(self.license_file).write_text(license_key)
            self._cached_license = None
            return True
        return False

    def load_license(self) -> Optional[LicenseInfo]:
        if self._cached_license:
            return self._cached_license

        license_path = Path(self.license_file)
        if not license_path.exists():
            return None

        license_key = license_path.read_text().strip()
        valid, license_info, _ = self.validator.validate_license(license_key)

        if valid:
            self._cached_license = license_info
            return license_info
        return None

    def is_licensed(self) -> bool:
        return self.load_license() is not None

    def get_license_type(self) -> Optional[LicenseType]:
        info = self.load_license()
        return info.license_type if info else None

    def days_remaining(self) -> Optional[int]:
        info = self.load_license()
        if info and info.expiration_date:
            delta = info.expiration_date - datetime.now()
            return max(0, delta.days)
        return None

35.5 Docker部署

35.5.1 Dockerfile编写

python
from dataclasses import dataclass
from typing import List, Optional, Dict
from enum import Enum


class PythonVersion(Enum):
    PY38 = "3.8"
    PY39 = "3.9"
    PY310 = "3.10"
    PY311 = "3.11"
    PY312 = "3.12"


@dataclass
class DockerConfig:
    python_version: PythonVersion = PythonVersion.PY311
    workdir: str = "/app"
    expose_port: int = 8000
    entry_point: str = "main.py"
    requirements_file: str = "requirements.txt"
    environment_vars: Dict[str, str] = None
    run_commands: List[str] = None
    copy_files: List[str] = None
    user: Optional[str] = None
    healthcheck: Optional[str] = None

    def __post_init__(self):
        if self.environment_vars is None:
            self.environment_vars = {}
        if self.run_commands is None:
            self.run_commands = []
        if self.copy_files is None:
            self.copy_files = ["."]


class DockerfileGenerator:
    def __init__(self, config: DockerConfig):
        self.config = config

    def generate(self) -> str:
        lines = []

        lines.append(f"FROM python:{self.config.python_version.value}-slim")
        lines.append("")

        lines.append("WORKDIR " + self.config.workdir)
        lines.append("")

        lines.append("COPY " + self.config.requirements_file + " .")
        lines.append("RUN pip install --no-cache-dir -r " + self.config.requirements_file)
        lines.append("")

        for cmd in self.config.run_commands:
            lines.append("RUN " + cmd)
        if self.config.run_commands:
            lines.append("")

        for var, value in self.config.environment_vars.items():
            lines.append(f"ENV {var}={value}")
        if self.config.environment_vars:
            lines.append("")

        for copy_spec in self.config.copy_files:
            lines.append("COPY " + copy_spec)
        lines.append("")

        if self.config.user:
            lines.append("USER " + self.config.user)
            lines.append("")

        lines.append(f"EXPOSE {self.config.expose_port}")
        lines.append("")

        if self.config.healthcheck:
            lines.append("HEALTHCHECK " + self.config.healthcheck)
            lines.append("")

        lines.append(f'CMD ["python", "{self.config.entry_point}"]')

        return "\n".join(lines)

    def save(self, filepath: str = "Dockerfile") -> None:
        content = self.generate()
        Path(filepath).write_text(content, encoding="utf-8")


class DockerComposeGenerator:
    def __init__(self, project_name: str):
        self.project_name = project_name
        self.services: Dict[str, Dict] = {}
        self.networks: Dict[str, Dict] = {}
        self.volumes: Dict[str, Dict] = {}

    def add_service(
        self,
        name: str,
        build: Optional[str] = None,
        image: Optional[str] = None,
        ports: List[str] = None,
        environment: Dict[str, str] = None,
        volumes: List[str] = None,
        depends_on: List[str] = None,
        command: Optional[str] = None
    ) -> None:
        service = {}

        if build:
            service["build"] = build
        if image:
            service["image"] = image
        if ports:
            service["ports"] = ports
        if environment:
            service["environment"] = environment
        if volumes:
            service["volumes"] = volumes
        if depends_on:
            service["depends_on"] = depends_on
        if command:
            service["command"] = command

        self.services[name] = service

    def add_network(self, name: str, driver: str = "bridge") -> None:
        self.networks[name] = {"driver": driver}

    def add_volume(self, name: str, driver: str = "local") -> None:
        self.volumes[name] = {"driver": driver}

    def generate(self) -> str:
        lines = [f"name: {self.project_name}", "", "services:"]

        for name, config in self.services.items():
            lines.append(f"  {name}:")
            for key, value in config.items():
                if isinstance(value, list):
                    lines.append(f"    {key}:")
                    for item in value:
                        lines.append(f"      - {item}")
                elif isinstance(value, dict):
                    lines.append(f"    {key}:")
                    for k, v in value.items():
                        lines.append(f"      {k}: {v}")
                else:
                    lines.append(f"    {key}: {value}")
            lines.append("")

        if self.networks:
            lines.append("networks:")
            for name, config in self.networks.items():
                lines.append(f"  {name}:")
                for key, value in config.items():
                    lines.append(f"    {key}: {value}")
            lines.append("")

        if self.volumes:
            lines.append("volumes:")
            for name, config in self.volumes.items():
                lines.append(f"  {name}:")
                for key, value in config.items():
                    lines.append(f"    {key}: {value}")

        return "\n".join(lines)

    def save(self, filepath: str = "docker-compose.yml") -> None:
        content = self.generate()
        Path(filepath).write_text(content, encoding="utf-8")

35.5.2 部署脚本

python
class DockerDeployer:
    def __init__(self, project_path: Path):
        self.project_path = Path(project_path)

    def build_image(self, tag: str, dockerfile: str = "Dockerfile") -> bool:
        cmd = ["docker", "build", "-t", tag, "-f", dockerfile, "."]
        try:
            subprocess.run(cmd, cwd=self.project_path, check=True)
            return True
        except subprocess.CalledProcessError:
            return False

    def run_container(
        self,
        image: str,
        name: str,
        ports: Dict[int, int] = None,
        environment: Dict[str, str] = None,
        volumes: Dict[str, str] = None,
        detached: bool = True
    ) -> bool:
        cmd = ["docker", "run"]

        if detached:
            cmd.append("-d")

        cmd.extend(["--name", name])

        if ports:
            for host_port, container_port in ports.items():
                cmd.extend(["-p", f"{host_port}:{container_port}"])

        if environment:
            for key, value in environment.items():
                cmd.extend(["-e", f"{key}={value}"])

        if volumes:
            for host_path, container_path in volumes.items():
                cmd.extend(["-v", f"{host_path}:{container_path}"])

        cmd.append(image)

        try:
            subprocess.run(cmd, check=True)
            return True
        except subprocess.CalledProcessError:
            return False

    def stop_container(self, name: str) -> bool:
        try:
            subprocess.run(["docker", "stop", name], check=True)
            return True
        except subprocess.CalledProcessError:
            return False

    def remove_container(self, name: str) -> bool:
        try:
            subprocess.run(["docker", "rm", name], check=True)
            return True
        except subprocess.CalledProcessError:
            return False

    def compose_up(self, compose_file: str = "docker-compose.yml") -> bool:
        cmd = ["docker", "compose", "-f", compose_file, "up", "-d"]
        try:
            subprocess.run(cmd, cwd=self.project_path, check=True)
            return True
        except subprocess.CalledProcessError:
            return False

    def compose_down(self, compose_file: str = "docker-compose.yml") -> bool:
        cmd = ["docker", "compose", "-f", compose_file, "down"]
        try:
            subprocess.run(cmd, cwd=self.project_path, check=True)
            return True
        except subprocess.CalledProcessError:
            return False

    def get_logs(self, container: str, follow: bool = False) -> str:
        cmd = ["docker", "logs"]
        if follow:
            cmd.append("-f")
        cmd.append(container)

        result = subprocess.run(cmd, capture_output=True, text=True)
        return result.stdout

    def push_image(self, tag: str, registry: str = None) -> bool:
        if registry:
            new_tag = f"{registry}/{tag}"
            subprocess.run(["docker", "tag", tag, new_tag], check=True)
            tag = new_tag

        try:
            subprocess.run(["docker", "push", tag], check=True)
            return True
        except subprocess.CalledProcessError:
            return False

35.6 CI/CD配置

35.6.1 GitHub Actions

python
class GitHubActionsGenerator:
    def __init__(self, project_name: str):
        self.project_name = project_name
        self.triggers: Dict = {"push": {"branches": ["main"]}}
        self.jobs: Dict[str, Dict] = {}

    def add_trigger(self, event: str, config: Dict) -> None:
        self.triggers[event] = config

    def add_job(
        self,
        name: str,
        runs_on: str = "ubuntu-latest",
        steps: List[Dict] = None,
        needs: List[str] = None
    ) -> None:
        job = {"runs-on": runs_on}
        if steps:
            job["steps"] = steps
        if needs:
            job["needs"] = needs
        self.jobs[name] = job

    def generate_build_job(self, python_versions: List[str] = None) -> Dict:
        versions = python_versions or ["3.10", "3.11", "3.12"]
        return {
            "runs-on": "ubuntu-latest",
            "strategy": {
                "matrix": {"python-version": versions}
            },
            "steps": [
                {"uses": "actions/checkout@v4"},
                {
                    "name": "Set up Python ${{ matrix.python-version }}",
                    "uses": "actions/setup-python@v4",
                    "with": {"python-version": "${{ matrix.python-version }}"}
                },
                {
                    "name": "Install dependencies",
                    "run": "pip install -r requirements.txt"
                },
                {
                    "name": "Run tests",
                    "run": "pytest tests/"
                }
            ]
        }

    def generate_release_job(self) -> Dict:
        return {
            "runs-on": "windows-latest",
            "needs": ["build"],
            "if": "startsWith(github.ref, 'refs/tags/')",
            "steps": [
                {"uses": "actions/checkout@v4"},
                {
                    "name": "Set up Python",
                    "uses": "actions/setup-python@v4",
                    "with": {"python-version": "3.11"}
                },
                {
                    "name": "Install PyInstaller",
                    "run": "pip install pyinstaller"
                },
                {
                    "name": "Build executable",
                    "run": "pyinstaller --onefile --name ${{ github.event.repository.name }} src/main.py"
                },
                {
                    "name": "Create Release",
                    "uses": "softprops/action-gh-release@v1",
                    "with": {
                        "files": "dist/${{ github.event.repository.name }}.exe"
                    }
                }
            ]
        }

    def generate(self) -> str:
        import yaml

        workflow = {
            "name": self.project_name,
            "on": self.triggers,
            "jobs": self.jobs
        }

        return yaml.dump(workflow, default_flow_style=False, sort_keys=False)

    def save(self, filepath: str = ".github/workflows/main.yml") -> None:
        content = self.generate()
        path = Path(filepath)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")

35.7 知识图谱

35.7.1 打包工具对比

Python打包工具对比

┌─────────────────────────────────────────────────────────────┐
│                    打包方式分类                             │
├─────────────────────────────────────────────────────────────┤
│ 源码分发    sdist, wheel                                   │
│ 可执行文件  PyInstaller, Nuitka, cx_Freeze                 │
│ 容器化      Docker, Podman                                 │
│ 安装包      MSI, RPM, DEB                                  │
└─────────────────────────────────────────────────────────────┘

工具特点:
┌─────────────────────────────────────────┐
│ PyInstaller  打包简单、跨平台           │
│ Nuitka       编译为C、性能好            │
│ cx_Freeze    老牌工具、稳定             │
│ py2app       macOS专用                  │
└─────────────────────────────────────────┘

35.7.2 部署架构

应用部署架构

┌─────────────────────────────────────────┐
│ 开发环境 → 构建 → 测试 → 生产          │
│                                         │
│ 本地开发                                 │
│   ↓                                     │
│ CI/CD构建                               │
│   ↓                                     │
│ 镜像仓库                                 │
│   ↓                                     │
│ 生产部署                                 │
└─────────────────────────────────────────┘

35.8 技术选型指南

35.8.1 打包工具选型

场景推荐工具原因
快速打包PyInstaller简单易用
性能要求高Nuitka编译优化
跨平台PyInstaller支持广泛
macOS应用py2app原生体验

35.8.2 部署方案选型

场景推荐方案说明
单机部署可执行文件简单直接
云原生Docker + K8s弹性伸缩
传统服务器systemd服务稳定可靠

35.9 常见问题与解决方案

35.9.1 打包体积过大

python
# 问题:打包后体积过大
# 解决方案:排除不需要的模块

# PyInstaller spec文件
a = Analysis(
    ['main.py'],
    excludes=['tkinter', 'matplotlib', 'numpy'],
    # 排除不需要的模块
)

35.9.2 动态导入失败

python
# 问题:动态导入的模块丢失
# 解决方案:显式声明隐式导入

# PyInstaller
hiddenimports=['module1', 'module2']

35.9.3 跨平台兼容性

bash
# 问题:Windows打包在Linux无法运行
# 解决方案:在目标平台打包

# 使用Docker进行跨平台构建
docker build -t myapp .
docker run --rm -v $(pwd)/dist:/dist myapp

35.10 本章小结

本章详细介绍了Python应用打包与部署的核心概念和实践:

  1. 打包基础:项目结构、依赖管理
  2. PyInstaller:配置、构建、优化
  3. Nuitka:编译配置、插件管理
  4. 代码保护:混淆、加密、许可证
  5. Docker部署:Dockerfile、Docker Compose
  6. CI/CD:GitHub Actions自动化

练习题

  1. 使用PyInstaller打包一个GUI应用,包含图标和版本信息
  2. 使用Nuitka编译一个数据处理应用,优化编译选项
  3. 实现一个简单的许可证验证系统
  4. 为一个Web应用编写Dockerfile和docker-compose.yml
  5. 配置GitHub Actions实现自动构建和发布

扩展阅读

Python技术丛书 - 江苏省宿城中等专业学校