第35章 应用打包与部署
学习目标
完成本章学习后,你将能够:
- 理解打包原理:Python应用打包的工作机制
- 使用PyInstaller打包:创建独立可执行文件
- 使用Nuitka编译:将Python编译为原生代码
- 配置打包选项:图标、版本信息、资源文件
- 实现代码保护:代码混淆、加密、许可证管理
- 使用Docker部署:容器化Python应用
- 配置CI/CD流程:自动化构建和部署
- 管理应用发布:版本管理、更新机制
35.1 打包基础
35.1.1 打包概述
┌─────────────────────────────────────────────────────────────────────┐
│ Python应用打包流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Python源码 │───►│ 依赖分析 │───►│ 资源收集 │ │
│ │ (.py文件) │ │ (imports) │ │ (数据文件) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 打包引擎 │ │
│ │ │ │
│ │ • PyInstaller│ │
│ │ • Nuitka │ │
│ │ • cx_Freeze │ │
│ │ • py2exe │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 输出格式 │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ 单文件 │ │ 目录 │ │ 安装包 │ │ │
│ │ │ (.exe) │ │ (dist/) │ │ (.msi) │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘35.1.2 项目结构规范
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.txtUsage
python src/main.pyAuthor
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_size35.2.2 高级配置
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 基本使用
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 代码混淆
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 许可证管理
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 None35.5 Docker部署
35.5.1 Dockerfile编写
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 部署脚本
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 False35.6 CI/CD配置
35.6.1 GitHub Actions
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 打包体积过大
# 问题:打包后体积过大
# 解决方案:排除不需要的模块
# PyInstaller spec文件
a = Analysis(
['main.py'],
excludes=['tkinter', 'matplotlib', 'numpy'],
# 排除不需要的模块
)35.9.2 动态导入失败
# 问题:动态导入的模块丢失
# 解决方案:显式声明隐式导入
# PyInstaller
hiddenimports=['module1', 'module2']35.9.3 跨平台兼容性
# 问题:Windows打包在Linux无法运行
# 解决方案:在目标平台打包
# 使用Docker进行跨平台构建
docker build -t myapp .
docker run --rm -v $(pwd)/dist:/dist myapp35.10 本章小结
本章详细介绍了Python应用打包与部署的核心概念和实践:
- 打包基础:项目结构、依赖管理
- PyInstaller:配置、构建、优化
- Nuitka:编译配置、插件管理
- 代码保护:混淆、加密、许可证
- Docker部署:Dockerfile、Docker Compose
- CI/CD:GitHub Actions自动化
练习题
- 使用PyInstaller打包一个GUI应用,包含图标和版本信息
- 使用Nuitka编译一个数据处理应用,优化编译选项
- 实现一个简单的许可证验证系统
- 为一个Web应用编写Dockerfile和docker-compose.yml
- 配置GitHub Actions实现自动构建和发布