五、模块、包与项目结构
阶段定位:模块和包是 Python 代码的组织单元。写好模块不仅是技术问题,更是工程素养的体现。本阶段从 import 机制出发,讲解如何组织出可维护、可复用的项目结构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ┌─────────────────────────────────────────────────────────────────┐ │ 模块、包与项目结构知识图谱 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. 模块 ──────→ import / sys.path / __name__ / 重载 │ │ │ │ │ 2. 包 ────────→ __init__.py / 绝对导入 / 相对导入 / 命名空间包 │ │ │ │ │ 3. 项目结构 ──→ src layout / pyproject.toml / README / tests │ │ │ │ │ 4. 循环导入 ──→ 重构 / 延迟导入 / 接口抽象 │ │ │ │ │ 5. 工程实践 ──→ import * / 副作用 / 版本管理 / zipapp │ │ │ └─────────────────────────────────────────────────────────────────┘
|
1. 模块(Module)
1.1 什么是模块
一个 .py 文件就是一个模块。模块是 Python 中最基本的代码复用单元。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| """数学工具模块。"""
PI = 3.14159265359
class Circle: def __init__(self, radius): self.radius = radius def area(self): return PI * self.radius ** 2
def add(a, b): return a + b
def multiply(a, b): return a * b
import math_utils print(math_utils.add(1, 2)) print(math_utils.PI) c = math_utils.Circle(5)
|
模块的本质:模块是一个包含 Python 定义和语句的文件,文件名就是模块名(去掉 .py)。导入模块时,Python 会执行文件中的所有顶级语句,并将结果存储在模块对象中。
1.2 导入机制
导入方式对比
| 方式 |
示例 |
适用场景 |
缺点 |
import |
import os |
标准库、长模块名 |
每次使用需写前缀 |
from ... import |
from os import path |
特定函数/类 |
命名空间污染 |
from ... import * |
from math import * |
交互式/小脚本 |
严重污染命名空间 |
as 别名 |
import numpy as np |
长模块名简化 |
团队需统一别名 |
1 2 3 4 5 6 7 8 9
| import os from pathlib import Path import requests import pandas as pd
from os import * from module import a, b, c, d, e, f
|
工程建议:
- 标准库:
import os(不简写)
- 第三方库:
import numpy as np(遵循社区约定)
- 自定义模块:
from myproject.utils import helper(明确具体对象)
1.3 模块搜索路径 sys.path
1 2 3 4
| import sys print(sys.path)
|
搜索顺序:
- 当前目录(运行脚本所在目录)
PYTHONPATH 环境变量中的目录
- 标准库目录
.pth 文件指定的目录
工程建议:不要直接修改 sys.path。正确做法:
- 开发时:在虚拟环境中
pip install -e .(可编辑安装)
- 运行时:使用
PYTHONPATH 环境变量
1 2 3
| export PYTHONPATH="/path/to/your/project:$PYTHONPATH" python -m mypackage.module
|
易错点:sys.path 中的第一个元素(空字符串或脚本所在目录)可能不是你期望的路径:
1 2 3
| python /home/user/app/main.py
|
1.4 if __name__ == "__main__"
1 2 3 4 5 6 7 8 9 10 11 12
|
def helper(): return "I'm a helper"
if __name__ == "__main__": print("直接运行 utils.py") print(helper())
|
__name__ 的值:
| 运行方式 |
__name__ |
| 直接运行文件 |
"__main__" |
| 被导入时 |
模块名(如 "utils") |
工程建议:每个模块都应该有 if __name__ == "__main__": 块,用于:
- 快速测试模块功能
- 提供命令行入口
- 运行简单的基准测试
1 2 3 4 5 6 7 8 9 10
| def main(): import argparse parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) args = parser.parse_args() process(args.input)
if __name__ == "__main__": main()
|
1.5 模块重载
1 2 3 4 5
| import importlib import my_module
importlib.reload(my_module)
|
工程建议:生产环境不要依赖模块重载,它可能导致状态不一致。仅在开发/调试时使用。
2. 包(Package)
2.1 包的基本结构
包是包含 __init__.py 的目录(Python 3.3+ 也支持隐式命名空间包,但显式更好)。
1 2 3 4 5 6 7 8 9 10 11
| myproject/ ├── __init__.py # 包初始化,控制导出内容 ├── core/ │ ├── __init__.py │ ├── models.py # 数据模型 │ └── services.py # 业务逻辑 ├── utils/ │ ├── __init__.py │ ├── helpers.py │ └── validators.py └── cli.py # 命令行入口
|
2.2 __init__.py 的作用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| """MyProject - 一个示例项目。"""
__version__ = "1.0.0"
__all__ = ["CoreService", "setup_logging"]
from myproject.core.services import CoreService from myproject.utils.helpers import setup_logging
|
1 2 3 4 5 6 7
| """工具包。"""
from .helpers import format_date, parse_json from .validators import validate_email, validate_phone
__all__ = ["format_date", "parse_json", "validate_email", "validate_phone"]
|
__init__.py 的多重角色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ┌─────────────────────────────────────────────────────────┐ │ __init__.py 的多重角色 │ ├─────────────────────────────────────────────────────────┤ │ │ │ 1. 标记目录为 Python 包 │ │ │ │ 2. 包级初始化代码(只执行一次) │ │ 如:注册信号、初始化数据库连接池 │ │ │ │ 3. 控制 `from package import *` 的导出内容 │ │ __all__ = ['func1', 'Class2'] │ │ │ │ 4. 简化导入路径 │ │ from .module import func → from package import func│ │ │ │ 5. 定义包级变量 │ │ __version__ = "1.0.0" │ │ │ └─────────────────────────────────────────────────────────┘
|
2.3 绝对导入与相对导入
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
from myproject.utils.helpers import format_date from myproject.core.models import User
from ..utils.helpers import format_date from .models import User
import sys sys.path.insert(0, "..") import helpers
|
工程建议:
- 项目顶层用绝对导入
- 包内部可用相对导入(
.module),但深度不要超过两级
- 永远不要使用
sys.path.insert 解决导入问题
易错点:相对导入只能在包内部使用,直接运行文件时不行:
2.4 命名空间包(Namespace Package)
Python 3.3+ 支持隐式命名空间包(没有 __init__.py),用于将一个大包拆分到多个目录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| # 场景:公司多个团队维护不同插件 # team-a 的包 team_plugins/ ├── feature_a/ │ └── plugin.py
# team-b 的包 team_plugins/ ├── feature_b/ │ └── plugin.py
# 两个目录都可以导入 from team_plugins.feature_a import plugin from team_plugins.feature_b import plugin
|
工程建议:除非有特殊需求(如插件系统),否则始终使用显式 __init__.py。隐式命名空间包在 IDE 支持、类型检查等方面有局限。
3. 项目结构规范
3.1 推荐的项目目录结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| myproject/ # 项目根目录 ├── README.md # 项目说明 ├── LICENSE # 许可证 ├── pyproject.toml # 项目配置(推荐) ├── setup.py / setup.cfg # 打包配置(旧方式) ├── requirements.txt # 运行依赖 ├── requirements-dev.txt # 开发依赖 ├── .gitignore # Git 忽略规则 ├── .editorconfig # 编辑器配置 ├── .pre-commit-config.yaml # 提交前检查 ├── docs/ # 文档 │ ├── index.md │ └── api.md ├── src/ # 源代码(推荐用 src 布局) │ └── myproject/ # 主包 │ ├── __init__.py │ ├── __main__.py # python -m myproject 入口 │ ├── core/ │ │ ├── __init__.py │ │ ├── models.py │ │ └── services.py │ ├── api/ │ │ ├── __init__.py │ │ └── routes.py │ ├── db/ │ │ ├── __init__.py │ │ └── connection.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── helpers.py │ │ └── validators.py │ └── config.py # 配置管理 ├── tests/ # 测试 │ ├── __init__.py │ ├── conftest.py # pytest 共享 fixture │ ├── unit/ # 单元测试 │ │ ├── test_models.py │ │ └── test_services.py │ ├── integration/ # 集成测试 │ │ └── test_api.py │ └── fixtures/ # 测试数据 │ └── users.json ├── scripts/ # 工具脚本 │ ├── setup_db.py │ └── seed_data.py └── .venv/ # 虚拟环境(.gitignore)
|
Flat Layout vs Src Layout:
| 布局 |
结构 |
优点 |
缺点 |
| Flat |
myproject/ 直接在根目录 |
简单直观 |
容易意外导入本地代码而非安装包 |
| Src |
src/myproject/ |
强制安装后测试,避免路径混乱 |
路径稍深 |
推荐:新项目一律使用 src layout。
面试真题:为什么推荐 src 布局?
答案:src 布局确保测试的是安装后的包,而不是开发目录下的代码,避免路径混乱。
3.2 __main__.py 的作用
1 2 3 4 5 6 7
| """python -m myproject 的入口。"""
from myproject.cli import main
if __name__ == "__main__": main()
|
1 2 3 4 5
| python -m myproject
python src/myproject/cli.py
|
3.3 配置文件 pyproject.toml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta"
[project] name = "myproject" version = "1.0.0" description = "一个示例项目" readme = "README.md" license = {text = "MIT"} authors = [ {name = "Your Name", email = "you@example.com"} ] requires-python = ">=3.9" dependencies = [ "requests>=2.28.0", "pydantic>=2.0", ]
[project.optional-dependencies] dev = [ "pytest>=7.0", "pytest-cov", "black", "flake8", "mypy", ]
[project.scripts] myproject = "myproject.cli:main"
[tool.setuptools.packages.find] where = ["src"]
[tool.black] line-length = 88 target-version = ['py39']
[tool.mypy] python_version = "3.9" warn_return_any = true warn_unused_configs = true
|
pyproject.toml 的优势:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| ┌─────────────────────────────────────────────────────────┐ │ pyproject.toml 优势 │ ├─────────────────────────────────────────────────────────┤ │ │ │ 1. 统一配置(PEP 621) │ │ 项目元数据 + 依赖 + 工具配置 都在一个文件 │ │ │ │ 2. 取代 setup.py / setup.cfg / requirements.txt │ │ 减少配置文件数量 │ │ │ │ 3. 工具独立配置区 │ │ [tool.black] / [tool.mypy] / [tool.pytest.ini_options]│ │ 每个工具有自己的命名空间 │ │ │ │ 4. 可编辑安装 │ │ pip install -e . # 开发模式安装 │ │ │ │ 5. 脚本入口 │ │ [project.scripts] 定义命令行入口 │ │ │ └─────────────────────────────────────────────────────────┘
|
4. 循环导入问题
4.1 问题场景
1 2 3 4 5 6 7 8 9 10 11
| from b import func_b
def func_a(): return func_b()
from a import func_a
def func_b(): return "b"
|
运行 a.py 时:
- 开始加载
a.py
- 执行
from b import func_b
- 开始加载
b.py
- 执行
from a import func_a
a.py 还没执行完,func_a 还不存在
ImportError: cannot import name 'func_a'
4.2 解决方案
方案一:重构代码,消除循环依赖(推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
from common import SharedInterface
from common import SharedInterface
def func_a(interface: SharedInterface): return interface.func_b()
from common import SharedInterface
def func_b(): return "b"
|
方案二:延迟导入(函数内部导入)
1 2 3 4 5
|
def func_a(): from b import func_b return func_b()
|
方案三:使用接口抽象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| from abc import ABC, abstractmethod
class ServiceInterface(ABC): @abstractmethod def process(self): pass
from interfaces import ServiceInterface
def func_a(service: ServiceInterface): return service.process()
from interfaces import ServiceInterface
class ServiceB(ServiceInterface): def process(self): return "b"
|
工程建议:循环导入是设计问题的信号。优先重构,延迟导入只是权宜之计。
面试真题:为什么循环导入在顶层导入时报错,在函数内部导入就不报错?
1 2 3 4 5 6
| from b import func_b
def func_a(): from b import func_b
|
答案:顶层导入在模块加载时立即执行,此时被导入模块可能还未完全加载。函数内导入在执行函数时才导入,此时所有模块都已加载完毕。
5. 模块与包的工程实践
5.1 避免 import *
1 2 3 4 5 6 7
| from os import * print(path)
from os.path import join, exists from pathlib import Path
|
5.2 模块级别的副作用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import requests
response = requests.get("`https://api.example.com/config`") DEFAULT_CONFIG = response.json()
DEFAULT_CONFIG = None
def get_config(): global DEFAULT_CONFIG if DEFAULT_CONFIG is None: response = requests.get("`https://api.example.com/config`") DEFAULT_CONFIG = response.json() return DEFAULT_CONFIG
|
5.3 包的版本管理
1 2 3 4 5 6 7 8 9 10 11 12
|
try: from importlib.metadata import version __version__ = version("myproject") except ImportError: try: import pkg_resources __version__ = pkg_resources.get_distribution("myproject").version except Exception: __version__ = "unknown"
|
5.4 类型提示与模块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| from __future__ import annotations
from typing import Optional, List from myproject.core.models import User
class UserService: def __init__(self) -> None: self._users: List[User] = []
def get_user(self, user_id: int) -> Optional[User]: for user in self._users: if user.id == user_id: return user return None
def add_user(self, user: User) -> None: self._users.append(user)
|
5.5 zipapp:打包为可执行 zip
Python 标准库支持将 Python 应用打包为单个 .pyz 文件,无需安装即可运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
import zipapp zipapp.create_archive("myproject", "myproject.pyz", interpreter="/usr/bin/env python3")
|
工程场景:快速分发工具脚本、演示项目、无依赖的 CLI 工具(不适用于需要大量第三方依赖的复杂项目)。
6. 工作实战场景
6.1 大型项目的模块分层
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
|
模块依赖规则:
1 2 3
| api/cli → services → domain → infrastructure → utils ↑ ↑ └────────────────────┘
|
- 上层可以依赖下层
- 同层之间尽量避免依赖
- 绝对不能循环依赖
6.2 插件化架构设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import importlib import os
class PluginManager: """插件管理器:自动发现和加载插件。""" def __init__(self, plugins_dir): self.plugins_dir = plugins_dir self.plugins = {} def load_plugins(self): """扫描插件目录并加载所有插件。""" if not os.path.exists(self.plugins_dir): return for filename in os.listdir(self.plugins_dir): if filename.endswith(".py") and not filename.startswith("_"): module_name = filename[:-3] try: module = importlib.import_module(f"plugins.{module_name}") if hasattr(module, "register"): plugin = module.register() self.plugins[plugin.name] = plugin print(f"Loaded plugin: {plugin.name}") except Exception as e: print(f"Failed to load plugin {module_name}: {e}") def get_plugin(self, name): return self.plugins.get(name)
class EmailNotifier: name = "email" def send(self, message): print(f"Sending email: {message}")
def register(): return EmailNotifier()
|
6.3 配置模块的封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| import os from pathlib import Path from typing import Optional
class Config: """配置管理:支持多环境配置。""" def __init__(self): self._config = {} self._load() def _load(self): """加载配置:环境变量优先,然后是配置文件。""" self._config["DEBUG"] = os.getenv("DEBUG", "false").lower() == "true" self._config["DATABASE_URL"] = os.getenv("DATABASE_URL", "sqlite:///app.db") self._config["SECRET_KEY"] = os.getenv("SECRET_KEY") config_file = Path("config.yaml") if config_file.exists(): import yaml with open(config_file) as f: file_config = yaml.safe_load(f) for key, value in file_config.items(): if key not in self._config or self._config[key] is None: self._config[key] = value def get(self, key: str, default=None): return self._config.get(key, default) @property def debug(self): return self.get("DEBUG", False) @property def database_url(self): url = self.get("DATABASE_URL") if not url: raise ValueError("DATABASE_URL is not set") return url
config = Config() print(f"Debug mode: {config.debug}") print(f"Database: {config.database_url}")
|
6.4 跨包共享资源
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| """共享资源管理。"""
import json from pathlib import Path
_RESOURCES_DIR = Path(__file__).parent
def load_resource(name: str) -> dict: """加载 JSON 资源文件。""" filepath = _RESOURCES_DIR / f"{name}.json" if not filepath.exists(): raise FileNotFoundError(f"Resource not found: {name}") with open(filepath, encoding="utf-8") as f: return json.load(f)
_COUNTRIES = None
def get_countries(): """获取国家列表(缓存)。""" global _COUNTRIES if _COUNTRIES is None: _COUNTRIES = load_resource("countries") return _COUNTRIES
|
7. 常见面试题汇总
7.1 基础概念题
Q1:什么是模块?什么是包?它们的区别是什么?
A:
- 模块:一个
.py 文件就是一个模块,是代码复用的基本单元
- 包:包含
__init__.py 的目录,是多个相关模块的集合
- 区别:模块是单个文件,包是多个文件的目录结构;包可以包含子包和子模块
Q2:sys.path 的搜索顺序是什么?如何正确添加自定义模块路径?
A:搜索顺序:
- 当前目录(运行脚本所在目录)
PYTHONPATH 环境变量中的目录
- 标准库目录
.pth 文件指定的目录
正确添加方式:
- 开发时:
pip install -e .(可编辑安装)
- 运行时:设置
PYTHONPATH 环境变量
- 不推荐:直接修改
sys.path
Q3:if __name__ == "__main__" 的作用是什么?
A:当模块被直接运行时,__name__ 的值为 "__main__";当模块被导入时,__name__ 的值为模块名。这个条件判断用于:
- 区分模块是被导入还是直接运行
- 放置测试代码、演示代码或 CLI 入口
- 确保导入模块时不会执行这些代码
Q4:__init__.py 的作用是什么?
A:
- 标记目录为 Python 包
- 包级初始化代码(只执行一次)
- 控制
from package import * 的导出内容(通过 __all__)
- 简化导入路径(将子模块的内容提升到包级别)
- 定义包级变量(如
__version__)
Q5:绝对导入和相对导入的区别?
A:
- 绝对导入:
from myproject.core.models import User,从项目根目录开始定位,清晰明确
- 相对导入:
from .models import User 或 from ..utils import helper,从当前模块位置开始定位
推荐:项目顶层用绝对导入,包内部可用相对导入。
7.2 实战应用题
Q6:为什么推荐 src 布局而不是 flat 布局?
A:src 布局确保测试的是安装后的包,而不是开发目录下的代码。flat 布局可能导致测试时导入本地代码而非安装包,造成”安装后测试”和”开发时测试”结果不一致。
Q7:如何解决循环导入问题?
A:
- 重构代码(推荐):将共享的接口/常量提取到独立模块,消除循环依赖
- 延迟导入:在函数内部导入,避免模块加载时的循环
- 使用接口抽象:通过 ABC 定义接口,依赖接口而非具体实现
Q8:模块导入时产生副作用有什么问题?如何避免?
A:问题:导入模块时执行耗时操作(如网络请求、数据库连接)会减慢导入速度,难以测试,可能导致意外行为。
避免方式:
- 将耗时操作封装到函数中,延迟执行
- 使用懒加载模式(首次调用时初始化)
- 模块级别只定义数据结构和函数,不执行实际操作
Q9:pyproject.toml 的作用是什么?
A:pyproject.toml 是 PEP 621 推荐的项目配置文件,统一管理:
- 项目元数据(名称、版本、作者)
- 依赖声明(运行依赖、开发依赖)
- 工具配置(black、mypy、pytest 等)
- 脚本入口定义
Q10:如何实现插件化架构?
A:
- 定义插件接口(抽象基类)
- 扫描插件目录
- 动态导入插件模块
- 注册插件到管理器
- 在运行时按需加载和使用插件
7.3 代码分析题
Q11:分析以下代码的问题:
1 2 3 4 5 6 7 8 9 10 11
| from module_b import func_b
def func_a(): return func_b()
from module_a import func_a
def func_b(): return "b"
|
A:循环导入问题。运行时会报 ImportError: cannot import name 'func_a'。解决方案:重构代码提取共享部分,或使用延迟导入。
Q12:分析以下代码的问题:
1 2 3 4 5
| import requests
response = requests.get("https://api.example.com/config") DEFAULT_CONFIG = response.json()
|
A:模块导入时产生副作用(发送网络请求)。问题:导入速度慢、难以测试、可能因网络问题导致导入失败。应改为延迟初始化。
Q13:如何正确运行以下包结构中的 main.py?
1 2 3 4 5
| myproject/ ├── __init__.py ├── core/ │ ├── __init__.py │ └── main.py
|
A:使用 python -m myproject.core.main。不要直接运行 python myproject/core/main.py,因为这样会导致 sys.path 问题,无法正确导入同包内的其他模块。
8. 实战项目:模块化 CLI 工具开发
8.1 项目概述
项目目标:开发一个命令行工具 pytool,包含多个子命令,支持模块化扩展。
功能需求:
pytool init <project> - 初始化项目
pytool lint - 代码检查
pytool format - 代码格式化
pytool test - 运行测试
pytool version - 显示版本
8.2 项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| pytool/ ├── README.md ├── pyproject.toml ├── src/ │ └── pytool/ │ ├── __init__.py │ ├── __main__.py │ ├── cli.py # CLI 入口 │ ├── commands/ # 命令模块 │ │ ├── __init__.py │ │ ├── init.py │ │ ├── lint.py │ │ ├── format.py │ │ ├── test.py │ │ └── version.py │ └── utils/ # 工具模块 │ ├── __init__.py │ └── helpers.py └── tests/ ├── __init__.py └── test_cli.py
|
8.3 核心代码
pyproject.toml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta"
[project] name = "pytool" version = "1.0.0" description = "A modular CLI tool" readme = "README.md" license = {text = "MIT"} authors = [ {name = "Your Name", email = "you@example.com"} ] requires-python = ">=3.9" dependencies = [ "click>=8.0", "black>=23.0", "flake8>=6.0", "pytest>=7.0", ]
[project.scripts] pytool = "pytool.cli:main"
[tool.setuptools.packages.find] where = ["src"]
|
cli.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import click import importlib import os
@click.group() def main(): """PyTool - 模块化 CLI 工具""" pass
def _load_commands(): """自动加载 commands 目录下的所有命令。""" commands_dir = os.path.join(os.path.dirname(__file__), "commands") for filename in os.listdir(commands_dir): if filename.endswith(".py") and not filename.startswith("_"): module_name = filename[:-3] module = importlib.import_module(f"pytool.commands.{module_name}") if hasattr(module, "command"): main.add_command(module.command)
_load_commands()
|
commands/init.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import click import os from pytool.utils.helpers import create_file
@click.command("init") @click.argument("project") def command(project): """初始化一个新项目。""" os.makedirs(project, exist_ok=True) files = { f"{project}/README.md": "# {project}\n", f"{project}/pyproject.toml": '[build-system]\nrequires = ["setuptools"]\n', f"{project}/src/{project}/__init__.py": "", f"{project}/tests/__init__.py": "", } for filepath, content in files.items(): create_file(filepath, content.format(project=project)) click.echo(f"项目 {project} 初始化完成")
|
commands/format.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import click import subprocess
@click.command("format") @click.argument("path", default=".") def command(path): """使用 black 格式化代码。""" try: subprocess.run(["black", path], check=True) click.echo(f"代码格式化完成: {path}") except subprocess.CalledProcessError: click.echo("格式化失败", err=True) except FileNotFoundError: click.echo("black 未安装,请运行: pip install black", err=True)
|
commands/version.py:
1 2 3 4 5 6 7
| import click from pytool import __version__
@click.command("version") def command(): """显示版本信息。""" click.echo(f"PyTool v{__version__}")
|
utils/helpers.py:
1 2 3 4 5 6 7 8 9
| import os
def create_file(filepath, content): """创建文件,如果父目录不存在则创建。""" dirname = os.path.dirname(filepath) if dirname: os.makedirs(dirname, exist_ok=True) with open(filepath, "w", encoding="utf-8") as f: f.write(content)
|
init.py:
1 2 3 4 5 6 7
| """PyTool - 模块化 CLI 工具"""
try: from importlib.metadata import version __version__ = version("pytool") except ImportError: __version__ = "1.0.0"
|
main.py:
1 2 3 4 5 6
| """python -m pytool 的入口。"""
from pytool.cli import main
if __name__ == "__main__": main()
|
8.4 项目运行
1 2 3 4 5 6 7 8 9 10 11 12
| pip install -e .
pytool init myproject pytool format . pytool lint pytool test pytool version
python -m pytool version
|
8.5 项目亮点
- 模块化设计:每个命令都是独立模块,易于扩展
- 自动发现:命令模块自动加载,无需手动注册
- 标准布局:使用 src layout,符合现代 Python 项目规范
- 可编辑安装:
pip install -e . 支持开发模式
- 命令行接口:使用 click 库,功能丰富、文档友好
附录:第五阶段自检清单
在继续学习第六阶段之前,请确保你能:
工程师寄语:好的项目结构是”自解释的”——新成员打开代码库,看目录结构就能猜到项目的架构和职责划分。花时间在项目初期设计好模块边界,比后期重构要划算得多。