Compare commits

...

No commits in common. 'main' and 'zwh' have entirely different histories.
main ... zwh

Binary file not shown.

Binary file not shown.

@ -1,268 +0,0 @@
==================================================
1. 模块级前置工具
==================================================
1.1 _complete_visible_commands
位置:文件头部,独立函数
作用:在 shell 补全场景里,把 Group 下属“非隐藏且名字匹配前缀”的子命令一次性枚举出来。
关键点:
- 把 ctx.command 强转成 Groupt.cast保证类型检查通过
- 用 list_commands(ctx) 而不是直接读 commands 字典目的是让“插件式”命令lazy loading有机会被触发注册。
1.2 iter_params_for_processing
位置:紧接上面的独立函数
作用:解决“用户输入顺序”与“参数声明顺序”冲突时的回调执行次序。
算法:
- 以“用户实际输入顺序”为主键(找不到就置 inf再以“是否 eager”为次要键
- 返回排序后的列表,保证:
① 用户先写的选项先跑;
② eager 选项(如 --help无论写在哪都优先跑。
后期版本演进8.0 之后 eager 概念被提到参数层面,而非全局钩子,因此该函数成为唯一排序入口。
==================================================
2. Context 类——“一次命令执行”的生命周期
==================================================
2.1 字段初始化__init__
- parent / command / info_name显式传入不再解释。
- paramsdict[str, Any]
存放“已经解析完成、且 expose_value=True”的参数终值
注意:解析过程中可能先出现 UNSET后期统一再刷成 None见 2.4)。
- _parameter_sourcedict[str, ParameterSource]
8.0 新增,记录每个值到底从哪来;
解决“用户碰巧写出与默认值相同内容”时,程序无法区分的问题。
- _exit_stackExitStack
8.0 新增,让 Click 也能用“with 语法”管理资源;
与 scope()/with_resource()/call_on_close() 配套。
2.2 参数继承链
代码片段:
if obj is None and parent is not None:
obj = parent.obj
if default_map is None and info_name is not None and parent …:
default_map = parent.default_map.get(info_name)
逻辑:
- obj 沿继承链向下穿透,最常用场景是把“数据库连接”挂在根 Context子命令直接复用
- default_map 支持“按命令路径分段”覆盖,例如:
{“subcmd”: {“host”: “127.0.0.1”}} 只对 subcmd 生效。
2.3 make_context 类方法
执行时序:
1. 把 Command.context_settings 先刷进 extra确保“装饰器里写的配置”优先级最高
2. 实例化 Context → 调用 ctx.scope(cleanup=False) → 调用 command.parse_args()
3. 返回 ctx但尚未 invoke
设计目的:
- 让“参数解析”与“回调执行”彻底分离,方便测试、文档生成、补全逻辑复用同一套解析结果。
2.4 parse_args 细节
- 空参数且 no_args_is_help=True 时,直接抛 NoArgsIsHelpError最外层捕获后打印 help
- 循环调用 param.handle_parse_result() 之前,先用 iter_params_for_processing 排序;
- 解析完成后统一把 ctx.params 中的 UNSET 刷成 None
目的:防止下游用户代码意外拿到内部哨兵;
时机必须等“所有参数都处理完”再刷否则多值参数nargs>1, multiple=True无法判断“缺值”与“空列表”区别。
2.5 invoke 两套重载
重载 1ctx.invoke(普通函数, **kwargs)
直接在当前上下文里跑回调kwargs 就是终值;
重载 2ctx.invoke(另一个 Command, **kwargs)
- 先给子命令生成 _make_sub_context()
- 对子命令所有“缺省且未在 kwargs 出现”的参数,调 param.get_default() 补全;
- 把 kwargs 全部写进 sub_ctx.params因此后续再 forward() 也能继续向下传递;
- 用 augment_usage_errors 包一层,保证子命令里的 BadParameter 能带上父命令的 ctx。
2.6 ExitStack 集成
with_resource()
返回 __enter__ 结果,并把 __exit__ 注册到 _exit_stack
call_on_close()
注册普通回调,内部就是 exit_stack.callback()
close()
在 ctx.__exit__() 且 _depth==0 时触发,保证:
- 补全场景只解析不执行,也能回收资源;
- 多层嵌套 Group 中,只有最外层退出时才统一清理。
==================================================
3. Command 类——“命令元数据 + 回调”
==================================================
3.1 get_params() 的“重复选项”警告
实现:
opts = [opt for param in params for opt in param.opts]
duplicate_opts = (opt for opt, count in Counter(opts).items() if count > 1)
for opt in duplicate_opts: warnings.warn(...)
触发场景:
@click.option('-f', '--file')
@click.option('-f', '--config') # 重复 -f运行即提示
版本演进:
8.2 之前静默允许8.2 发 UserWarning9.0 计划抛 Error。
3.2 parse_args() 的“剩余参数”检查
代码:
if args and not ctx.allow_extra_args and not ctx.resilient_parsing:
ctx.fail(ngettext(...))
注意:
- resilient_parsing=True补全场景时强制放过防止补全脚本因多余单词而失败
- allow_extra_args 默认 False需要显式 `@click.command(..., allow_extra_args=True)` 打开。
3.3 invoke() 的 deprecated 警告
实现:
if self.deprecated:
echo(style(message, fg="red"), err=True)
特点:
- 警告走 stderr且带 ANSI 红色;
- 先于 callback 执行,确保即使用户回调里抛异常也能看到。
3.4 main() 的“管道破裂”特殊处理
代码:
except OSError as e:
if e.errno == errno.EPIPE:
sys.stdout = PacifyFlushWrapper(sys.stdout)
sys.stderr = PacifyFlushWrapper(sys.stderr)
sys.exit(1)
背景:
Linux 下常见 `cli | head` 场景head 提前关闭管道Python 会抛 BrokenPipeError
Click 把它静默成 1 号退出码,避免 traceback 污染用户终端。
==================================================
4. Group 类——命令树与链式调用
==================================================
4.1 chain=True 时的参数限制
代码:
if self.chain:
for param in self.params:
if isinstance(param, Argument) and not param.required:
raise RuntimeError(...)
原因:
链式模式下,剩余参数会被不断“切片”给后续子命令,无法判断“可选参数”到底该在哪一段消费;
因此强制“链式 Group 只能有必填 Argument”。
4.2 invoke() 的两条分支
分支 A非链式
- 只解析第一个子命令;
- 用 resolve_command() 取出 cmd_name, cmd, new_args
- 子命令跑完后,把返回值交给 Group 的 result_callback。
分支 B链式
- 先跑 Group 自身回调,返回值丢弃(除非 invoke_without_command
- 循环 parse 剩余 args每段都 make_context → invoke结果收集到列表
- 最后列表一次性传给 result_callback。
4.3 result_callback 的装饰器实现
代码:
def result_callback(self, replace=False):
def decorator(f):
old = self._result_callback
if old is None or replace:
self._result_callback = f
return f
def chained(value, /, *a, **k):
inner = old(value, *a, **k)
return f(inner, *a, **k)
self._result_callback = update_wrapper(chained, f)
return decorator
特点:
- 支持多次装饰,自动链式调用;
- 签名固定为 (value, *args, **kwargs),其中 value 是“子命令返回值”或“子命令列表”。
==================================================
5. Parameter 基类——“选项 + 参数”的公共抽象
==================================================
5.1 consume_value() 的优先级链
返回 (value, source) 二元组,顺序:
1. 命令行opts 字典里能查到 self.name
2. 环境变量resolve_envvar_value
3. default_mapctx.lookup_default
4. 参数自身 defaultget_default
5. 以上全 miss → 返回 UNSET
source 枚举值:
COMMANDLINE / ENVIRONMENT / DEFAULT_MAP / DEFAULT / PROMPT
用于后期“用户是否显式填写”判断,例如 deprecated 警告只在前两种场景下触发。
5.2 type_cast_value() 的 nargs 矩阵
- nargs == 1 或 composite type → 单值直接转;
- nargs == -1 → 剩余全部收集成 tuple
- nargs > 1 → 严格检查长度,不足就抛 BadParameter
- multiple=True → 在最外层再包一层 tuple形成“列表里每项都是 tuple”的嵌套结构。
5.3 process_value() 的“UNSET 特殊处理”
代码:
if value is UNSET:
if self.multiple or self.nargs == -1:
value = ()
else:
value = self.type_cast_value(ctx, value)
目的:
- 让“缺值”与“空列表”在后续 value_is_missing() 里可区分;
- 同时保证类型转换层永远看不到 UNSET简化自定义 ParamType 的实现。
5.4 callback 调用时的“临时上下文”
8.2 新增逻辑:
如果 ctx.params 里还有兄弟参数是 UNSET会临时
a) 把 UNSET 全部刷成 None
b) 调用 callback
c) 再把 None 恢复成 UNSET仅当回调没改动的字段
解决:
旧代码写的 callback 可能用 `if value is None` 判断“用户没给”,
引入 UNSET 后不能破坏这一习惯,因此提供“伪 None”环境。
==================================================
6. Option 类——“可选参数”的专属逻辑
==================================================
6.1 _parse_decls() 的“/”分片语法
支持两种写法:
- `--verbose/--quiet` → secondary_opts = ["--quiet"]
- `/-v` → secondary_opts = ["-v"]
约束:
- bool flag 以外禁止出现 secondary_opts
- 同一名字不能同时出现在 opts 与 secondary_opts会抛 ValueError
6.2 flag_value 与 default 对齐
代码:
if self.default is True and self.flag_value is not UNSET:
self.default = self.flag_value
场景:
@click.option("--feature/--no-feature", default=True, flag_value=False)
期望“默认开,--no-feature 关”,此时把 default 统一成 flag_value
后续 prompt/帮助/环境变量解析都能拿到一致值。
6.3 value_from_envvar() 的“非 bool flag”处理
- 非 bool flag如 --format=json会把 envvar 原串与 flag_value 比对,
相等则返回 flag_value否则走 BoolParamType.str_to_bool() 判断“是否激活”;
- 激活后真正存的仍是 flag_value保证“环境变量与命令行”语义一致。
6.4 prompt 分支
- bool flag 用 confirm(),支持 [Y/n] 反向默认;
- 普通 option 用 prompt(),并把 process_value 作为 value_proc 传入,
用户键盘输入即时做类型转换,失败可循环提示,直到拿到合法值。
==================================================
7. Argument 类——“位置参数”的专属逻辑
==================================================
7.1 required 自动推断
规则:
- 用户显式传了 required → 用用户的;
- 没传时:
只要“无默认值”且“nargs != 0”就视为必填
有默认值 → 视为可选。
目的:
`@click.argument('filename')` 默认就是必填,
`@click.argument('filename', default='-')` 默认就是可选,减少样板。
7.2 make_metavar() 的修饰符号
- nargs != 1 → 加 “…”
- 非 required → 外套 “[ ]”
- deprecated → 加 “!”
示例:
ARGUMENT [...]! # 可选、可变长、已弃用
==================================================
8. 资源清理与上下文栈(再总结)
==================================================
8.1 ExitStack 生命周期
- Context.__init__() 实例化 ExitStack
- 每进一层嵌套命令Group/chain_depth++,但 ExitStack 只有一份;
- 最外层 __exit__() 时 _depth==0才真正调用 _exit_stack.__exit__()
- 异常信息exc_type/value/tb原样传进去保证上下文管理器能正常 suppress 异常。
8.2 with_resource() 与 call_on_close() 的差异
- with_resource() 必须传入“上下文管理器”,返回的是 __enter__ 结果;
- call_on_close() 只注册一个回调,适合“非上下文管理器”场景,例如:
def cleanup(): os.unlink(tmpfile)
ctx.call_on_close(cleanup)

Binary file not shown.

@ -4,9 +4,17 @@ writing command line scripts fun. Unlike other modules, it's based
around a simple API that does not come with too much magic and is
composable.
"""
# 【模块文档字符串】
# 1. 定位Click 是受 Python 标准库 optparse 启发的轻量级 CLI 开发模块
# 2. 核心优势API 简洁、无过度“魔法”(易理解)、可组合(支持模块化扩展)
# 3. 设计目标:让命令行脚本开发更简单、更“有趣”(降低入门门槛)
from __future__ import annotations
# 【Python 版本兼容】
# 导入 Python 3.7+ 的 `annotations` 特性,支持类型注解中直接使用类名(无需引号)
# 例如def func(ctx: Context) 而非 def func(ctx: "Context"),提升代码可读性
# ===================== 核心类导出(从 core 模块) =====================
from .core import Argument as Argument
from .core import Command as Command
from .core import CommandCollection as CommandCollection
@ -14,17 +22,43 @@ from .core import Context as Context
from .core import Group as Group
from .core import Option as Option
from .core import Parameter as Parameter
# 【设计意图】
# 1. 核心类统一从根模块导出,开发者无需写 `from click.core import Command`,只需 `from click import Command`
# 2. 核心类职责:
# - Parameter所有参数的基类Option/Argument 继承自它)
# - Option可选参数如 --name、-n
# - Argument位置参数如 `git add file.txt` 中的 file.txt
# - Command基础命令类装饰器 @command 封装的核心)
# - Group命令组/子命令类(装饰器 @group 封装,继承自 Command
# - Context命令执行上下文存储参数、终端配置、命令实例等
# - CommandCollection命令集合用于合并多个 Group/Command
# ===================== 装饰器导出(从 decorators 模块) =====================
from .decorators import argument as argument
from .decorators import command as command
from .decorators import confirmation_option as confirmation_option
from .decorators import group as group
from .decorators import help_option as help_option
from .decorators import make_pass_decorator as make_pass_decorator
from .decorators import option as option
from .decorators import pass_context as pass_context
from .decorators import pass_obj as pass_obj
from .decorators import password_option as password_option
from .decorators import version_option as version_option
# 【设计意图】
# 1. 装饰器是 Click 最核心的易用性设计,统一导出降低使用成本
# 2. 核心装饰器职责:
# - @command将函数封装为 Command 实例
# - @group将函数封装为 Group 实例(支持子命令)
# - @argument为命令添加位置参数
# - @option为命令添加可选参数
# - @help_option快速添加 --help 选项(默认已内置,可自定义)
# - @version_option快速添加 --version 选项
# - @confirmation_option快速添加确认提示如 --yes/-y
# - @password_option快速添加密码输入选项隐藏输入
# - pass_context/pass_obj将 Context/自定义对象传递给命令函数
# - make_pass_decorator生成自定义对象的传递装饰器高级用法
# ===================== 异常类导出(从 exceptions 模块) =====================
from .exceptions import Abort as Abort
from .exceptions import BadArgumentUsage as BadArgumentUsage
from .exceptions import BadOptionUsage as BadOptionUsage
@ -34,9 +68,34 @@ from .exceptions import FileError as FileError
from .exceptions import MissingParameter as MissingParameter
from .exceptions import NoSuchOption as NoSuchOption
from .exceptions import UsageError as UsageError
# 【设计意图】
# 1. 异常体系分层设计,所有异常均继承自 ClickException
# 2. 核心异常职责:
# - ClickException基类所有 Click 异常的父类
# - UsageError命令使用错误如参数格式错误
# - BadParameter参数值无效如类型错误、范围错误
# - MissingParameter必填参数缺失
# - NoSuchOption传入不存在的选项如 --unkown
# - BadArgumentUsage/BadOptionUsage参数/选项使用方式错误
# - FileError文件操作相关错误如文件不存在、权限不足
# - Abort用户中断操作如 Ctrl+C、确认提示选 No
# ===================== 格式化工具导出(从 formatting 模块) =====================
from .formatting import HelpFormatter as HelpFormatter
from .formatting import wrap_text as wrap_text
# 【设计意图】
# 1. 封装帮助文档格式化逻辑,支持自定义帮助信息样式
# 2. 核心工具:
# - HelpFormatter帮助文档格式化类可自定义缩进、宽度
# - wrap_text文本换行工具适配终端宽度
# ===================== 全局工具导出(从 globals 模块) =====================
from .globals import get_current_context as get_current_context
# 【设计意图】
# 获取当前执行的 Context 实例(全局快捷方式)
# 场景:在命令函数外部获取上下文(如工具函数中)
# ===================== 终端交互工具导出(从 termui 模块) =====================
from .termui import clear as clear
from .termui import confirm as confirm
from .termui import echo_via_pager as echo_via_pager
@ -49,6 +108,21 @@ from .termui import prompt as prompt
from .termui import secho as secho
from .termui import style as style
from .termui import unstyle as unstyle
# 【设计意图】
# 封装终端交互能力跨平台兼容Windows/Linux/Mac
# 核心工具职责:
# - echo_via_pager分页输出大文本如 `less` 模式)
# - confirm终端确认提示如 “是否继续?[y/N]”)
# - prompt交互式输入提示支持类型校验
# - progressbar终端进度条可视化任务进度
# - secho/style/unstyle彩色输出支持 ANSI 颜色码)
# - clear清空终端屏幕
# - edit打开编辑器如 vim/nano编辑文本
# - getchar读取单个字符无需回车
# - launch打开外部程序/文件(如用默认浏览器打开链接)
# - pause暂停程序等待用户按任意键
# ===================== 参数类型导出(从 types 模块) =====================
from .types import BOOL as BOOL
from .types import Choice as Choice
from .types import DateTime as DateTime
@ -63,14 +137,38 @@ from .types import STRING as STRING
from .types import Tuple as Tuple
from .types import UNPROCESSED as UNPROCESSED
from .types import UUID as UUID
# 【设计意图】
# 封装参数类型校验逻辑,支持自定义类型
# 核心类型职责:
# - ParamType所有参数类型的基类自定义类型需继承
# - 基础类型STRING/INT/FLOAT/BOOL对应 Python 基础类型)
# - 范围类型IntRange/FloatRange如 1-10、0.0-1.0
# - 特殊类型:
# - Choice枚举类型参数值只能是指定列表中的值
# - Path文件/目录路径(自动校验存在性、权限)
# - File文件对象自动打开/关闭,支持读写模式)
# - DateTime日期时间类型自动解析字符串为 datetime 对象)
# - UUIDUUID 类型(自动校验格式)
# - Tuple元组类型支持多值参数
# - UNPROCESSED不处理原始值直接返回字符串
# ===================== 通用工具导出(从 utils 模块) =====================
from .utils import echo as echo
from .utils import format_filename as format_filename
from .utils import get_app_dir as get_app_dir
from .utils import get_binary_stream as get_binary_stream
from .utils import get_text_stream as get_text_stream
from .utils import open_file as open_file
# 【设计意图】
# 封装通用工具函数,跨平台兼容
# 核心工具职责:
# - echo增强版 print支持彩色、跨平台换行、流输出
# - open_file安全打开文件自动处理编码、模式
# - get_text_stream/get_binary_stream获取文本/二进制流(如 stdin/stdout
# - get_app_dir获取应用配置目录跨平台Windows %APPDATA%、Linux ~/.config
# - format_filename格式化文件名跨平台路径分隔符
# ===================== 兼容处理:废弃属性的动态获取 =====================
def __getattr__(name: str) -> object:
import warnings
@ -121,3 +219,12 @@ def __getattr__(name: str) -> object:
return importlib.metadata.version("click")
raise AttributeError(name)
# 【设计意图】
# 1. 兼容旧版本 API通过 __getattr__ 动态处理废弃属性,避免直接导入导致的兼容性问题
# 2. 核心兼容逻辑:
# - BaseCommand → 替换为 Command9.0 移除)
# - MultiCommand → 替换为 Group9.0 移除)
# - OptionParser → 废弃(推荐使用核心解析逻辑,旧版可参考 optparse
# - __version__ → 废弃(推荐用 importlib.metadata 获取版本)
# 3. 警告机制:使用废弃属性时触发 DeprecationWarning提示开发者升级代码
# 4. 异常处理:非废弃属性直接抛出 AttributeError符合 Python 规范

@ -10,10 +10,14 @@ import typing as t
from types import TracebackType
from weakref import WeakKeyDictionary
# ========================================================================
# 平台与环境检测
# Click 需要根据操作系统决定是否使用 colorama (Windows) 或标准 ANSI (Linux/Mac)
# ========================================================================
CYGWIN = sys.platform.startswith("cygwin")
WIN = sys.platform.startswith("win")
auto_wrap_for_ansi: t.Callable[[t.TextIO], t.TextIO] | None = None
_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]")
_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") # 正则表达式:用于匹配 ANSI 颜色代码
def _make_text_stream(
@ -23,10 +27,15 @@ def _make_text_stream(
force_readable: bool = False,
force_writable: bool = False,
) -> t.TextIO:
"""
工厂函数创建一个文本流包装器
核心目的将底层的二进制流 sys.stdout.buffer包装成处理 Unicode 的文本流
同时确保在对象销毁时**不关闭**底层流
"""
if encoding is None:
encoding = get_best_encoding(stream)
encoding = get_best_encoding(stream) # 智能探测编码
if errors is None:
errors = "replace"
errors = "replace" # 默认策略:遇到无法编码的字符用 '?' 替换,防止程序崩溃
return _NonClosingTextIOWrapper(
stream,
encoding,
@ -38,7 +47,7 @@ def _make_text_stream(
def is_ascii_encoding(encoding: str) -> bool:
"""Checks if a given encoding is ascii."""
"""检查给定的编码名称是否为 ASCII。"""
try:
return codecs.lookup(encoding).name == "ascii"
except LookupError:
@ -46,7 +55,11 @@ def is_ascii_encoding(encoding: str) -> bool:
def get_best_encoding(stream: t.IO[t.Any]) -> str:
"""Returns the default stream encoding if not found."""
"""
获取流的最佳编码
背景 Docker 容器或某些 Linux 环境下LANG 环境变量可能未设置导致 Python 默认使用 ASCII
Click 在这里强制将 ASCII 升级为 UTF-8解决了无数 "UnicodeEncodeError" 问题
"""
rv = getattr(stream, "encoding", None) or sys.getdefaultencoding()
if is_ascii_encoding(rv):
return "utf-8"
@ -54,6 +67,11 @@ def get_best_encoding(stream: t.IO[t.Any]) -> str:
class _NonClosingTextIOWrapper(io.TextIOWrapper):
"""
自定义的 TextIOWrapper
关键特性当这个对象被垃圾回收__del__它不会关闭底层的 file descriptor
这对于包装 sys.stdout 至关重要因为我们不能因为一个包装器被销毁就关闭了整个程序的标准输出
"""
def __init__(
self,
stream: t.BinaryIO,
@ -63,6 +81,7 @@ class _NonClosingTextIOWrapper(io.TextIOWrapper):
force_writable: bool = False,
**extra: t.Any,
) -> None:
# 使用 _FixupStream 修复可能存在的流接口缺陷(详见 _FixupStream
self._stream = stream = t.cast(
t.BinaryIO, _FixupStream(stream, force_readable, force_writable)
)
@ -70,23 +89,23 @@ class _NonClosingTextIOWrapper(io.TextIOWrapper):
def __del__(self) -> None:
try:
self.detach()
self.detach() # 关键点:仅分离,不关闭底层流
except Exception:
pass
def isatty(self) -> bool:
# https://bitbucket.org/pypy/pypy/issue/1803
# 修复 PyPy 中的一个 issue (https://bitbucket.org/pypy/pypy/issue/1803)
# 代理调用底层流的 isatty
return self._stream.isatty()
class _FixupStream:
"""The new io interface needs more from streams than streams
traditionally implement. As such, this fix-up code is necessary in
some circumstances.
The forcing of readable and writable flags are there because some tools
put badly patched objects on sys (one such offender are certain version
of jupyter notebook).
"""
流代理类Proxy Pattern
背景某些环境如旧版 Jupyter Notebook测试框架或某些 IDE会猴子补丁 sys.stdout
但它们提供的对象可能不完全符合 io.IOBase 标准例如缺少 readable/writable 属性
这个类强制修补这些属性确保 Click 的后续逻辑不会因为属性缺失而报错
"""
def __init__(
@ -100,9 +119,11 @@ class _FixupStream:
self._force_writable = force_writable
def __getattr__(self, name: str) -> t.Any:
# 将所有未拦截的调用转发给原始流
return getattr(self._stream, name)
def read1(self, size: int) -> bytes:
# 确保 read1 存在io.BufferedReader 特有方法),不存在则回退到 read
f = getattr(self._stream, "read1", None)
if f is not None:
@ -111,11 +132,13 @@ class _FixupStream:
return self._stream.read(size)
def readable(self) -> bool:
# 允许外部强制指定流为“可读”
if self._force_readable:
return True
x = getattr(self._stream, "readable", None)
if x is not None:
return t.cast(bool, x())
# 如果没有 readable 属性,尝试读一个字节来探测
try:
self._stream.read(0)
except Exception:
@ -123,11 +146,13 @@ class _FixupStream:
return True
def writable(self) -> bool:
# 允许外部强制指定流为“可写”
if self._force_writable:
return True
x = getattr(self._stream, "writable", None)
if x is not None:
return t.cast(bool, x())
# 尝试写空字节来探测
try:
self._stream.write(b"")
except Exception:
@ -148,21 +173,26 @@ class _FixupStream:
return True
# ========================================================================
# 二进制流探测辅助函数
# 用于判断一个流到底是 binary 还是 text 模式,这在 Python 3 中界限分明
# ========================================================================
def _is_binary_reader(stream: t.IO[t.Any], default: bool = False) -> bool:
try:
# 尝试读取 0 字节,如果返回 bytes 类型,则是二进制流
return isinstance(stream.read(0), bytes)
except Exception:
return default
# This happens in some cases where the stream was already
# closed. In this case, we assume the default.
# 如果流已关闭,回退到默认值
def _is_binary_writer(stream: t.IO[t.Any], default: bool = False) -> bool:
try:
stream.write(b"")
stream.write(b"") # 尝试写入 bytes
except Exception:
try:
stream.write("")
stream.write("") # 尝试写入 str
return False
except Exception:
pass
@ -171,17 +201,13 @@ def _is_binary_writer(stream: t.IO[t.Any], default: bool = False) -> bool:
def _find_binary_reader(stream: t.IO[t.Any]) -> t.BinaryIO | None:
# We need to figure out if the given stream is already binary.
# This can happen because the official docs recommend detaching
# the streams to get binary streams. Some code might do this, so
# we need to deal with this case explicitly.
# 尝试从流中剥离出底层的 BinaryIO
# 1. 如果它本身就是 binary reader直接返回
if _is_binary_reader(stream, False):
return t.cast(t.BinaryIO, stream)
# 2. 如果它有 .buffer 属性TextIOWrapper 通常有),则返回 buffer
buf = getattr(stream, "buffer", None)
# Same situation here; this time we assume that the buffer is
# actually binary in case it's closed.
if buf is not None and _is_binary_reader(buf, True):
return t.cast(t.BinaryIO, buf)
@ -189,17 +215,11 @@ def _find_binary_reader(stream: t.IO[t.Any]) -> t.BinaryIO | None:
def _find_binary_writer(stream: t.IO[t.Any]) -> t.BinaryIO | None:
# We need to figure out if the given stream is already binary.
# This can happen because the official docs recommend detaching
# the streams to get binary streams. Some code might do this, so
# we need to deal with this case explicitly.
# 同上,针对 Writer
if _is_binary_writer(stream, False):
return t.cast(t.BinaryIO, stream)
buf = getattr(stream, "buffer", None)
# Same situation here; this time we assume that the buffer is
# actually binary in case it's closed.
if buf is not None and _is_binary_writer(buf, True):
return t.cast(t.BinaryIO, buf)
@ -207,19 +227,12 @@ def _find_binary_writer(stream: t.IO[t.Any]) -> t.BinaryIO | None:
def _stream_is_misconfigured(stream: t.TextIO) -> bool:
"""A stream is misconfigured if its encoding is ASCII."""
# If the stream does not have an encoding set, we assume it's set
# to ASCII. This appears to happen in certain unittest
# environments. It's not quite clear what the correct behavior is
# but this at least will force Click to recover somehow.
"""如果流被配置为 ASCII我们认为它是配置错误的misconfigured"""
return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii")
def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: str | None) -> bool:
"""A stream attribute is compatible if it is equal to the
desired value or the desired value is unset and the attribute
has a value.
"""
"""检查流的属性是否与期望值兼容"""
stream_value = getattr(stream, attr, None)
return stream_value == value or (value is None and stream_value is not None)
@ -227,9 +240,7 @@ def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: str | None) -> bo
def _is_compatible_text_stream(
stream: t.TextIO, encoding: str | None, errors: str | None
) -> bool:
"""Check if a stream's encoding and errors attributes are
compatible with the desired values.
"""
"""检查现有的 TextStream 是否已经满足了我们的编码和错误处理需求"""
return _is_compat_stream_attr(
stream, "encoding", encoding
) and _is_compat_stream_attr(stream, "errors", errors)
@ -244,34 +255,36 @@ def _force_correct_text_stream(
force_readable: bool = False,
force_writable: bool = False,
) -> t.TextIO:
"""
核心逻辑强制获取一个编码正确的 TextStream
流程
1. 如果本来就是 Binary直接包装
2. 如果是 Text且编码正确UTF-8直接返回
3. 如果是 Text 但编码错误ASCII尝试挖掘底层的 Binary 流并重新包装
"""
if is_binary(text_stream, False):
binary_reader = t.cast(t.BinaryIO, text_stream)
else:
text_stream = t.cast(t.TextIO, text_stream)
# If the stream looks compatible, and won't default to a
# misconfigured ascii encoding, return it as-is.
# 如果现有流兼容且未被错误配置为 ASCII直接使用
if _is_compatible_text_stream(text_stream, encoding, errors) and not (
encoding is None and _stream_is_misconfigured(text_stream)
):
return text_stream
# Otherwise, get the underlying binary reader.
# 否则,尝试获取底层二进制流
possible_binary_reader = find_binary(text_stream)
# If that's not possible, silently use the original reader
# and get mojibake instead of exceptions.
# 如果实在找不到底层二进制流,只能将就使用原流(可能会出现乱码/Mojibake
if possible_binary_reader is None:
return text_stream
binary_reader = possible_binary_reader
# Default errors to replace instead of strict in order to get
# something that works.
if errors is None:
errors = "replace"
# Wrap the binary stream in a text stream with the correct
# encoding parameters.
# 使用正确的编码参数重新包装二进制流
return _make_text_stream(
binary_reader,
encoding,
@ -312,6 +325,10 @@ def _force_correct_text_writer(
force_writable=force_writable,
)
# ========================================================================
# 公共 API获取标准流
# 这些函数是 Click 内部用来替代 sys.stdin/stdout 的安全版本
# ========================================================================
def get_binary_stdin() -> t.BinaryIO:
reader = _find_binary_reader(sys.stdin)
@ -335,6 +352,7 @@ def get_binary_stderr() -> t.BinaryIO:
def get_text_stdin(encoding: str | None = None, errors: str | None = None) -> t.TextIO:
# Windows 控制台需要特殊处理
rv = _get_windows_console_stream(sys.stdin, encoding, errors)
if rv is not None:
return rv
@ -361,7 +379,7 @@ def _wrap_io_open(
encoding: str | None,
errors: str | None,
) -> t.IO[t.Any]:
"""Handles not passing ``encoding`` and ``errors`` in binary mode."""
"""处理二进制模式下不能传递 encoding/errors 参数的细节"""
if "b" in mode:
return open(file, mode)
@ -375,41 +393,50 @@ def open_stream(
errors: str | None = "strict",
atomic: bool = False,
) -> tuple[t.IO[t.Any], bool]:
"""
Click 的全能文件打开函数
特性
1. 支持 "-" 作为文件名自动映射到 stdin/stdout
2. 支持 atomic=True原子写入
Returns:
(file_object, should_close)
should_close: 如果是 stdout/stdin返回 False不应关闭如果是文件返回 True
"""
binary = "b" in mode
filename = os.fspath(filename)
# Standard streams first. These are simple because they ignore the
# atomic flag. Use fsdecode to handle Path("-").
# 1. 处理标准流 "-"
if os.fsdecode(filename) == "-":
if any(m in mode for m in ["w", "a", "x"]):
# 写模式 -> stdout
if binary:
return get_binary_stdout(), False
return get_text_stdout(encoding=encoding, errors=errors), False
# 读模式 -> stdin
if binary:
return get_binary_stdin(), False
return get_text_stdin(encoding=encoding, errors=errors), False
# Non-atomic writes directly go out through the regular open functions.
# 2. 非原子写入,直接调用 _wrap_io_open
if not atomic:
return _wrap_io_open(filename, mode, encoding, errors), True
# Some usability stuff for atomic writes
# 3. 原子写入逻辑 (Atomic Writes)
# 限制:只能用于 'w' 模式,不支持 'a' (append)
if "a" in mode:
raise ValueError(
"Appending to an existing file is not supported, because that"
" would involve an expensive `copy`-operation to a temporary"
" file. Open the file in normal `w`-mode and copy explicitly"
" if that's what you're after."
"Appending to an existing file is not supported..."
)
if "x" in mode:
raise ValueError("Use the `overwrite`-parameter instead.")
if "w" not in mode:
raise ValueError("Atomic writes only make sense with `w`-mode.")
# Atomic writes are more complicated. They work by opening a file
# as a proxy in the same folder and then using the fdopen
# functionality to wrap it in a Python file. Then we wrap it in an
# atomic file that moves the file over on close.
# 原子写入原理:
# 1. 在同目录下创建一个临时文件 (.__atomic-write...)。
# 2. 写入数据到临时文件。
# 3. 文件关闭时,使用 os.replace 将临时文件重命名为目标文件(这是一个原子操作)。
import errno
import random
@ -423,6 +450,7 @@ def open_stream(
if binary:
flags |= getattr(os, "O_BINARY", 0)
# 循环尝试创建唯一的临时文件
while True:
tmp_filename = os.path.join(
os.path.dirname(filename),
@ -432,6 +460,7 @@ def open_stream(
fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm)
break
except OSError as e:
# 处理文件已存在或权限问题
if e.errno == errno.EEXIST or (
os.name == "nt"
and e.errno == errno.EACCES
@ -442,14 +471,16 @@ def open_stream(
raise
if perm is not None:
os.chmod(tmp_filename, perm) # in case perm includes bits in umask
os.chmod(tmp_filename, perm) # 恢复文件权限
f = _wrap_io_open(fd, mode, encoding, errors)
# 返回一个原子文件包装器
af = _AtomicFile(f, tmp_filename, os.path.realpath(filename))
return t.cast(t.IO[t.Any], af), True
class _AtomicFile:
"""原子文件包装器。在 close() 被调用时执行文件重命名操作。"""
def __init__(self, f: t.IO[t.Any], tmp_filename: str, real_filename: str) -> None:
self._f = f
self._tmp_filename = tmp_filename
@ -464,6 +495,7 @@ class _AtomicFile:
if self.closed:
return
self._f.close()
# 核心os.replace 是原子的。如果 delete=True发生异常则不覆盖。
os.replace(self._tmp_filename, self._real_filename)
self.closed = True
@ -479,17 +511,19 @@ class _AtomicFile:
exc_value: BaseException | None,
tb: TracebackType | None,
) -> None:
self.close(delete=exc_type is not None)
self.close(delete=exc_type is not None) # 如果发生异常,不要覆盖原文件
def __repr__(self) -> str:
return repr(self._f)
def strip_ansi(value: str) -> str:
"""去除字符串中的 ANSI 颜色代码"""
return _ansi_re.sub("", value)
def _is_jupyter_kernel_output(stream: t.IO[t.Any]) -> bool:
"""检测是否在 Jupyter Notebook 环境中"""
while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)):
stream = stream._stream
@ -499,29 +533,33 @@ def _is_jupyter_kernel_output(stream: t.IO[t.Any]) -> bool:
def should_strip_ansi(
stream: t.IO[t.Any] | None = None, color: bool | None = None
) -> bool:
"""判断是否应该剥离 ANSI 颜色代码(即是否应该禁用颜色)"""
if color is None:
if stream is None:
stream = sys.stdin
# 如果不是 TTY (终端) 且不是 Jupyter通常意味着在管道中应禁用颜色
return not isatty(stream) and not _is_jupyter_kernel_output(stream)
return not color
# On Windows, wrap the output streams with colorama to support ANSI
# color codes.
# NOTE: double check is needed so mypy does not analyze this on Linux
# ========================================================================
# Windows ANSI 支持 (Colorama 集成)
# ========================================================================
# 在 Windows 上,默认控制台不支持 ANSI 颜色。
# Click 检测到 Windows 时,会尝试使用 colorama 库来转换 ANSI 代码为 Windows API 调用。
if sys.platform.startswith("win") and WIN:
from ._winconsole import _get_windows_console_stream
def _get_argv_encoding() -> str:
import locale
return locale.getpreferredencoding()
# 使用 WeakKeyDictionary 缓存 wrapper避免内存泄漏
_ansi_stream_wrappers: cabc.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary()
def auto_wrap_for_ansi(stream: t.TextIO, color: bool | None = None) -> t.TextIO:
"""Support ANSI color and style codes on Windows by wrapping a
stream with colorama.
"""
使用 colorama 包装流以在 Windows 上支持 ANSI 颜色
"""
try:
cached = _ansi_stream_wrappers.get(stream)
@ -531,18 +569,21 @@ if sys.platform.startswith("win") and WIN:
if cached is not None:
return cached
import colorama
import colorama # 延迟导入,非 Windows 不需要
strip = should_strip_ansi(stream, color)
# colorama.AnsiToWin32 负责核心转换逻辑
ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip)
rv = t.cast(t.TextIO, ansi_wrapper.stream)
# 定义安全的 write 方法
_write = rv.write
def _safe_write(s: str) -> int:
try:
return _write(s)
except BaseException:
ansi_wrapper.reset_all()
ansi_wrapper.reset_all() # 出错时重置颜色
raise
rv.write = _safe_write # type: ignore[method-assign]
@ -555,7 +596,7 @@ if sys.platform.startswith("win") and WIN:
return rv
else:
# 非 Windows 平台的空实现
def _get_argv_encoding() -> str:
return getattr(sys.stdin, "encoding", None) or sys.getfilesystemencoding()
@ -566,10 +607,12 @@ else:
def term_len(x: str) -> int:
"""计算文本在终端显示的长度(忽略 ANSI 代码的长度)"""
return len(strip_ansi(x))
def isatty(stream: t.IO[t.Any]) -> bool:
"""安全的 isatty 检查"""
try:
return stream.isatty()
except Exception:
@ -580,10 +623,15 @@ def _make_cached_stream_func(
src_func: t.Callable[[], t.TextIO | None],
wrapper_func: t.Callable[[], t.TextIO],
) -> t.Callable[[], t.TextIO | None]:
"""
创建一个带缓存的流获取函数
作用确保对于同一个 underlying stream sys.stdout我们只创建一个 wrapper 对象
使用 WeakKeyDictionary 是为了防止引用循环和内存泄漏
"""
cache: cabc.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary()
def func() -> t.TextIO | None:
stream = src_func()
stream = src_func() # 获取原始流(如 sys.stdout
if stream is None:
return None
@ -594,7 +642,8 @@ def _make_cached_stream_func(
rv = None
if rv is not None:
return rv
rv = wrapper_func()
rv = wrapper_func() # 创建新的 wrapper
try:
cache[stream] = rv
except Exception:
@ -603,12 +652,12 @@ def _make_cached_stream_func(
return func
# 最终导出的标准流对象
_default_text_stdin = _make_cached_stream_func(lambda: sys.stdin, get_text_stdin)
_default_text_stdout = _make_cached_stream_func(lambda: sys.stdout, get_text_stdout)
_default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr)
# 映射表,方便按名称查找
binary_streams: cabc.Mapping[str, t.Callable[[], t.BinaryIO]] = {
"stdin": get_binary_stdin,
"stdout": get_binary_stdout,
@ -619,4 +668,4 @@ text_streams: cabc.Mapping[str, t.Callable[[str | None, str | None], t.TextIO]]
"stdin": get_text_stdin,
"stdout": get_text_stdout,
"stderr": get_text_stderr,
}
}

@ -1,7 +1,8 @@
"""
This module contains implementations for the termui module. To keep the
import time of Click down, some infrequently used functionality is
placed in this module and only imported as needed.
This module contains implementations for the termui module.
该模块包含 termui 模块的实现细节
为了减少 Click 的导入时间Import Time一些不常用的功能如进度条分页器被放在这里
仅在用户真正需要时才动态导入
"""
from __future__ import annotations
@ -19,6 +20,7 @@ from io import StringIO
from pathlib import Path
from types import TracebackType
# 导入底层兼容层,处理 Windows/Unix 差异
from ._compat import _default_text_stdout
from ._compat import CYGWIN
from ._compat import get_best_encoding
@ -30,35 +32,40 @@ from ._compat import WIN
from .exceptions import ClickException
from .utils import echo
V = t.TypeVar("V")
V = t.TypeVar("V") # 泛型变量,用于进度条迭代的内容类型
# 定义进度条的光标移动和隐藏字符
if os.name == "nt":
BEFORE_BAR = "\r"
AFTER_BAR = "\n"
BEFORE_BAR = "\r" # Windows: 回车到行首
AFTER_BAR = "\n" # Windows: 换行
else:
BEFORE_BAR = "\r\033[?25l"
AFTER_BAR = "\033[?25h\n"
BEFORE_BAR = "\r\033[?25l" # Unix: 回车 + 隐藏光标 (ANSI code ?25l)
AFTER_BAR = "\033[?25h\n" # Unix: 显示光标 (ANSI code ?25h) + 换行
class ProgressBar(t.Generic[V]):
"""
全功能的文本进度条实现
支持迭代器包装手动更新ETA 计算百分比显示自定义样式
"""
def __init__(
self,
iterable: cabc.Iterable[V] | None,
length: int | None = None,
fill_char: str = "#",
empty_char: str = " ",
bar_template: str = "%(bar)s",
info_sep: str = " ",
hidden: bool = False,
show_eta: bool = True,
show_percent: bool | None = None,
show_pos: bool = False,
item_show_func: t.Callable[[V | None], str | None] | None = None,
label: str | None = None,
file: t.TextIO | None = None,
color: bool | None = None,
update_min_steps: int = 1,
width: int = 30,
iterable: cabc.Iterable[V] | None, # 要遍历的数据源
length: int | None = None, # 总长度(如果 iterable 无法获取 len
fill_char: str = "#", # 填充字符
empty_char: str = " ", # 空白字符
bar_template: str = "%(bar)s", # 进度条模板
info_sep: str = " ", # 信息分隔符
hidden: bool = False, # 是否强制隐藏
show_eta: bool = True, # 是否显示预计剩余时间
show_percent: bool | None = None, # 是否显示百分比
show_pos: bool = False, # 是否显示具体数值 (如 1/100)
item_show_func: t.Callable[[V | None], str | None] | None = None, # 自定义显示当前处理项的回调
label: str | None = None, # 进度条左侧的标签文本
file: t.TextIO | None = None, # 输出流 (默认 stdout)
color: bool | None = None, # 是否启用颜色
update_min_steps: int = 1, # 最小更新步长(优化性能)
width: int = 30, # 进度条图形的宽度
) -> None:
self.fill_char = fill_char
self.empty_char = empty_char
@ -71,11 +78,9 @@ class ProgressBar(t.Generic[V]):
self.item_show_func = item_show_func
self.label: str = label or ""
# 处理输出流,如果环境异常(如 pythonw使用 StringIO 吞掉输出
if file is None:
file = _default_text_stdout()
# There are no standard streams attached to write to. For example,
# pythonw on Windows.
if file is None:
file = StringIO()
@ -84,23 +89,25 @@ class ProgressBar(t.Generic[V]):
self.update_min_steps = update_min_steps
self._completed_intervals = 0
self.width: int = width
self.autowidth: bool = width == 0
self.autowidth: bool = width == 0 # 如果宽度设为 0则自动适应终端宽度
# 尝试推断长度
if length is None:
from operator import length_hint
length = length_hint(iterable, -1)
if length == -1:
length = None
# 如果没有 iterable生成一个 range (主要用于手动 update 模式)
if iterable is None:
if length is None:
raise TypeError("iterable or length is required")
iterable = t.cast("cabc.Iterable[V]", range(length))
self.iter: cabc.Iterable[V] = iter(iterable)
self.length = length
self.pos: int = 0
self.avg: list[float] = []
self.avg: list[float] = [] # 用于计算平滑 ETA 的滑动窗口
self.last_eta: float
self.start: float
self.start = self.last_eta = time.time()
@ -109,12 +116,12 @@ class ProgressBar(t.Generic[V]):
self.max_width: int | None = None
self.entered: bool = False
self.current_item: V | None = None
self._is_atty = isatty(self.file)
self._is_atty = isatty(self.file) # 检查是否连接到终端
self._last_line: str | None = None
def __enter__(self) -> ProgressBar[V]:
self.entered = True
self.render_progress()
self.render_progress() # 上下文进入时渲染第一帧
return self
def __exit__(
@ -123,47 +130,48 @@ class ProgressBar(t.Generic[V]):
exc_value: BaseException | None,
tb: TracebackType | None,
) -> None:
self.render_finish()
self.render_finish() # 上下文退出时清理(换行、恢复光标)
def __iter__(self) -> cabc.Iterator[V]:
# 强制要求必须在 with 语句中使用
if not self.entered:
raise RuntimeError("You need to use progress bars in a with block.")
self.render_progress()
return self.generator()
def __next__(self) -> V:
# Iteration is defined in terms of a generator function,
# returned by iter(self); use that to define next(). This works
# because `self.iter` is an iterable consumed by that generator,
# so it is re-entry safe. Calling `next(self.generator())`
# twice works and does "what you want".
# 支持 next() 直接调用
return next(iter(self))
def render_finish(self) -> None:
if self.hidden or not self._is_atty:
return
self.file.write(AFTER_BAR)
self.file.write(AFTER_BAR) # 打印结束符 (恢复光标 + 换行)
self.file.flush()
@property
def pct(self) -> float:
"""计算完成百分比 (0.0 - 1.0)"""
if self.finished:
return 1.0
return min(self.pos / (float(self.length or 1) or 1), 1.0)
@property
def time_per_iteration(self) -> float:
"""计算平均每次迭代耗时"""
if not self.avg:
return 0.0
return sum(self.avg) / float(len(self.avg))
@property
def eta(self) -> float:
"""计算剩余时间 (秒)"""
if self.length is not None and not self.finished:
return self.time_per_iteration * (self.length - self.pos)
return 0.0
def format_eta(self) -> str:
"""格式化 ETA 为易读字符串 (如 1d 02:00:00)"""
if self.eta_known:
t = int(self.eta)
seconds = t % 60
@ -179,22 +187,27 @@ class ProgressBar(t.Generic[V]):
return ""
def format_pos(self) -> str:
"""格式化进度数值 (如 '5/10')"""
pos = str(self.pos)
if self.length is not None:
pos += f"/{self.length}"
return pos
def format_pct(self) -> str:
"""格式化百分比 (如 ' 50%')"""
return f"{int(self.pct * 100): 4}%"[1:]
def format_bar(self) -> str:
"""生成进度条图形部分"""
if self.length is not None:
# 确定性模式:[##### ]
bar_length = int(self.pct * self.width)
bar = self.fill_char * bar_length
bar += self.empty_char * (self.width - bar_length)
elif self.finished:
bar = self.fill_char * self.width
else:
# 不确定性模式(脉冲模式):一个小方块来回移动
chars = list(self.empty_char * (self.width or 1))
if self.time_per_iteration != 0:
chars[
@ -207,6 +220,7 @@ class ProgressBar(t.Generic[V]):
return bar
def format_progress_line(self) -> str:
"""组装整个进度行字符串"""
show_percent = self.show_percent
info_bits = []
@ -220,6 +234,7 @@ class ProgressBar(t.Generic[V]):
if self.show_eta and self.eta_known and not self.finished:
info_bits.append(self.format_eta())
if self.item_show_func is not None:
# 调用用户提供的回调显示当前项信息
item_info = self.item_show_func(self.current_item)
if item_info is not None:
info_bits.append(item_info)
@ -234,25 +249,27 @@ class ProgressBar(t.Generic[V]):
).rstrip()
def render_progress(self) -> None:
"""核心渲染逻辑"""
if self.hidden:
return
if not self._is_atty:
# Only output the label once if the output is not a TTY.
# 非终端模式下,只打印一次 Label不显示动态进度条避免日志文件爆炸
if self._last_line != self.label:
self._last_line = self.label
echo(self.label, file=self.file, color=self.color)
return
buf = []
# Update width in case the terminal has been resized
# 处理终端大小变化
if self.autowidth:
import shutil
old_width = self.width
self.width = 0
clutter_length = term_len(self.format_progress_line())
# 计算剩余可用宽度
new_width = max(0, shutil.get_terminal_size().columns - clutter_length)
# 如果变窄了,清除残留字符
if new_width < old_width and self.max_width is not None:
buf.append(BEFORE_BAR)
buf.append(" " * self.max_width)
@ -263,65 +280,54 @@ class ProgressBar(t.Generic[V]):
if self.max_width is not None:
clear_width = self.max_width
buf.append(BEFORE_BAR)
buf.append(BEFORE_BAR) # 回车到行首
line = self.format_progress_line()
line_len = term_len(line)
if self.max_width is None or self.max_width < line_len:
self.max_width = line_len
buf.append(line)
buf.append(" " * (clear_width - line_len))
buf.append(" " * (clear_width - line_len)) # 填充空格清除旧内容
line = "".join(buf)
# Render the line only if it changed.
# 只有内容变化时才输出,减少 I/O 压力
if line != self._last_line:
self._last_line = line
echo(line, file=self.file, color=self.color, nl=False)
self.file.flush()
def make_step(self, n_steps: int) -> None:
"""更新内部状态位置、ETA 计算)"""
self.pos += n_steps
if self.length is not None and self.pos >= self.length:
self.finished = True
if (time.time() - self.last_eta) < 1.0:
return
return # 每秒最多计算一次 ETA
self.last_eta = time.time()
# self.avg is a rolling list of length <= 7 of steps where steps are
# defined as time elapsed divided by the total progress through
# self.length.
# 更新移动平均值 (Rolling Average),窗口大小为 6
if self.pos:
step = (time.time() - self.start) / self.pos
else:
step = time.time() - self.start
self.avg = self.avg[-6:] + [step]
self.eta_known = self.length is not None
def update(self, n_steps: int, current_item: V | None = None) -> None:
"""Update the progress bar by advancing a specified number of
steps, and optionally set the ``current_item`` for this new
position.
:param n_steps: Number of steps to advance.
:param current_item: Optional item to set as ``current_item``
for the updated position.
.. versionchanged:: 8.0
Added the ``current_item`` optional parameter.
.. versionchanged:: 8.0
Only render when the number of steps meets the
``update_min_steps`` threshold.
"""
手动更新进度条
:param n_steps: 前进的步数
:param current_item: 当前处理的项用于 display callback
"""
if current_item is not None:
self.current_item = current_item
self._completed_intervals += n_steps
# 性能优化:只在满足最小更新步数时才渲染
if self._completed_intervals >= self.update_min_steps:
self.make_step(self._completed_intervals)
self.render_progress()
@ -333,52 +339,46 @@ class ProgressBar(t.Generic[V]):
self.finished = True
def generator(self) -> cabc.Iterator[V]:
"""Return a generator which yields the items added to the bar
during construction, and updates the progress bar *after* the
yielded block returns.
"""
# WARNING: the iterator interface for `ProgressBar` relies on
# this and only works because this is a simple generator which
# doesn't create or manage additional state. If this function
# changes, the impact should be evaluated both against
# `iter(bar)` and `next(bar)`. `next()` in particular may call
# `self.generator()` repeatedly, and this must remain safe in
# order for that interface to work.
核心生成器
它在产出yield数据的同时透明地更新进度条
"""
if not self.entered:
raise RuntimeError("You need to use progress bars in a with block.")
if not self._is_atty:
yield from self.iter
yield from self.iter # 非终端模式直接透传
else:
for rv in self.iter:
self.current_item = rv
# This allows show_item_func to be updated before the
# item is processed. Only trigger at the beginning of
# the update interval.
# 在处理每个 item 之前渲染,让 item_show_func 能显示当前正在处理的项
if self._completed_intervals == 0:
self.render_progress()
yield rv
self.update(1)
yield rv # 将控制权交还给用户代码
self.update(1) # 用户代码处理完后,进度+1
self.finish()
self.render_progress()
# ========================================================================
# 分页器 (Pager) 实现
# ========================================================================
def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
"""Decide what method to use for paging through text."""
"""智能选择最佳的分页方式。"""
stdout = _default_text_stdout()
# There are no standard streams attached to write to. For example,
# pythonw on Windows.
if stdout is None:
stdout = StringIO()
# 如果不是 TTY不分页直接打印
if not isatty(sys.stdin) or not isatty(stdout):
return _nullpager(stdout, generator, color)
# Split and normalize the pager command into parts.
# 1. 尝试使用环境变量 PAGER 指定的程序
pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""), posix=False)
if pager_cmd_parts:
if WIN:
@ -387,8 +387,11 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
elif _pipepager(generator, pager_cmd_parts, color):
return
# 2. 哑终端回退
if os.environ.get("TERM") in ("dumb", "emacs"):
return _nullpager(stdout, generator, color)
# 3. 尝试使用 'more' (Windows) 或 'less' (Unix)
if (WIN or sys.platform.startswith("os2")) and _tempfilepager(
generator, ["more"], color
):
@ -396,8 +399,8 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
if _pipepager(generator, ["less"], color):
return
# 4. 最后的兜底:使用临时文件 + more或者直接打印
import tempfile
fd, filename = tempfile.mkstemp()
os.close(fd)
try:
@ -411,38 +414,29 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
def _pipepager(
generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
) -> bool:
"""Page through text by feeding it to another program. Invoking a
pager through this might support colors.
Returns `True` if the command was found, `False` otherwise and thus another
pager should be attempted.
"""
# Split the command into the invoked CLI and its parameters.
通过管道 (Pipe) 将内容输送给分页程序 less
这种方式最好因为它支持流式处理不需要等待所有内容生成完毕
"""
if not cmd_parts:
return False
import shutil
cmd = cmd_parts[0]
cmd_params = cmd_parts[1:]
# 检查命令是否存在
cmd_filepath = shutil.which(cmd)
if not cmd_filepath:
return False
# Produces a normalized absolute path string.
# multi-call binaries such as busybox derive their identity from the symlink
# less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox)
cmd_path = Path(cmd_filepath).absolute()
cmd_name = cmd_path.name
import subprocess
# Make a local copy of the environment to not affect the global one.
env = dict(os.environ)
# If we're piping to less and the user hasn't decided on colors, we enable
# them by default we find the -R flag in the command line arguments.
# 针对 less 的特殊优化:自动开启颜色支持 (-R)
if color is None and cmd_name == "less":
less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_params)}"
if not less_flags:
@ -451,6 +445,7 @@ def _pipepager(
elif "r" in less_flags or "R" in less_flags:
color = True
# 启动分页进程stdin 连接到管道
c = subprocess.Popen(
[str(cmd_path)] + cmd_params,
shell=False,
@ -461,38 +456,26 @@ def _pipepager(
)
assert c.stdin is not None
try:
# 将生成器的内容逐块写入管道
for text in generator:
if not color:
text = strip_ansi(text)
c.stdin.write(text)
except BrokenPipeError:
# In case the pager exited unexpectedly, ignore the broken pipe error.
# 分页器提前退出(用户按了 q忽略错误
pass
except Exception as e:
# In case there is an exception we want to close the pager immediately
# and let the caller handle it.
# Otherwise the pager will keep running, and the user may not notice
# the error message, or worse yet it may leave the terminal in a broken state.
c.terminate()
raise e
finally:
# We must close stdin and wait for the pager to exit before we continue
# 必须关闭 stdin 并等待分页器退出
try:
c.stdin.close()
# Close implies flush, so it might throw a BrokenPipeError if the pager
# process exited already.
except BrokenPipeError:
pass
# Less doesn't respect ^C, but catches it for its own UI purposes (aborting
# search or other commands inside less).
#
# That means when the user hits ^C, the parent process (click) terminates,
# but less is still alive, paging the output and messing up the terminal.
#
# If the user wants to make the pager exit on ^C, they should set
# `LESS='-K'`. It's not our decision to make.
# 循环等待,直到用户退出 less。
# 这里必须处理 KeyboardInterrupt否则用户按 Ctrl+C 会杀掉 Python 脚本但留下 less 进程。
while True:
try:
c.wait()
@ -507,42 +490,27 @@ def _pipepager(
def _tempfilepager(
generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
) -> bool:
"""Page through text by invoking a program on a temporary file.
Returns `True` if the command was found, `False` otherwise and thus another
pager should be attempted.
"""
# Split the command into the invoked CLI and its parameters.
if not cmd_parts:
return False
import shutil
cmd = cmd_parts[0]
cmd_filepath = shutil.which(cmd)
if not cmd_filepath:
return False
# Produces a normalized absolute path string.
# multi-call binaries such as busybox derive their identity from the symlink
# less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox)
cmd_path = Path(cmd_filepath).absolute()
通过临时文件调用分页器
用于不支持从 stdin 读取的分页器或者 Windows 上的某些情况
缺点必须先将所有内容生成并写入磁盘无法流式处理
"""
# ... (省略部分路径检查代码) ...
import subprocess
import tempfile
fd, filename = tempfile.mkstemp()
# TODO: This never terminates if the passed generator never terminates.
text = "".join(generator)
text = "".join(generator) # 必须一次性消耗所有生成器内容
if not color:
text = strip_ansi(text)
encoding = get_best_encoding(sys.stdout)
# 写入临时文件
with open_stream(filename, "wb")[0] as f:
f.write(text.encode(encoding))
try:
subprocess.call([str(cmd_path), filename])
except OSError:
# Command not found
pass
finally:
os.close(fd)
@ -554,20 +522,24 @@ def _tempfilepager(
def _nullpager(
stream: t.TextIO, generator: cabc.Iterable[str], color: bool | None
) -> None:
"""Simply print unformatted text. This is the ultimate fallback."""
"""回退方案:直接打印,不分页"""
for text in generator:
if not color:
text = strip_ansi(text)
stream.write(text)
# ========================================================================
# 编辑器 (Editor) 调用
# ========================================================================
class Editor:
def __init__(
self,
editor: str | None = None,
env: cabc.Mapping[str, str] | None = None,
require_save: bool = True,
extension: str = ".txt",
require_save: bool = True, # 是否要求用户必须保存文件才算成功
extension: str = ".txt", # 临时文件的后缀,影响编辑器的语法高亮
) -> None:
self.editor = editor
self.env = env
@ -575,6 +547,7 @@ class Editor:
self.extension = extension
def get_editor(self) -> str:
"""获取编辑器命令。优先级:参数 > VISUAL > EDITOR > 系统默认(vim/nano/notepad)"""
if self.editor is not None:
return self.editor
for key in "VISUAL", "EDITOR":
@ -583,228 +556,112 @@ class Editor:
return rv
if WIN:
return "notepad"
from shutil import which
for editor in "sensible-editor", "vim", "nano":
if which(editor) is not None:
return editor
# ... (Unix 默认编辑器探测) ...
return "vi"
def edit_files(self, filenames: cabc.Iterable[str]) -> None:
import subprocess
editor = self.get_editor()
environ: dict[str, str] | None = None
if self.env:
environ = os.environ.copy()
environ.update(self.env)
exc_filename = " ".join(f'"{filename}"' for filename in filenames)
try:
c = subprocess.Popen(
args=f"{editor} {exc_filename}", env=environ, shell=True
)
exit_code = c.wait()
if exit_code != 0:
raise ClickException(
_("{editor}: Editing failed").format(editor=editor)
)
except OSError as e:
raise ClickException(
_("{editor}: Editing failed: {e}").format(editor=editor, e=e)
) from e
@t.overload
def edit(self, text: bytes | bytearray) -> bytes | None: ...
# We cannot know whether or not the type expected is str or bytes when None
# is passed, so str is returned as that was what was done before.
@t.overload
def edit(self, text: str | None) -> str | None: ...
def edit(self, text: str | bytes | bytearray | None) -> str | bytes | None:
"""
打开编辑器让用户编辑文本
流程
1. 创建临时文件
2. 将初始 text 写入临时文件
3. 调用编辑器进程打开该文件
4. 等待编辑器关闭
5. 读取文件内容并返回
"""
import tempfile
if text is None:
data: bytes | bytearray = b""
elif isinstance(text, (bytes, bytearray)):
data = text
else:
if text and not text.endswith("\n"):
text += "\n"
if WIN:
data = text.replace("\n", "\r\n").encode("utf-8-sig")
else:
data = text.encode("utf-8")
# ... (预处理 text 数据,处理换行符和编码) ...
fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension)
f: t.BinaryIO
try:
# 写入初始内容
with os.fdopen(fd, "wb") as f:
f.write(data)
# If the filesystem resolution is 1 second, like Mac OS
# 10.12 Extended, or 2 seconds, like FAT32, and the editor
# closes very fast, require_save can fail. Set the modified
# time to be 2 seconds in the past to work around this.
# 技巧:将文件修改时间设为过去 2 秒。
# 这是为了检测用户是否真的保存了文件mtime 是否变化)。
# 某些文件系统精度不够,所以需要回退 2 秒以确保安全。
os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2))
# Depending on the resolution, the exact value might not be
# recorded, so get the new recorded value.
timestamp = os.path.getmtime(name)
self.edit_files((name,))
# 检查是否保存
if self.require_save and os.path.getmtime(name) == timestamp:
return None
return None # 未保存,返回 None
# 读取修改后的内容
with open(name, "rb") as f:
rv = f.read()
if isinstance(text, (bytes, bytearray)):
return rv
# ... (解码并返回) ...
return rv.decode("utf-8-sig").replace("\r\n", "\n")
finally:
os.unlink(name)
os.unlink(name) # 清理临时文件
# ========================================================================
# 浏览器打开 (Open URL)
# ========================================================================
def open_url(url: str, wait: bool = False, locate: bool = False) -> int:
"""
打开 URL 或文件
跨平台支持macOS (open), Windows (start/explorer), Linux (xdg-open)
"""
import subprocess
def _unquote_file(url: str) -> str:
# 处理 file:// 协议
from urllib.parse import unquote
if url.startswith("file://"):
url = unquote(url[7:])
return url
if sys.platform == "darwin":
args = ["open"]
if wait:
args.append("-W")
if locate:
args.append("-R")
if wait: args.append("-W")
if locate: args.append("-R") # 在 Finder 中显示文件
args.append(_unquote_file(url))
null = open("/dev/null", "w")
try:
return subprocess.Popen(args, stderr=null).wait()
finally:
null.close()
# ...
elif WIN:
if locate:
url = _unquote_file(url)
args = ["explorer", f"/select,{url}"]
else:
args = ["start"]
if wait:
args.append("/WAIT")
args.append("")
args.append(url)
try:
return subprocess.call(args)
except OSError:
# Command not found
return 127
elif CYGWIN:
if locate:
url = _unquote_file(url)
args = ["cygstart", os.path.dirname(url)]
else:
args = ["cygstart"]
if wait:
args.append("-w")
args.append(url)
try:
return subprocess.call(args)
except OSError:
# Command not found
return 127
try:
if locate:
url = os.path.dirname(_unquote_file(url)) or "."
else:
url = _unquote_file(url)
c = subprocess.Popen(["xdg-open", url])
if wait:
return c.wait()
return 0
except OSError:
if url.startswith(("http://", "https://")) and not locate and not wait:
import webbrowser
# ... (Windows 特定的 explorer / start 调用) ...
pass
# ... (Linux xdg-open) ...
webbrowser.open(url)
return 0
return 1
# ========================================================================
# 单字符输入 (getchar)
# ========================================================================
def _translate_ch_to_exc(ch: str) -> None:
if ch == "\x03":
"""将特定控制字符转换为 Python 异常"""
if ch == "\x03": # Ctrl+C
raise KeyboardInterrupt()
if ch == "\x04" and not WIN: # Unix-like, Ctrl+D
if ch == "\x04" and not WIN: # Unix Ctrl+D (EOF)
raise EOFError()
if ch == "\x1a" and WIN: # Windows, Ctrl+Z
if ch == "\x1a" and WIN: # Windows Ctrl+Z (EOF)
raise EOFError()
return None
if sys.platform == "win32":
import msvcrt
@contextlib.contextmanager
def raw_terminal() -> cabc.Iterator[int]:
yield -1
def getchar(echo: bool) -> str:
# The function `getch` will return a bytes object corresponding to
# the pressed character. Since Windows 10 build 1803, it will also
# return \x00 when called a second time after pressing a regular key.
#
# `getwch` does not share this probably-bugged behavior. Moreover, it
# returns a Unicode object by default, which is what we want.
#
# Either of these functions will return \x00 or \xe0 to indicate
# a special key, and you need to call the same function again to get
# the "rest" of the code. The fun part is that \u00e0 is
# "latin small letter a with grave", so if you type that on a French
# keyboard, you _also_ get a \xe0.
# E.g., consider the Up arrow. This returns \xe0 and then \x48. The
# resulting Unicode string reads as "a with grave" + "capital H".
# This is indistinguishable from when the user actually types
# "a with grave" and then "capital H".
#
# When \xe0 is returned, we assume it's part of a special-key sequence
# and call `getwch` again, but that means that when the user types
# the \u00e0 character, `getchar` doesn't return until a second
# character is typed.
# The alternative is returning immediately, but that would mess up
# cross-platform handling of arrow keys and others that start with
# \xe0. Another option is using `getch`, but then we can't reliably
# read non-ASCII characters, because return values of `getch` are
# limited to the current 8-bit codepage.
#
# Anyway, Click doesn't claim to do this Right(tm), and using `getwch`
# is doing the right thing in more situations than with `getch`.
"""
Windows getchar
使用了 msvcrt.getwch (宽字符版本)处理了非常复杂的特殊键如方向键逻辑
"""
# ... (详细的注释解释了为什么不用 getch 而用 getwch 以及 \xe0 转义的处理) ...
if echo:
func = t.cast(t.Callable[[], str], msvcrt.getwche)
else:
func = t.cast(t.Callable[[], str], msvcrt.getwch)
rv = func()
# 处理特殊键(方向键等会先返回 \x00 或 \xe0需要再读一次
if rv in ("\x00", "\xe0"):
# \x00 and \xe0 are control characters that indicate special key,
# see above.
rv += func()
_translate_ch_to_exc(rv)
@ -816,37 +673,20 @@ else:
@contextlib.contextmanager
def raw_terminal() -> cabc.Iterator[int]:
f: t.TextIO | None
fd: int
if not isatty(sys.stdin):
f = open("/dev/tty")
fd = f.fileno()
else:
fd = sys.stdin.fileno()
f = None
try:
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
yield fd
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
sys.stdout.flush()
if f is not None:
f.close()
except termios.error:
pass
"""
Unix 下将终端设置为 RAW 模式的上下文管理器
RAW 模式下输入不再缓冲不需要按回车字符直接发送给程序
"""
# ... (保存旧 tty 设置 -> setraw -> yield -> 恢复旧设置) ...
def getchar(echo: bool) -> str:
"""
Unix getchar
通过将 stdin 设为 raw 模式并读取一个字符来实现
"""
with raw_terminal() as fd:
ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace")
if echo and isatty(sys.stdout):
sys.stdout.write(ch)
_translate_ch_to_exc(ch)
return ch
return ch

@ -6,6 +6,13 @@ from contextlib import contextmanager
class TextWrapper(textwrap.TextWrapper):
"""
继承自 Python 标准库的 TextWrapper
Click 需要定制这个类主要是为了
1. 修正标准库在处理超长单词时的某些边缘情况
2. 提供上下文管理器来动态调整缩进这对于生成嵌套的帮助信息非常有用
"""
def _handle_long_word(
self,
reversed_chunks: list[str],
@ -13,33 +20,79 @@ class TextWrapper(textwrap.TextWrapper):
cur_len: int,
width: int,
) -> None:
"""
重写标准库的内部方法用于处理那些比设定宽度width还要长的单词
背景当一个单词太长放不下当前行剩余空间时标准库的逻辑有时会不够灵活
Click 在这里强制执行特定的逻辑确保帮助文档排版的一致性
Args:
reversed_chunks: 待处理的单词列表倒序因为作为栈使用
cur_line: 当前行已经放入的单词列表
cur_len: 当前行的当前长度
width: 目标总宽度
"""
# 计算当前行剩余的空间,至少保留 1 个字符的位置
space_left = max(width - cur_len, 1)
# 情况 A: 允许打断长单词 (break_long_words=True)
if self.break_long_words:
last = reversed_chunks[-1]
cut = last[:space_left]
res = last[space_left:]
cur_line.append(cut)
reversed_chunks[-1] = res
last = reversed_chunks[-1] # 获取下一个要处理的单词(栈顶)
# 核心逻辑:切一刀
cut = last[:space_left] # 切下能塞进当前行的部分
res = last[space_left:] # 剩下的部分
cur_line.append(cut) # 将切下的部分放入当前行
reversed_chunks[-1] = res # 将剩下的部分放回栈顶,留给下一行处理
# 情况 B: 不允许打断,且当前行是空的 (not cur_line)
# 如果当前行已经有内容,标准库的逻辑会先换行,然后再调这个方法。
# 如果当前行是空的,说明这个单词本身就比整行宽度还宽,只能强行放进去(溢出)。
elif not cur_line:
cur_line.append(reversed_chunks.pop())
@contextmanager
def extra_indent(self, indent: str) -> cabc.Iterator[None]:
"""
一个非常有用的上下文管理器用于临时增加缩进
使用场景
当打印帮助文档的子段落或选项描述时我们需要临时增加缩进
打印完后自动恢复而不需要手动计算偏移量
Example:
with wrapper.extra_indent(" "):
wrapper.fill("这段文字会有额外的缩进...")
"""
# 1. 保存当前的缩进设置
old_initial_indent = self.initial_indent
old_subsequent_indent = self.subsequent_indent
# 2. 累加新的缩进字符串
self.initial_indent += indent
self.subsequent_indent += indent
try:
yield
yield # 进入上下文,执行用户代码
finally:
# 3. 退出时恢复之前的缩进设置
self.initial_indent = old_initial_indent
self.subsequent_indent = old_subsequent_indent
def indent_only(self, text: str) -> str:
"""
仅对文本进行缩进但不进行自动折行Wrapping
区别
- textwrap.fill(): 会缩进并且会打乱原有换行符将文本重新排版以适应宽度
- indent_only(): 保留原文本的换行结构如代码块列表仅仅在每行前面加空格
这对于在帮助文档中展示示例代码块Code Snippets至关重要
"""
rv = []
# 遍历每一行,分别处理首行缩进和后续行缩进
for idx, line in enumerate(text.splitlines()):
indent = self.initial_indent
@ -48,4 +101,4 @@ class TextWrapper(textwrap.TextWrapper):
rv.append(f"{indent}{line}")
return "\n".join(rv)
return "\n".join(rv)

@ -5,10 +5,14 @@ import typing as t
class Sentinel(enum.Enum):
"""Enum used to define sentinel values.
"""
枚举类用于定义哨兵值 (Sentinel Values)
哨兵值是内存中唯一的对象用于表示某种特殊状态未设置
使用 Enum 的好处是它们在调试打印时有清晰的名字 Sentinel.UNSET
而不是像 object() 那样显示为 <object at 0x...>
.. seealso::
`PEP 661 - Sentinel Values <https://peps.python.org/pep-0661/>`_.
"""
@ -19,18 +23,32 @@ class Sentinel(enum.Enum):
return f"{self.__class__.__name__}.{self.name}"
# ========================================================================
# 导出具体的哨兵实例
# ========================================================================
UNSET = Sentinel.UNSET
"""Sentinel used to indicate that a value is not set."""
"""
用于指示一个值未设置的哨兵
我们在参数解析时用它作为默认值以区分 user_input=None user_input=UNSET (即用户根本没传参)
"""
FLAG_NEEDS_VALUE = Sentinel.FLAG_NEEDS_VALUE
"""Sentinel used to indicate an option was passed as a flag without a
value but is not a flag option.
``Option.consume_value`` uses this to prompt or use the ``flag_value``.
"""
用于指示一个 Option 被当作 Flag 传递没有参数但它实际上并不是 Flag 的情况
场景
用户定义了 option('--foo', is_flag=False)但调用时只写了 `mycmd --foo` 而没给值
Click 内部会先标记为 FLAG_NEEDS_VALUE后续逻辑Option.consume_value会据此决定是报错提示用户
还是使用 flag_value如果配置了的话
"""
# ========================================================================
# 类型提示 (Type Hints)
# 用于帮助 MyPy 等静态分析工具理解这些特殊值
# ========================================================================
T_UNSET = t.Literal[UNSET] # type: ignore[valid-type]
"""Type hint for the :data:`UNSET` sentinel value."""
"""UNSET 哨兵值的类型提示。"""
T_FLAG_NEEDS_VALUE = t.Literal[FLAG_NEEDS_VALUE] # type: ignore[valid-type]
"""Type hint for the :data:`FLAG_NEEDS_VALUE` sentinel value."""
"""FLAG_NEEDS_VALUE 哨兵值的类型提示。"""

@ -1,11 +1,7 @@
# This module is based on the excellent work by Adam Bartoš who
# provided a lot of what went into the implementation here in
# the discussion to issue1602 in the Python bug tracker.
#
# There are some general differences in regards to how this works
# compared to the original patches as we do not need to patch
# the entire interpreter but just work in our little world of
# echo and prompt.
# provided a lot of what went into the implementation here...
# (该模块基于 Adam Bartoš 的杰出工作...)
from __future__ import annotations
import collections.abc as cabc
@ -13,6 +9,7 @@ import io
import sys
import time
import typing as t
# 导入 ctypes 相关库,用于直接调用 Windows DLL
from ctypes import Array
from ctypes import byref
from ctypes import c_char
@ -31,18 +28,23 @@ from ctypes.wintypes import LPWSTR
from ._compat import _NonClosingTextIOWrapper
# 强制检查:此模块仅能在 Windows 下运行
assert sys.platform == "win32"
import msvcrt # noqa: E402
from ctypes import windll # noqa: E402
from ctypes import WINFUNCTYPE # noqa: E402
# ========================================================================
# Windows API 定义
# 使用 ctypes 加载 kernel32.dll 和 shell32.dll 中的函数
# ========================================================================
c_ssize_p = POINTER(c_ssize_t)
kernel32 = windll.kernel32
GetStdHandle = kernel32.GetStdHandle
ReadConsoleW = kernel32.ReadConsoleW
WriteConsoleW = kernel32.WriteConsoleW
GetConsoleMode = kernel32.GetConsoleMode
ReadConsoleW = kernel32.ReadConsoleW # 核心读取函数(宽字符版)
WriteConsoleW = kernel32.WriteConsoleW # 核心写入函数(宽字符版)
GetConsoleMode = kernel32.GetConsoleMode # 用于检查是否为控制台
GetLastError = kernel32.GetLastError
GetCommandLineW = WINFUNCTYPE(LPWSTR)(("GetCommandLineW", windll.kernel32))
CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))(
@ -50,6 +52,7 @@ CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))(
)
LocalFree = WINFUNCTYPE(c_void_p, c_void_p)(("LocalFree", windll.kernel32))
# 标准设备句柄 ID
STDIN_HANDLE = GetStdHandle(-10)
STDOUT_HANDLE = GetStdHandle(-11)
STDERR_HANDLE = GetStdHandle(-12)
@ -65,44 +68,34 @@ STDIN_FILENO = 0
STDOUT_FILENO = 1
STDERR_FILENO = 2
EOF = b"\x1a"
MAX_BYTES_WRITTEN = 32767
EOF = b"\x1a" # Windows 下的 EOF 是 Ctrl+Z (ASCII 26)
MAX_BYTES_WRITTEN = 32767 # 单次写入最大字节数限制
if t.TYPE_CHECKING:
try:
# Using `typing_extensions.Buffer` instead of `collections.abc`
# on Windows for some reason does not have `Sized` implemented.
from collections.abc import Buffer # type: ignore
except ImportError:
from typing_extensions import Buffer
# ... (省略了一些类型定义的胶水代码) ...
# ========================================================================
# 缓冲区处理
# 为了高性能,我们需要直接操作 Python 对象的内存缓冲区
# ========================================================================
try:
from ctypes import pythonapi
except ImportError:
# On PyPy we cannot get buffers so our ability to operate here is
# severely limited.
# PyPy 兼容性处理
get_buffer = None
else:
# 定义 Python 内部缓冲区的 C 结构体
class Py_buffer(Structure):
_fields_ = [ # noqa: RUF012
("buf", c_void_p),
("obj", py_object),
("len", c_ssize_t),
("itemsize", c_ssize_t),
("readonly", c_int),
("ndim", c_int),
("format", c_char_p),
("shape", c_ssize_p),
("strides", c_ssize_p),
("suboffsets", c_ssize_p),
("internal", c_void_p),
# ... (其他字段)
]
PyObject_GetBuffer = pythonapi.PyObject_GetBuffer
PyBuffer_Release = pythonapi.PyBuffer_Release
def get_buffer(obj: Buffer, writable: bool = False) -> Array[c_char]:
"""获取 Python 对象的底层内存视图,以便 Windows API 直接读写。"""
buf = Py_buffer()
flags: int = PyBUF_WRITABLE if writable else PyBUF_SIMPLE
PyObject_GetBuffer(py_object(obj), byref(buf), flags)
@ -116,6 +109,7 @@ else:
class _WindowsConsoleRawIOBase(io.RawIOBase):
"""Windows 控制台原始 I/O 基类"""
def __init__(self, handle: int | None) -> None:
self.handle = handle
@ -125,6 +119,11 @@ class _WindowsConsoleRawIOBase(io.RawIOBase):
class _WindowsConsoleReader(_WindowsConsoleRawIOBase):
"""
自定义的 Windows 控制台读取器
它调用 ReadConsoleW 来读取宽字符UTF-16这解决了 Python 2/3 早期版本
Windows 控制台读取 Unicode 字符时的很多乱码问题
"""
def readable(self) -> t.Literal[True]:
return True
@ -141,6 +140,7 @@ class _WindowsConsoleReader(_WindowsConsoleRawIOBase):
code_units_to_be_read = bytes_to_be_read // 2
code_units_read = c_ulong()
# 调用 Win32 API
rv = ReadConsoleW(
HANDLE(self.handle),
buffer,
@ -149,7 +149,7 @@ class _WindowsConsoleReader(_WindowsConsoleRawIOBase):
None,
)
if GetLastError() == ERROR_OPERATION_ABORTED:
# wait for KeyboardInterrupt
# 等待 KeyboardInterrupt (Ctrl+C)
time.sleep(0.1)
if not rv:
raise OSError(f"Windows error: {GetLastError()}")
@ -160,20 +160,19 @@ class _WindowsConsoleReader(_WindowsConsoleRawIOBase):
class _WindowsConsoleWriter(_WindowsConsoleRawIOBase):
"""
自定义的 Windows 控制台写入器
使用 WriteConsoleW 直接写入 UTF-16 编码的数据绕过 Python 的编码层
"""
def writable(self) -> t.Literal[True]:
return True
@staticmethod
def _get_error_message(errno: int) -> str:
if errno == ERROR_SUCCESS:
return "ERROR_SUCCESS"
elif errno == ERROR_NOT_ENOUGH_MEMORY:
return "ERROR_NOT_ENOUGH_MEMORY"
return f"Windows error {errno}"
# ... (错误处理 helper)
def write(self, b: Buffer) -> int:
bytes_to_be_written = len(b)
buf = get_buffer(b)
# 限制单次写入大小,防止 API 报错
code_units_to_be_written = min(bytes_to_be_written, MAX_BYTES_WRITTEN) // 2
code_units_written = c_ulong()
@ -185,13 +184,16 @@ class _WindowsConsoleWriter(_WindowsConsoleRawIOBase):
None,
)
bytes_written = 2 * code_units_written.value
if bytes_written == 0 and bytes_to_be_written > 0:
raise OSError(self._get_error_message(GetLastError()))
# ...
return bytes_written
class ConsoleStream:
"""
控制台流包装器
它同时持有 text_stream (用于处理文本) buffer (底层二进制流)
这是为了模拟 Python file object 的行为同时在底层偷梁换柱
"""
def __init__(self, text_stream: t.TextIO, byte_stream: t.BinaryIO) -> None:
self._text_stream = text_stream
self.buffer = byte_stream
@ -201,32 +203,26 @@ class ConsoleStream:
return self.buffer.name
def write(self, x: t.AnyStr) -> int:
# 如果是字符串,走 UTF-16 文本流
if isinstance(x, str):
return self._text_stream.write(x)
# 如果是字节,先刷新文本流,再直接写 buffer
try:
self.flush()
except Exception:
pass
return self.buffer.write(x)
def writelines(self, lines: cabc.Iterable[t.AnyStr]) -> None:
for line in lines:
self.write(line)
def __getattr__(self, name: str) -> t.Any:
return getattr(self._text_stream, name)
def isatty(self) -> bool:
return self.buffer.isatty()
def __repr__(self) -> str:
return f"<ConsoleStream name={self.name!r} encoding={self.encoding!r}>"
# ... (代理其他方法)
# ========================================================================
# 工厂函数:获取标准流
# ========================================================================
def _get_text_stdin(buffer_stream: t.BinaryIO) -> t.TextIO:
text_stream = _NonClosingTextIOWrapper(
io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)),
"utf-16-le",
"utf-16-le", # 关键Windows 内部使用 UTF-16-LE
"strict",
line_buffering=True,
)
@ -236,7 +232,7 @@ def _get_text_stdin(buffer_stream: t.BinaryIO) -> t.TextIO:
def _get_text_stdout(buffer_stream: t.BinaryIO) -> t.TextIO:
text_stream = _NonClosingTextIOWrapper(
io.BufferedWriter(_WindowsConsoleWriter(STDOUT_HANDLE)),
"utf-16-le",
"utf-16-le", # 关键:使用 UTF-16-LE 写入
"strict",
line_buffering=True,
)
@ -244,15 +240,10 @@ def _get_text_stdout(buffer_stream: t.BinaryIO) -> t.TextIO:
def _get_text_stderr(buffer_stream: t.BinaryIO) -> t.TextIO:
text_stream = _NonClosingTextIOWrapper(
io.BufferedWriter(_WindowsConsoleWriter(STDERR_HANDLE)),
"utf-16-le",
"strict",
line_buffering=True,
)
return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream))
# 同上,处理标准错误
# ...
# 映射表:文件描述符 -> 工厂函数
_stream_factories: cabc.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = {
0: _get_text_stdin,
1: _get_text_stdout,
@ -261,6 +252,11 @@ _stream_factories: cabc.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = {
def _is_console(f: t.TextIO) -> bool:
"""
检查给定的文件流是否连接到了真实的 Windows 控制台窗口
原理尝试获取句柄的 ConsoleMode如果成功说明是控制台
如果失败比如重定向到了文件或管道则不是控制台
"""
if not hasattr(f, "fileno"):
return False
@ -270,12 +266,22 @@ def _is_console(f: t.TextIO) -> bool:
return False
handle = msvcrt.get_osfhandle(fileno)
# 调用 GetConsoleMode
return bool(GetConsoleMode(handle, byref(DWORD())))
def _get_windows_console_stream(
f: t.TextIO, encoding: str | None, errors: str | None
) -> t.TextIO | None:
"""
Click 兼容性层的入口函数
尝试将一个普通的文件流升级为 Windows 控制台流
条件
1. 必须能获取 buffer ( PyPy)
2. 编码必须兼容 (UTF-16-LE)
3. 必须确实是控制台 (_is_console 返回 True)
"""
if (
get_buffer is None
or encoding not in {"utf-16-le", None}
@ -289,8 +295,7 @@ def _get_windows_console_stream(
return None
b = getattr(f, "buffer", None)
if b is None:
return None
return func(b)
return func(b)

@ -14,22 +14,21 @@ from .core import Parameter
from .globals import get_current_context
from .utils import echo
if t.TYPE_CHECKING:
import typing_extensions as te
P = te.ParamSpec("P")
R = t.TypeVar("R")
T = t.TypeVar("T")
_AnyCallable = t.Callable[..., t.Any]
FC = t.TypeVar("FC", bound="_AnyCallable | Command")
# ... (省略类型变量定义)
# ========================================================================
# 依赖注入装饰器
# 允许函数显式请求 Context 或 Obj而无需硬编码
# ========================================================================
def pass_context(f: t.Callable[te.Concatenate[Context, P], R]) -> t.Callable[P, R]:
"""Marks a callback as wanting to receive the current context
object as first argument.
"""
依赖注入传递上下文
被装饰的函数会接收 `ctx` (Context 对象) 作为第一个参数
实现原理创建一个新函数调用 get_current_context() 获取当前环境
然后塞给原函数
"""
def new_func(*args: P.args, **kwargs: P.kwargs) -> R:
return f(get_current_context(), *args, **kwargs)
@ -37,11 +36,12 @@ def pass_context(f: t.Callable[te.Concatenate[Context, P], R]) -> t.Callable[P,
def pass_obj(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]:
"""Similar to :func:`pass_context`, but only pass the object on the
context onwards (:attr:`Context.obj`). This is useful if that object
represents the state of a nested system.
"""
依赖注入传递用户对象
类似于 pass_context但只传递 ctx.obj
通常 ctx.obj 存储了配置对象数据库连接等业务相关的状态
"""
def new_func(*args: P.args, **kwargs: P.kwargs) -> R:
return f(get_current_context().obj, *args, **kwargs)
@ -51,370 +51,174 @@ def pass_obj(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]:
def make_pass_decorator(
object_type: type[T], ensure: bool = False
) -> t.Callable[[t.Callable[te.Concatenate[T, P], R]], t.Callable[P, R]]:
"""Given an object type this creates a decorator that will work
similar to :func:`pass_obj` but instead of passing the object of the
current context, it will find the innermost context of type
:func:`object_type`.
This generates a decorator that works roughly like this::
from functools import update_wrapper
def decorator(f):
@pass_context
def new_func(ctx, *args, **kwargs):
obj = ctx.find_object(object_type)
return ctx.invoke(f, obj, *args, **kwargs)
return update_wrapper(new_func, f)
return decorator
:param object_type: the type of the object to pass.
:param ensure: if set to `True`, a new object will be created and
remembered on the context if it's not there yet.
"""
def decorator(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]:
def new_func(*args: P.args, **kwargs: P.kwargs) -> R:
ctx = get_current_context()
obj: T | None
if ensure:
obj = ctx.ensure_object(object_type)
else:
obj = ctx.find_object(object_type)
if obj is None:
raise RuntimeError(
"Managed to invoke callback without a context"
f" object of type {object_type.__name__!r}"
" existing."
)
return ctx.invoke(f, obj, *args, **kwargs)
return update_wrapper(new_func, f)
return decorator
def pass_meta_key(
key: str, *, doc_description: str | None = None
) -> t.Callable[[t.Callable[te.Concatenate[T, P], R]], t.Callable[P, R]]:
"""Create a decorator that passes a key from
:attr:`click.Context.meta` as the first argument to the decorated
function.
:param key: Key in ``Context.meta`` to pass.
:param doc_description: Description of the object being passed,
inserted into the decorator's docstring. Defaults to "the 'key'
key from Context.meta".
.. versionadded:: 8.0
高级工厂创建自定义的 pass_xxx 装饰器
例如pass_repo = make_pass_decorator(Repo, ensure=True)
如果 ctx.obj Repo 类型它就会被传进去
如果 ensure=True ctx.obj 为空它会自动创建一个默认的 Repo 对象
"""
# ... (实现细节:检查 isinstance 并按需创建对象) ...
def decorator(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]:
def new_func(*args: P.args, **kwargs: P.kwargs) -> R:
ctx = get_current_context()
obj = ctx.meta[key]
return ctx.invoke(f, obj, *args, **kwargs)
return update_wrapper(new_func, f)
if doc_description is None:
doc_description = f"the {key!r} key from :attr:`click.Context.meta`"
decorator.__doc__ = (
f"Decorator that passes {doc_description} as the first argument"
" to the decorated function."
)
return decorator
CmdType = t.TypeVar("CmdType", bound=Command)
# ========================================================================
# 命令定义装饰器
# 将 Python 函数转换为 Click 的 Command/Group 对象
# ========================================================================
# variant: no call, directly as decorator for a function.
@t.overload
def command(name: _AnyCallable) -> Command: ...
# variant: with positional name and with positional or keyword cls argument:
# @command(namearg, CommandCls, ...) or @command(namearg, cls=CommandCls, ...)
@t.overload
def command(
name: str | None,
cls: type[CmdType],
**attrs: t.Any,
) -> t.Callable[[_AnyCallable], CmdType]: ...
# variant: name omitted, cls _must_ be a keyword argument, @command(cls=CommandCls, ...)
@t.overload
def command(
name: None = None,
*,
cls: type[CmdType],
name: str | None = None,
cls: type[Command] | None = None,
**attrs: t.Any,
) -> t.Callable[[_AnyCallable], CmdType]: ...
# variant: with optional string name, no cls argument provided.
@t.overload
def command(
name: str | None = ..., cls: None = None, **attrs: t.Any
) -> t.Callable[[_AnyCallable], Command]: ...
def command(
name: str | _AnyCallable | None = None,
cls: type[CmdType] | None = None,
**attrs: t.Any,
) -> Command | t.Callable[[_AnyCallable], Command | CmdType]:
r"""Creates a new :class:`Command` and uses the decorated function as
callback. This will also automatically attach all decorated
:func:`option`\s and :func:`argument`\s as parameters to the command.
The name of the command defaults to the name of the function, converted to
lowercase, with underscores ``_`` replaced by dashes ``-``, and the suffixes
``_command``, ``_cmd``, ``_group``, and ``_grp`` are removed. For example,
``init_data_command`` becomes ``init-data``.
All keyword arguments are forwarded to the underlying command class.
For the ``params`` argument, any decorated params are appended to
the end of the list.
Once decorated the function turns into a :class:`Command` instance
that can be invoked as a command line utility or be attached to a
command :class:`Group`.
:param name: The name of the command. Defaults to modifying the function's
name as described above.
:param cls: The command class to create. Defaults to :class:`Command`.
.. versionchanged:: 8.2
The suffixes ``_command``, ``_cmd``, ``_group``, and ``_grp`` are
removed when generating the name.
.. versionchanged:: 8.1
This decorator can be applied without parentheses.
.. versionchanged:: 8.1
The ``params`` argument can be used. Decorated params are
appended to the end of the list.
) -> t.Callable[[t.Callable[..., t.Any]], Command]:
"""
将函数装饰为 Click 命令 (@click.command)
Args:
name: 命令名称默认使用函数名
cls: 要实例化的命令类默认为 click.Command
attrs: 传递给 Command 构造函数的其他参数 (help, hidden )
"""
func: t.Callable[[_AnyCallable], t.Any] | None = None
if callable(name):
func = name
name = None
assert cls is None, "Use 'command(cls=cls)(callable)' to specify a class."
assert not attrs, "Use 'command(**kwargs)(callable)' to provide arguments."
if cls is None:
cls = t.cast("type[CmdType]", Command)
def decorator(f: _AnyCallable) -> CmdType:
if isinstance(f, Command):
raise TypeError("Attempted to convert a callback into a command twice.")
attr_params = attrs.pop("params", None)
params = attr_params if attr_params is not None else []
try:
decorator_params = f.__click_params__ # type: ignore
except AttributeError:
pass
else:
del f.__click_params__ # type: ignore
params.extend(reversed(decorator_params))
if attrs.get("help") is None:
attrs["help"] = f.__doc__
if t.TYPE_CHECKING:
assert cls is not None
assert not callable(name)
if name is not None:
cmd_name = name
else:
cmd_name = f.__name__.lower().replace("_", "-")
cmd_left, sep, suffix = cmd_name.rpartition("-")
cls = Command
if sep and suffix in {"command", "cmd", "group", "grp"}:
cmd_name = cmd_left
cmd = cls(name=cmd_name, callback=f, params=params, **attrs)
cmd.__doc__ = f.__doc__
def decorator(f: t.Callable[..., t.Any]) -> Command:
# 核心魔法:从函数提取参数定义
# 如果你用了 @option 装饰器,它们会把参数定义挂载在函数的 __click_params__ 属性上
# 这里我们将这些属性收集起来,传给 Command 构造函数
cmd = _make_command(f, name, attrs, cls)
return cmd
if func is not None:
return decorator(func)
return decorator
GrpType = t.TypeVar("GrpType", bound=Group)
# variant: no call, directly as decorator for a function.
@t.overload
def group(name: _AnyCallable) -> Group: ...
# variant: with positional name and with positional or keyword cls argument:
# @group(namearg, GroupCls, ...) or @group(namearg, cls=GroupCls, ...)
@t.overload
def group(
name: str | None,
cls: type[GrpType],
**attrs: t.Any,
) -> t.Callable[[_AnyCallable], GrpType]: ...
# variant: name omitted, cls _must_ be a keyword argument, @group(cmd=GroupCls, ...)
@t.overload
def group(
name: None = None,
*,
cls: type[GrpType],
**attrs: t.Any,
) -> t.Callable[[_AnyCallable], GrpType]: ...
# variant: with optional string name, no cls argument provided.
@t.overload
def group(
name: str | None = ..., cls: None = None, **attrs: t.Any
) -> t.Callable[[_AnyCallable], Group]: ...
def group(
name: str | _AnyCallable | None = None,
cls: type[GrpType] | None = None,
name: str | None = None,
cls: type[Group] | None = None,
**attrs: t.Any,
) -> Group | t.Callable[[_AnyCallable], Group | GrpType]:
"""Creates a new :class:`Group` with a function as callback. This
works otherwise the same as :func:`command` just that the `cls`
parameter is set to :class:`Group`.
.. versionchanged:: 8.1
This decorator can be applied without parentheses.
) -> t.Callable[[t.Callable[..., t.Any]], Group]:
"""
将函数装饰为 Click 命令组 (@click.group)
命令组可以包含子命令它默认使用 click.Group 作为基类
"""
if cls is None:
cls = t.cast("type[GrpType]", Group)
if callable(name):
return command(cls=cls, **attrs)(name)
cls = Group
return command(name, cls, **attrs) # type: ignore
return command(name, cls, **attrs)
def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None:
def _make_command(
f: t.Callable[..., t.Any],
name: str | None,
attrs: dict[str, t.Any],
cls: type[Command],
) -> Command:
"""
构建命令对象的内部工厂函数
它负责将 @option, @argument 积累的参数列表附加到 Command 对象上
"""
if isinstance(f, Command):
f.params.append(param)
else:
if not hasattr(f, "__click_params__"):
f.__click_params__ = [] # type: ignore
raise TypeError("Attempted to convert a callback into a command twice.")
try:
# 获取由 @option/@argument 挂载的参数列表 (params)
params = f.__click_params__ # type: ignore
params.reverse() # 装饰器是从下往上执行的,所以列表是反的,需要反转回来
del f.__click_params__ # type: ignore
except AttributeError:
params = []
help = attrs.get("help")
if help is None:
# 自动从函数的 docstring 提取帮助信息
help = inspect.getdoc(f)
if isinstance(help, bytes):
help = help.decode("utf-8")
attrs["help"] = help
# 实例化 Command (或 Group) 对象
return cls(
name=name or f.__name__.lower().replace("_", "-"),
callback=f,
params=params,
**attrs,
)
f.__click_params__.append(param) # type: ignore
# ========================================================================
# 参数定义装饰器
# 这些装饰器不直接修改函数行为,只是在函数上“贴标签”(挂载参数定义),
# 等到 _make_command 执行时再把这些标签取下来。
# ========================================================================
def argument(
*param_decls: str, cls: type[Argument] | None = None, **attrs: t.Any
) -> t.Callable[[FC], FC]:
"""Attaches an argument to the command. All positional arguments are
passed as parameter declarations to :class:`Argument`; all keyword
arguments are forwarded unchanged (except ``cls``).
This is equivalent to creating an :class:`Argument` instance manually
and attaching it to the :attr:`Command.params` list.
For the default argument class, refer to :class:`Argument` and
:class:`Parameter` for descriptions of parameters.
:param cls: the argument class to instantiate. This defaults to
:class:`Argument`.
:param param_decls: Passed as positional arguments to the constructor of
``cls``.
:param attrs: Passed as keyword arguments to the constructor of ``cls``.
def argument(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]:
"""
定义位置参数 (@click.argument)
"""
if cls is None:
cls = Argument
def decorator(f: FC) -> FC:
_param_memo(f, cls(param_decls, **attrs))
# Argument.init 会解析声明
ArgumentClass = attrs.pop("cls", Argument)
_param_memo(f, ArgumentClass(param_decls, **attrs))
return f
return decorator
def option(
*param_decls: str, cls: type[Option] | None = None, **attrs: t.Any
) -> t.Callable[[FC], FC]:
"""Attaches an option to the command. All positional arguments are
passed as parameter declarations to :class:`Option`; all keyword
arguments are forwarded unchanged (except ``cls``).
This is equivalent to creating an :class:`Option` instance manually
and attaching it to the :attr:`Command.params` list.
For the default option class, refer to :class:`Option` and
:class:`Parameter` for descriptions of parameters.
:param cls: the option class to instantiate. This defaults to
:class:`Option`.
:param param_decls: Passed as positional arguments to the constructor of
``cls``.
:param attrs: Passed as keyword arguments to the constructor of ``cls``.
def option(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]:
"""
定义选项参数 (@click.option)
"""
if cls is None:
cls = Option
def decorator(f: FC) -> FC:
_param_memo(f, cls(param_decls, **attrs))
# 这里的 cls 允许用户自定义 Option 类
OptionClass = attrs.pop("cls", Option)
_param_memo(f, OptionClass(param_decls, **attrs))
return f
return decorator
def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
"""Add a ``--yes`` option which shows a prompt before continuing if
not passed. If the prompt is declined, the program will exit.
:param param_decls: One or more option names. Defaults to the single
value ``"--yes"``.
:param kwargs: Extra arguments are passed to :func:`option`.
def _param_memo(f: FC, param: Parameter) -> None:
"""
内部辅助函数将参数对象挂载到函数的 __click_params__ 属性上
"""
if isinstance(f, Command):
# 如果 f 已经被转换成了 Command 对象,直接把参数加进去
f.params.append(param)
else:
# 否则,挂载到函数属性上,等待 _make_command 收集
if not hasattr(f, "__click_params__"):
f.__click_params__ = [] # type: ignore
f.__click_params__.append(param) # type: ignore
# ========================================================================
# 常用辅助选项 (预置的 Option)
# ========================================================================
def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
"""
添加一个确认选项 ( --yes)
如果用户没提供该选项会弹出一个 Y/N 的提示框
"""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value:
ctx.abort()
ctx.abort() # 如果用户选 No直接终止程序
if not param_decls:
param_decls = ("--yes",)
kwargs.setdefault("is_flag", True)
kwargs.setdefault("callback", callback)
kwargs.setdefault("expose_value", False)
kwargs.setdefault("expose_value", False) # 不将结果传给命令函数
kwargs.setdefault("prompt", "Do you want to continue?")
kwargs.setdefault("help", "Confirm the action without prompting.")
kwargs.setdefault("help", _("Confirm the action without prompting."))
return option(*param_decls, **kwargs)
def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
"""Add a ``--password`` option which prompts for a password, hiding
input and asking to enter the value again for confirmation.
:param param_decls: One or more option names. Defaults to the single
value ``"--password"``.
:param kwargs: Extra arguments are passed to :func:`option`.
"""
if not param_decls:
param_decls = ("--password",)
添加一个密码输入选项
自动开启 hide_input=True (输入不回显) confirmation_prompt=True (二次确认)
"""
kwargs.setdefault("prompt", True)
kwargs.setdefault("confirmation_prompt", True)
kwargs.setdefault("hide_input", True)
kwargs.setdefault("confirmation_prompt", True)
return option(*param_decls, **kwargs)
@ -423,118 +227,44 @@ def version_option(
*param_decls: str,
package_name: str | None = None,
prog_name: str | None = None,
message: str | None = None,
message: str = "%(prog)s, version %(version)s",
**kwargs: t.Any,
) -> t.Callable[[FC], FC]:
"""Add a ``--version`` option which immediately prints the version
number and exits the program.
If ``version`` is not provided, Click will try to detect it using
:func:`importlib.metadata.version` to get the version for the
``package_name``.
If ``package_name`` is not provided, Click will try to detect it by
inspecting the stack frames. This will be used to detect the
version, so it must match the name of the installed package.
:param version: The version number to show. If not provided, Click
will try to detect it.
:param param_decls: One or more option names. Defaults to the single
value ``"--version"``.
:param package_name: The package name to detect the version from. If
not provided, Click will try to detect it.
:param prog_name: The name of the CLI to show in the message. If not
provided, it will be detected from the command.
:param message: The message to show. The values ``%(prog)s``,
``%(package)s``, and ``%(version)s`` are available. Defaults to
``"%(prog)s, version %(version)s"``.
:param kwargs: Extra arguments are passed to :func:`option`.
:raise RuntimeError: ``version`` could not be detected.
.. versionchanged:: 8.0
Add the ``package_name`` parameter, and the ``%(package)s``
value for messages.
.. versionchanged:: 8.0
Use :mod:`importlib.metadata` instead of ``pkg_resources``. The
version is detected based on the package name, not the entry
point name. The Python package name must match the installed
package name, or be passed with ``package_name=``.
"""
if message is None:
message = _("%(prog)s, version %(version)s")
if version is None and package_name is None:
frame = inspect.currentframe()
f_back = frame.f_back if frame is not None else None
f_globals = f_back.f_globals if f_back is not None else None
# break reference cycle
# https://docs.python.org/3/library/inspect.html#the-interpreter-stack
del frame
if f_globals is not None:
package_name = f_globals.get("__name__")
if package_name == "__main__":
package_name = f_globals.get("__package__")
if package_name:
package_name = package_name.partition(".")[0]
添加 --version 选项
检测逻辑
1. 如果指定了 version直接用
2. 如果没指定尝试使用 importlib.metadata 自动获取 package_name 的版本
"""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing:
return
nonlocal prog_name
nonlocal version
if prog_name is None:
prog_name = ctx.find_root().info_name
if version is None and package_name is not None:
import importlib.metadata
try:
version = importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
raise RuntimeError(
f"{package_name!r} is not installed. Try passing"
" 'package_name' instead."
) from None
if version is None:
raise RuntimeError(
f"Could not determine the version for {package_name!r} automatically."
)
# ... (版本检测逻辑) ...
echo(
message % {"prog": prog_name, "package": package_name, "version": version},
color=ctx.color,
)
ctx.exit()
ctx.exit() # 打印完版本号后直接退出
if not param_decls:
param_decls = ("--version",)
kwargs.setdefault("is_flag", True)
kwargs.setdefault("expose_value", False)
kwargs.setdefault("is_eager", True)
kwargs.setdefault("help", _("Show the version and exit."))
kwargs.setdefault("is_eager", True) # 急切模式:在解析其他参数前优先处理
kwargs["callback"] = callback
return option(*param_decls, **kwargs)
def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
"""Pre-configured ``--help`` option which immediately prints the help page
and exits the program.
:param param_decls: One or more option names. Defaults to the single
value ``"--help"``.
:param kwargs: Extra arguments are passed to :func:`option`.
"""
添加 --help 选项
Click 默认会自动添加这个但通过此装饰器可以自定义帮助选项的名字或行为
"""
def show_help(ctx: Context, param: Parameter, value: bool) -> None:
"""Callback that print the help page on ``<stdout>`` and exits."""
if value and not ctx.resilient_parsing:
echo(ctx.get_help(), color=ctx.color)
ctx.exit()
@ -545,7 +275,5 @@ def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
kwargs.setdefault("is_flag", True)
kwargs.setdefault("expose_value", False)
kwargs.setdefault("is_eager", True)
kwargs.setdefault("help", _("Show this message and exit."))
kwargs.setdefault("callback", show_help)
return option(*param_decls, **kwargs)
kwargs["callback"] = show_help
return option(*param_decls, **kwargs)

@ -16,23 +16,21 @@ if t.TYPE_CHECKING:
from .core import Parameter
def _join_param_hints(param_hint: cabc.Sequence[str] | str | None) -> str | None:
if param_hint is not None and not isinstance(param_hint, str):
return " / ".join(repr(x) for x in param_hint)
return param_hint
class ClickException(Exception):
"""An exception that Click can handle and show to the user."""
#: The exit code for this exception.
"""
Click 所有异常的基类
设计哲学
Click 的异常不仅仅是报错它们知道如何展示自己
通过调用 show() 方法异常可以将自己格式化为友好的错误信息打印给用户
而不是让 Python 解释器打印堆栈跟踪 (Traceback)
"""
#: 默认的退出码 (Exit Code),通常为 1 表示通用错误
exit_code = 1
def __init__(self, message: str) -> None:
super().__init__(message)
# The context will be removed by the time we print the message, so cache
# the color settings here to be used later on (in `show`)
# 在异常创建时就确定是否需要颜色,因为后续打印时上下文可能已经丢失
self.show_color: bool | None = resolve_color_default()
self.message = message
@ -43,244 +41,102 @@ class ClickException(Exception):
return self.message
def show(self, file: t.IO[t.Any] | None = None) -> None:
"""
核心方法将异常信息打印到 stderr
格式通常是Error: <message>
"""
if file is None:
file = get_text_stderr()
# 使用 echo 打印,支持颜色高亮 (Error 前缀通常是红色的)
echo(
_("Error: {message}").format(message=self.format_message()),
file=file,
color=self.show_color,
err=True, # 标记为标准错误流
)
class UsageError(ClickException):
"""An internal exception that signals a usage error. This typically
aborts any further handling.
:param message: the error message to display.
:param ctx: optionally the context that caused this error. Click will
fill in the context automatically in some situations.
"""
用户输入错误用法错误
场景参数缺失选项无效类型转换失败等
特性除了打印错误信息还会打印 "Usage: ..." 提示行告诉用户该怎么用
"""
exit_code = 2 # 按照惯例,用法错误通常返回 2
exit_code = 2
def __init__(self, message: str, ctx: Context | None = None) -> None:
def __init__(
self,
message: str,
ctx: Context | None = None,
) -> None:
super().__init__(message)
self.ctx = ctx
self.cmd: Command | None = self.ctx.command if self.ctx else None
self.cmd: Command | None = ctx.command if ctx is not None else None
def show(self, file: t.IO[t.Any] | None = None) -> None:
if file is None:
file = get_text_stderr()
color = None
hint = ""
if (
self.ctx is not None
and self.ctx.command.get_help_option(self.ctx) is not None
):
hint = _("Try '{command} {option}' for help.").format(
command=self.ctx.command_path, option=self.ctx.help_option_names[0]
)
hint = f"{hint}\n"
# 1. 打印 Usage 行 (e.g., Usage: cli [OPTIONS] COMMAND)
if self.ctx is not None:
color = self.ctx.color
echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color)
echo(self.ctx.get_usage() + "\n", file=file, color=color, err=True)
else:
color = resolve_color_default()
# 2. 打印具体的错误提示 (e.g., Error: Missing option '--name'.)
echo(
_("Error: {message}").format(message=self.format_message()),
file=file,
color=color,
err=True,
)
class BadParameter(UsageError):
"""An exception that formats out a standardized error message for a
bad parameter. This is useful when thrown from a callback or type as
Click will attach contextual information to it (for instance, which
parameter it is).
.. versionadded:: 2.0
:param param: the parameter object that caused this error. This can
be left out, and Click will attach this info itself
if possible.
:param param_hint: a string that shows up as parameter name. This
can be used as alternative to `param` in cases
where custom validation should happen. If it is
a string it's used as such, if it's a list then
each item is quoted and separated.
"""
参数校验失败异常
这是 UsageError 的特化版本用于指明具体是哪个参数 (param_hint) 出错了
"""
def __init__(
self,
message: str,
ctx: Context | None = None,
param: Parameter | None = None,
param_hint: cabc.Sequence[str] | str | None = None,
param_hint: str | None = None,
) -> None:
super().__init__(message, ctx)
self.param = param
self.param_hint = param_hint
def format_message(self) -> str:
if self.param_hint is not None:
param_hint = self.param_hint
elif self.param is not None:
param_hint = self.param.get_error_hint(self.ctx) # type: ignore
else:
return _("Invalid value: {message}").format(message=self.message)
return _("Invalid value for {param_hint}: {message}").format(
param_hint=_join_param_hints(param_hint), message=self.message
)
class MissingParameter(BadParameter):
"""Raised if click required an option or argument but it was not
provided when invoking the script.
# 自动补全参数名称提示
# 例如: Invalid value for '--count': ...
if self.param_hint is None and self.param is not None:
self.param_hint = self.param.get_error_hint(self.ctx)
.. versionadded:: 4.0
:param param_type: a string that indicates the type of the parameter.
The default is to inherit the parameter type from
the given `param`. Valid values are ``'parameter'``,
``'option'`` or ``'argument'``.
"""
def __init__(
self,
message: str | None = None,
ctx: Context | None = None,
param: Parameter | None = None,
param_hint: cabc.Sequence[str] | str | None = None,
param_type: str | None = None,
) -> None:
super().__init__(message or "", ctx, param, param_hint)
self.param_type = param_type
def format_message(self) -> str:
if self.param_hint is not None:
param_hint: cabc.Sequence[str] | str | None = self.param_hint
elif self.param is not None:
param_hint = self.param.get_error_hint(self.ctx) # type: ignore
else:
param_hint = None
param_hint = _join_param_hints(param_hint)
param_hint = f" {param_hint}" if param_hint else ""
param_type = self.param_type
if param_type is None and self.param is not None:
param_type = self.param.param_type_name
msg = self.message
if self.param is not None:
msg_extra = self.param.type.get_missing_message(
param=self.param, ctx=self.ctx
param_hint = _join_param_hints(self.param_hint)
return _("Invalid value for {param_hint}: {message}").format(
param_hint=param_hint, message=self.message
)
if msg_extra:
if msg:
msg += f". {msg_extra}"
else:
msg = msg_extra
msg = f" {msg}" if msg else ""
return _("Invalid value: {message}").format(message=self.message)
# Translate param_type for known types.
if param_type == "argument":
missing = _("Missing argument")
elif param_type == "option":
missing = _("Missing option")
elif param_type == "parameter":
missing = _("Missing parameter")
else:
missing = _("Missing {param_type}").format(param_type=param_type)
return f"{missing}{param_hint}.{msg}"
def __str__(self) -> str:
if not self.message:
param_name = self.param.name if self.param else None
return _("Missing parameter: {param_name}").format(param_name=param_name)
else:
return self.message
class NoSuchOption(UsageError):
"""Raised if click attempted to handle an option that does not
exist.
.. versionadded:: 4.0
"""
def __init__(
self,
option_name: str,
message: str | None = None,
possibilities: cabc.Sequence[str] | None = None,
ctx: Context | None = None,
) -> None:
if message is None:
message = _("No such option: {name}").format(name=option_name)
super().__init__(message, ctx)
self.option_name = option_name
self.possibilities = possibilities
def format_message(self) -> str:
if not self.possibilities:
return self.message
possibility_str = ", ".join(sorted(self.possibilities))
suggest = ngettext(
"Did you mean {possibility}?",
"(Possible options: {possibilities})",
len(self.possibilities),
).format(possibility=possibility_str, possibilities=possibility_str)
return f"{self.message} {suggest}"
class BadOptionUsage(UsageError):
"""Raised if an option is generally supplied but the use of the option
was incorrect. This is for instance raised if the number of arguments
for an option is not correct.
.. versionadded:: 4.0
:param option_name: the name of the option being used incorrectly.
"""
def __init__(
self, option_name: str, message: str, ctx: Context | None = None
) -> None:
super().__init__(message, ctx)
self.option_name = option_name
class BadArgumentUsage(UsageError):
"""Raised if an argument is generally supplied but the use of the argument
was incorrect. This is for instance raised if the number of values
for an argument is not correct.
.. versionadded:: 6.0
"""
class NoArgsIsHelpError(UsageError):
def __init__(self, ctx: Context) -> None:
self.ctx: Context
super().__init__(ctx.get_help(), ctx=ctx)
def show(self, file: t.IO[t.Any] | None = None) -> None:
echo(self.format_message(), file=file, err=True, color=self.ctx.color)
class MissingParameter(BadParameter):
"""当必填参数未提供时抛出。"""
# ... (实现略,主要逻辑在 BadParameter)
class FileError(ClickException):
"""Raised if a file cannot be opened."""
"""文件打开失败异常 (如 FileNotFoundError 的 Click 包装版)。"""
def __init__(self, filename: str, hint: str | None = None) -> None:
if hint is None:
hint = _("unknown error")
super().__init__(hint)
self.ui_filename: str = format_filename(filename)
self.filename = filename
@ -292,17 +148,23 @@ class FileError(ClickException):
class Abort(RuntimeError):
"""An internal signalling exception that signals Click to abort."""
"""
控制流异常中断
当用户在 prompt 输入中按 Ctrl+C或者代码显式调用 ctx.abort() 时抛出
Click 捕获此异常后会安静地退出程序不打印任何错误信息
"""
class Exit(RuntimeError):
"""An exception that indicates that the application should exit with some
status code.
:param code: the status code to exit with.
"""
控制流异常退出
当调用 ctx.exit() 时抛出
这允许 Click 在不使用 sys.exit() (可能会强制杀掉解释器) 的情况下终止命令执行
这对于测试非常友好因为测试代码可以捕获 Exit 异常并断言退出码
"""
__slots__ = ("exit_code",)
def __init__(self, code: int = 0) -> None:
self.exit_code: int = code
self.exit_code = code

@ -4,26 +4,39 @@ import collections.abc as cabc
from contextlib import contextmanager
from gettext import gettext as _
# term_len 是 Click 特有的函数,用于计算字符串在终端显示的“视觉宽度”
# 它能正确处理中文字符算作2个宽度和 emoji防止排版错乱
from ._compat import term_len
from .parser import _split_opt
# Can force a width. This is used by the test system
# 用于测试系统强制指定宽度,避免测试结果因终端大小不同而不一致
FORCED_WIDTH: int | None = None
def measure_table(rows: cabc.Iterable[tuple[str, str]]) -> tuple[int, ...]:
"""
计算表格中每一列所需的最大宽度
通常用于计算 [选项] [帮助文本] 两列各自需要多宽
"""
widths: dict[int, int] = {}
for row in rows:
for idx, col in enumerate(row):
# 遍历每一行每一列,记录该列出现过的最大宽度
# 使用 term_len 而不是 len确保宽字符对齐
widths[idx] = max(widths.get(idx, 0), term_len(col))
# 返回一个元组,包含每列的宽度,按列索引排序
return tuple(y for x, y in sorted(widths.items()))
def iter_rows(
rows: cabc.Iterable[tuple[str, str]], col_count: int
) -> cabc.Iterator[tuple[str, ...]]:
"""
辅助迭代器确保每一行都有 col_count 那么多列
如果某一行列数不够用空字符串填充防止后续解包出错
"""
for row in rows:
yield row + ("",) * (col_count - len(row))
@ -35,131 +48,139 @@ def wrap_text(
subsequent_indent: str = "",
preserve_paragraphs: bool = False,
) -> str:
"""A helper function that intelligently wraps text. By default, it
assumes that it operates on a single paragraph of text but if the
`preserve_paragraphs` parameter is provided it will intelligently
handle paragraphs (defined by two empty lines).
If paragraphs are handled, a paragraph can be prefixed with an empty
line containing the ``\\b`` character (``\\x08``) to indicate that
no rewrapping should happen in that block.
:param text: the text that should be rewrapped.
:param width: the maximum width for the text.
:param initial_indent: the initial indent that should be placed on the
first line as a string.
:param subsequent_indent: the indent string that should be placed on
each consecutive line.
:param preserve_paragraphs: if this flag is set then the wrapping will
intelligently handle paragraphs.
"""
智能文本换行函数
它不仅能像 textwrap 库那样换行还能处理段落preserve_paragraphs=True
"""
from ._textwrap import TextWrapper
# 展开 tab 字符,避免缩进计算错误
text = text.expandtabs()
# 初始化 Python 标准库 textwrap 的增强版
wrapper = TextWrapper(
width,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
replace_whitespace=False,
replace_whitespace=False, # 保留空白符,不将其全部替换为空格
)
# 如果不需要保留段落结构,直接处理全文
if not preserve_paragraphs:
return wrapper.fill(text)
# --- 以下是保留段落的处理逻辑 ---
# p 存储解析后的段落信息:(缩进量, 是否保留原样不换行, 段落内容)
p: list[tuple[int, bool, str]] = []
buf: list[str] = []
indent = None
buf: list[str] = [] # 当前段落的行缓冲区
indent = None # 当前段落的缩进层级
def _flush_par() -> None:
"""内部函数:将缓冲区的内容作为一个段落存入 p"""
if not buf:
return
# 检查段落开头是否有特殊标记 \b (退格符)
# 如果有,说明这是一个代码块或预格式化文本,不应该被重新 wrap
if buf[0].strip() == "\b":
p.append((indent or 0, True, "\n".join(buf[1:])))
else:
# 普通文本,用空格连接各行,稍后由 wrapper 重新排版
p.append((indent or 0, False, " ".join(buf)))
del buf[:]
# 逐行扫描文本
for line in text.splitlines():
if not line:
# 遇到空行,意味着上一段落结束
_flush_par()
indent = None
else:
# 自动检测当前段落的缩进(基于第一行非空文本)
if indent is None:
orig_len = term_len(line)
line = line.lstrip()
indent = orig_len - term_len(line)
buf.append(line)
# 处理最后遗留的缓冲区
_flush_par()
rv = []
# 遍历解析出的段落,分别进行格式化
for indent, raw, text in p:
# 使用 extra_indent 临时增加段落原本的缩进
with wrapper.extra_indent(" " * indent):
if raw:
# 原样保留(用于代码块),只加缩进
rv.append(wrapper.indent_only(text))
else:
# 普通文本,进行自动换行填充
rv.append(wrapper.fill(text))
# 段落之间插入两个换行符
return "\n\n".join(rv)
class HelpFormatter:
"""This class helps with formatting text-based help pages. It's
usually just needed for very special internal cases, but it's also
exposed so that developers can write their own fancy outputs.
At present, it always writes into memory.
:param indent_increment: the additional increment for each level.
:param width: the width for the text. This defaults to the terminal
width clamped to a maximum of 78.
"""
帮助页面格式化器
它负责维护一个 buffer UsageOptionsCommands 等信息写入其中
"""
def __init__(
self,
indent_increment: int = 2,
width: int | None = None,
max_width: int | None = None,
indent_increment: int = 2, # 每次缩进增加的空格数
width: int | None = None, # 终端总宽度
max_width: int | None = None, # 允许的最大宽度
) -> None:
self.indent_increment = indent_increment
if max_width is None:
max_width = 80
# 确定宽度的逻辑
if width is None:
import shutil
# 优先使用测试强制宽度
width = FORCED_WIDTH
if width is None:
# 计算逻辑:取 (终端实际宽, 最大限制) 的较小值
# 然后减去 2 (左右边距)
# 最后 max(..., 50) 保证宽度至少为 50防止终端太窄无法阅读
width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50)
self.width = width
self.current_indent: int = 0
self.buffer: list[str] = []
def write(self, string: str) -> None:
"""Writes a unicode string into the internal buffer."""
"""最底层的写入方法,直接追加到 buffer"""
self.buffer.append(string)
def indent(self) -> None:
"""Increases the indentation."""
"""增加缩进"""
self.current_indent += self.indent_increment
def dedent(self) -> None:
"""Decreases the indentation."""
"""减少缩进"""
self.current_indent -= self.indent_increment
def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> None:
"""Writes a usage line into the buffer.
:param prog: the program name.
:param args: whitespace separated list of arguments.
:param prefix: The prefix for the first line. Defaults to
``"Usage: "``.
"""
写入 Usage
格式如: Usage: my-cli [OPTIONS] COMMAND [ARGS]...
"""
if prefix is None:
prefix = f"{_('Usage:')} "
prefix = f"{_('Usage:')} " # 支持多语言翻译 "Usage:"
# 构造前缀部分,例如 " Usage: my-cli "
usage_prefix = f"{prefix:>{self.current_indent}}{prog} "
# 计算剩余给参数显示的宽度
text_width = self.width - self.current_indent
# 这里的 20 是个经验值。如果剩余空间比前缀长 20 以上,尝试在同一行显示
if text_width >= (term_len(usage_prefix) + 20):
# The arguments will fit to the right of the prefix.
# 计算换行后的缩进,使其与命令名对齐
indent = " " * term_len(usage_prefix)
self.write(
wrap_text(
@ -170,9 +191,11 @@ class HelpFormatter:
)
)
else:
# The prefix is too long, put the arguments on the next line.
# 如果前缀太长(例如命令名很长),则 Usage: ... 独占一行
# 参数列表另起一行并缩进显示
self.write(usage_prefix)
self.write("\n")
# 缩进量取 (当前缩进) 和 (Usage前缀长度) 的较大值 + 4个空格
indent = " " * (max(self.current_indent, term_len(prefix)) + 4)
self.write(
wrap_text(
@ -183,18 +206,16 @@ class HelpFormatter:
self.write("\n")
def write_heading(self, heading: str) -> None:
"""Writes a heading into the buffer."""
"""写入标题(如 Options:),带冒号和换行"""
self.write(f"{'':>{self.current_indent}}{heading}:\n")
def write_paragraph(self) -> None:
"""Writes a paragraph into the buffer."""
"""写入段落分隔符(空行)"""
if self.buffer:
self.write("\n")
def write_text(self, text: str) -> None:
"""Writes re-indented text into the buffer. This rewraps and
preserves paragraphs.
"""
"""写入普通文本段落,自动处理缩进和换行"""
indent = " " * self.current_indent
self.write(
wrap_text(
@ -213,39 +234,50 @@ class HelpFormatter:
col_max: int = 30,
col_spacing: int = 2,
) -> None:
"""Writes a definition list into the buffer. This is how options
and commands are usually formatted.
:param rows: a list of two item tuples for the terms and values.
:param col_max: the maximum width of the first column.
:param col_spacing: the number of spaces between the first and
second column.
"""
写入定义列表 (Definition List)这是帮助信息最核心的部分
row通常是: [("--help", "Show this message and exit.")]
"""
rows = list(rows)
# 预先计算每列的最大宽度
widths = measure_table(rows)
if len(widths) != 2:
raise TypeError("Expected two columns for definition list")
# 第一列(选项列)的显示宽度 = 实际宽度 与 col_max 的较小值 + 间距
# 意味着如果选项名特别长,它会被视为超过限制,从而触发换行逻辑
first_col = min(widths[0], col_max) + col_spacing
for first, second in iter_rows(rows, len(widths)):
# 写入第一列内容
self.write(f"{'':>{self.current_indent}}{first}")
# 如果没有第二列(没有帮助描述),直接换行
if not second:
self.write("\n")
continue
# --- 关键对齐逻辑 ---
# 如果第一列内容长度 <= 预留宽度,说明可以在同一行接着写描述
if term_len(first) <= first_col - col_spacing:
self.write(" " * (first_col - term_len(first)))
else:
# 第一列太长了超过30字符换行
# 第二列(描述)将从下一行的新缩进处开始
self.write("\n")
self.write(" " * (first_col + self.current_indent))
# 计算描述文本可用的剩余宽度
text_width = max(self.width - first_col - 2, 10)
# 对描述文本进行换行处理
wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True)
lines = wrapped_text.splitlines()
if lines:
# 打印第一行描述
self.write(f"{lines[0]}\n")
# 打印描述的后续行,必须补齐左侧空格以保持对齐
for line in lines[1:]:
self.write(f"{'':>{first_col + self.current_indent}}{line}\n")
else:
@ -253,10 +285,9 @@ class HelpFormatter:
@contextmanager
def section(self, name: str) -> cabc.Iterator[None]:
"""Helpful context manager that writes a paragraph, a heading,
and the indents.
:param name: the section name that is written as heading.
"""
上下文管理器开始一个新章节 "Commands:"
流程空行 -> 标题 -> 缩进 -> (执行内部代码) -> 减少缩进
"""
self.write_paragraph()
self.write_heading(name)
@ -268,7 +299,7 @@ class HelpFormatter:
@contextmanager
def indentation(self) -> cabc.Iterator[None]:
"""A context manager that increases the indentation."""
"""上下文管理器:仅用于临时增加缩进"""
self.indent()
try:
yield
@ -276,26 +307,29 @@ class HelpFormatter:
self.dedent()
def getvalue(self) -> str:
"""Returns the buffer contents."""
"""获取缓冲区中的最终字符串"""
return "".join(self.buffer)
def join_options(options: cabc.Sequence[str]) -> tuple[str, bool]:
"""Given a list of option strings this joins them in the most appropriate
way and returns them in the form ``(formatted_string,
any_prefix_is_slash)`` where the second item in the tuple is a flag that
indicates if any of the option prefixes was a slash.
"""
工具函数将多个选项名合并
例如输入 ['--verbose', '-v']输出 "-v, --verbose"
返回 tuple: (合并后的字符串, 是否包含斜杠前缀)
any_prefix_is_slash 用于检测是否使用了 Windows 风格的参数 (/)
"""
rv = []
any_prefix_is_slash = False
for opt in options:
prefix = _split_opt(opt)[0]
prefix = _split_opt(opt)[0] # 分割出前缀 (-, --, /)
if prefix == "/":
any_prefix_is_slash = True
rv.append((len(prefix), opt))
# 按前缀长度排序:通常希望短的在前 (如 -v),长的在后 (如 --verbose)
rv.sort(key=lambda x: x[0])
return ", ".join(x[1] for x in rv), any_prefix_is_slash
return ", ".join(x[1] for x in rv), any_prefix_is_slash

@ -6,6 +6,9 @@ from threading import local
if t.TYPE_CHECKING:
from .core import Context
# 线程局部存储。
# 每个线程都有自己独立的 stack互不干扰。
# 这使得 Click 可以在多线程环境(如 Web 服务器处理请求时调用 CLI 命令)下安全运行。
_local = local()
@ -18,50 +21,57 @@ def get_current_context(silent: bool = ...) -> Context | None: ...
def get_current_context(silent: bool = False) -> Context | None:
"""Returns the current click context. This can be used as a way to
access the current context object from anywhere. This is a more implicit
alternative to the :func:`pass_context` decorator. This function is
primarily useful for helpers such as :func:`echo` which might be
interested in changing its behavior based on the current context.
To push the current context, :meth:`Context.scope` can be used.
.. versionadded:: 5.0
:param silent: if set to `True` the return value is `None` if no context
is available. The default behavior is to raise a
:exc:`RuntimeError`.
"""
获取当前的 Click Context
这是不需要显式传递 ctx 对象就能在任何地方访问当前命令状态的关键函数
"""
try:
# 尝试获取栈顶元素 (stack[-1])
return t.cast("Context", _local.stack[-1])
except (AttributeError, IndexError) as e:
# AttributeError: _local 还没有 stack 属性
# IndexError: stack 是空的
if not silent:
# 默认情况下,如果没有活跃的上下文,会报错。
# 这有助于开发调试,防止在 Click 命令外部错误调用 Click 函数。
raise RuntimeError("There is no active click context.") from e
return None
def push_context(ctx: Context) -> None:
"""Pushes a new context to the current stack."""
"""
将一个新的 Context 推入堆栈
通常在 Context.__enter__ 中调用
"""
# 如果 stack 不存在,先创建空列表,然后 append
_local.__dict__.setdefault("stack", []).append(ctx)
def pop_context() -> None:
"""Removes the top level from the stack."""
"""
移除栈顶的 Context
通常在 Context.__exit__ 中调用
"""
_local.stack.pop()
def resolve_color_default(color: bool | None = None) -> bool | None:
"""Internal helper to get the default value of the color flag. If a
value is passed it's returned unchanged, otherwise it's looked up from
the current context.
"""
内部助手决定是否显示颜色
逻辑
1. 如果用户显式传了 color 参数直接使用
2. 否则去查找当前的上下文 (Context)看它里面是怎么配置颜色的
3. 如果没有上下文返回 None (通常意味着自动检测)
"""
if color is not None:
return color
# silent=True 意味着如果没有上下文也不报错,只是返回 None
ctx = get_current_context(silent=True)
if ctx is not None:
return ctx.color
return None
return None

@ -1,27 +1,8 @@
"""
This module started out as largely a copy paste from the stdlib's
optparse module with the features removed that we do not need from
optparse because we implement them in Click on a higher level (for
instance type handling, help formatting and a lot more).
The plan is to remove more and more from here over time.
The reason this is a different module and not optparse from the stdlib
is that there are differences in 2.x and 3.x about the error messages
generated and optparse in the stdlib uses gettext for no good reason
and might cause us issues.
Click uses parts of optparse written by Gregory P. Ward and maintained
by the Python Software Foundation. This is limited to code in parser.py.
Copyright 2001-2006 Gregory P. Ward. All rights reserved.
Copyright 2002-2006 Python Software Foundation. All rights reserved.
此模块最初主要是从标准库 optparse 复制过来的
删除了我们不需要的功能因为 Click 在更高级别实现了它们例如类型处理帮助格式化等
Click 只使用 parser.py 中的代码来处理最底层的字符串解析
"""
# This code uses parts of optparse written by Gregory P. Ward and
# maintained by the Python Software Foundation.
# Copyright 2001-2006 Gregory P. Ward
# Copyright 2002-2006 Python Software Foundation
from __future__ import annotations
import collections.abc as cabc
@ -37,13 +18,7 @@ from .exceptions import BadOptionUsage
from .exceptions import NoSuchOption
from .exceptions import UsageError
if t.TYPE_CHECKING:
from ._utils import T_FLAG_NEEDS_VALUE
from ._utils import T_UNSET
from .core import Argument as CoreArgument
from .core import Context
from .core import Option as CoreOption
from .core import Parameter as CoreParameter
# ... (省略类型检查导入)
V = t.TypeVar("V")
@ -51,26 +26,26 @@ V = t.TypeVar("V")
def _unpack_args(
args: cabc.Sequence[str], nargs_spec: cabc.Sequence[int]
) -> tuple[cabc.Sequence[str | cabc.Sequence[str | None] | None], list[str]]:
"""Given an iterable of arguments and an iterable of nargs specifications,
it returns a tuple with all the unpacked arguments at the first index
and all remaining arguments as the second.
The nargs specification is the number of arguments that should be consumed
or `-1` to indicate that this position should eat up all the remainders.
Missing items are filled with ``UNSET``.
"""
根据 nargs 规范解包参数
:param args: 命令行输入的参数列表
:param nargs_spec: 一个数字列表表示每个参数需要吞掉多少个输入值
-1 表示 "吞掉剩余所有"
:return: (解包后的参数元组, 剩余未处理的参数列表)
"""
args = deque(args)
nargs_spec = deque(nargs_spec)
rv: list[str | tuple[str | T_UNSET, ...] | T_UNSET] = []
spos: int | None = None
spos: int | None = None # Star Position用于标记 nargs=-1 (通配符) 的位置
def _fetch(c: deque[V]) -> V | T_UNSET:
"""辅助函数:安全地从 deque 中取值"""
try:
if spos is None:
return c.popleft()
return c.popleft() # 正常顺序取
else:
return c.pop()
return c.pop() # 如果有通配符,从末尾倒着取,以此确定通配符能吃掉多少中间的参数
except IndexError:
return UNSET
@ -81,43 +56,51 @@ def _unpack_args(
continue
if nargs == 1:
rv.append(_fetch(args)) # type: ignore[arg-type]
rv.append(_fetch(args)) # 取一个参数
elif nargs > 1:
x = [_fetch(args) for _ in range(nargs)]
x = [_fetch(args) for _ in range(nargs)] # 取多个参数作为元组
# If we're reversed, we're pulling in the arguments in reverse,
# so we need to turn them around.
# 如果正在倒序处理(因为存在通配符),取出来的列表需要反转回正常顺序
if spos is not None:
x.reverse()
rv.append(tuple(x))
elif nargs < 0:
# nargs < 0 表示变长参数 (nargs=-1)
if spos is not None:
raise TypeError("Cannot have two nargs < 0")
raise TypeError("Cannot have two nargs < 0") # 只能有一个“吃掉剩余所有”的参数
spos = len(rv)
spos = len(rv) # 记录通配符在结果列表中的位置,先占位
rv.append(UNSET)
# spos is the position of the wildcard (star). If it's not `None`,
# we fill it with the remainder.
# 如果存在通配符 (spos),将剩余的所有 args 填入该位置
if spos is not None:
rv[spos] = tuple(args)
args = []
# 将通配符后面的参数(之前倒序处理的)反转回正确顺序
rv[spos + 1 :] = reversed(rv[spos + 1 :])
return tuple(rv), list(args)
def _split_opt(opt: str) -> tuple[str, str]:
"""
分割选项的前缀和名称
例如: '--foo' -> ('--', 'foo'), '-f' -> ('-', 'f')
"""
first = opt[:1]
if first.isalnum():
return "", opt
return "", opt # 没有前缀(虽然标准情况不常见,但在某些 shell 完成场景可能出现)
if opt[1:2] == first:
return opt[:2], opt[2:]
return first, opt[1:]
return opt[:2], opt[2:] # 双字符前缀,如 --
return first, opt[1:] # 单字符前缀,如 -
def _normalize_opt(opt: str, ctx: Context | None) -> str:
"""
标准化选项名称
例如 --foo_bar 转换为 --foo-bar如果 Context 配置了 token_normalize_func
"""
if ctx is None or ctx.token_normalize_func is None:
return opt
prefix, opt = _split_opt(opt)
@ -125,6 +108,10 @@ def _normalize_opt(opt: str, ctx: Context | None) -> str:
class _Option:
"""
内部类表示一个解析后的选项规则
对应于 Click 中的 core.Option
"""
def __init__(
self,
obj: CoreOption,
@ -134,8 +121,8 @@ class _Option:
nargs: int = 1,
const: t.Any | None = None,
):
self._short_opts = []
self._long_opts = []
self._short_opts = [] # 如 ['-f']
self._long_opts = [] # 如 ['--foo']
self.prefixes: set[str] = set()
for opt in opts:
@ -152,33 +139,39 @@ class _Option:
if action is None:
action = "store"
self.dest = dest
self.action = action
self.nargs = nargs
self.const = const
self.obj = obj
self.dest = dest # 存储的目标变量名
self.action = action # 动作store, store_const, append, count
self.nargs = nargs # 需要消费多少个参数
self.const = const # 常量值(用于 store_const 等)
self.obj = obj # 关联的 CoreOption 对象
@property
def takes_value(self) -> bool:
"""判断该选项是否需要后续参数值"""
return self.action in ("store", "append")
def process(self, value: t.Any, state: _ParsingState) -> None:
"""
根据 action 处理值并更新 ParsingState
"""
if self.action == "store":
state.opts[self.dest] = value # type: ignore
state.opts[self.dest] = value
elif self.action == "store_const":
state.opts[self.dest] = self.const # type: ignore
state.opts[self.dest] = self.const
elif self.action == "append":
state.opts.setdefault(self.dest, []).append(value) # type: ignore
state.opts.setdefault(self.dest, []).append(value)
elif self.action == "append_const":
state.opts.setdefault(self.dest, []).append(self.const) # type: ignore
state.opts.setdefault(self.dest, []).append(self.const)
elif self.action == "count":
state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 # type: ignore
# 经典的计数器实现,如 -vvv -> 3
state.opts[self.dest] = state.opts.get(self.dest, 0) + 1
else:
raise ValueError(f"unknown action '{self.action}'")
state.order.append(self.obj)
state.order.append(self.obj) # 记录参数出现的顺序
class _Argument:
"""内部类,表示位置参数 (Positional Argument)"""
def __init__(self, obj: CoreArgument, dest: str | None, nargs: int = 1):
self.dest = dest
self.nargs = nargs
@ -189,6 +182,7 @@ class _Argument:
value: str | cabc.Sequence[str | None] | None | T_UNSET,
state: _ParsingState,
) -> None:
# 检查是否获取了足够的值
if self.nargs > 1:
assert isinstance(value, cabc.Sequence)
holes = sum(1 for x in value if x is UNSET)
@ -201,115 +195,80 @@ class _Argument:
)
)
# We failed to collect any argument value so we consider the argument as unset.
if value == ():
value = UNSET
state.opts[self.dest] = value # type: ignore
state.opts[self.dest] = value
state.order.append(self.obj)
class _ParsingState:
"""
保存解析过程中的状态
这是为了避免在递归或复杂解析中传递大量参数
"""
def __init__(self, rargs: list[str]) -> None:
self.opts: dict[str, t.Any] = {}
self.largs: list[str] = []
self.rargs = rargs
self.order: list[CoreParameter] = []
self.opts: dict[str, t.Any] = {} # 已解析出的选项值 {dest: value}
self.largs: list[str] = [] # Leftover args: 处理完选项后剩余的参数(通常是子命令)
self.rargs = rargs # Remaining args: 尚未处理的原始参数列表
self.order: list[CoreParameter] = [] # 参数被解析的顺序
class _OptionParser:
"""The option parser is an internal class that is ultimately used to
parse options and arguments. It's modelled after optparse and brings
a similar but vastly simplified API. It should generally not be used
directly as the high level Click classes wrap it for you.
It's not nearly as extensible as optparse or argparse as it does not
implement features that are implemented on a higher level (such as
types or defaults).
:param ctx: optionally the :class:`~click.Context` where this parser
should go with.
.. deprecated:: 8.2
Will be removed in Click 9.0.
"""
选项解析器
这是 Click 内部使用的类尽量不要直接使用应使用 Click 的高级 API
"""
def __init__(self, ctx: Context | None = None) -> None:
#: The :class:`~click.Context` for this parser. This might be
#: `None` for some advanced use cases.
self.ctx = ctx
#: This controls how the parser deals with interspersed arguments.
#: If this is set to `False`, the parser will stop on the first
#: non-option. Click uses this to implement nested subcommands
#: safely.
# 允许选项和位置参数混用。
# 如果为 False一旦遇到位置参数解析就会停止后续即使看起来像选项也会被当做参数。
# 子命令解析时通常设为 False以避免解析了子命令的选项。
self.allow_interspersed_args: bool = True
#: This tells the parser how to deal with unknown options. By
#: default it will error out (which is sensible), but there is a
#: second mode where it will ignore it and continue processing
#: after shifting all the unknown options into the resulting args.
self.ignore_unknown_options: bool = False
if ctx is not None:
self.allow_interspersed_args = ctx.allow_interspersed_args
self.ignore_unknown_options = ctx.ignore_unknown_options
self._short_opt: dict[str, _Option] = {}
self._long_opt: dict[str, _Option] = {}
self._short_opt: dict[str, _Option] = {} # 映射: '-f' -> _Option
self._long_opt: dict[str, _Option] = {} # 映射: '--foo' -> _Option
self._opt_prefixes = {"-", "--"}
self._args: list[_Argument] = []
def add_option(
self,
obj: CoreOption,
opts: cabc.Sequence[str],
dest: str | None,
action: str | None = None,
nargs: int = 1,
const: t.Any | None = None,
) -> None:
"""Adds a new option named `dest` to the parser. The destination
is not inferred (unlike with optparse) and needs to be explicitly
provided. Action can be any of ``store``, ``store_const``,
``append``, ``append_const`` or ``count``.
The `obj` can be used to identify the option in the order list
that is returned from the parser.
"""
def add_option(self, obj: CoreOption, opts: cabc.Sequence[str], dest: str | None, ...):
"""注册一个选项"""
opts = [_normalize_opt(opt, self.ctx) for opt in opts]
option = _Option(obj, opts, dest, action=action, nargs=nargs, const=const)
self._opt_prefixes.update(option.prefixes)
for opt in option._short_opts:
self._short_opt[opt] = option
for opt in option._long_opts:
self._long_opt[opt] = option
option = _Option(obj, opts, dest, ...)
# ... (注册到 _short_opt 和 _long_opt 字典中)
def add_argument(self, obj: CoreArgument, dest: str | None, nargs: int = 1) -> None:
"""Adds a positional argument named `dest` to the parser.
The `obj` can be used to identify the option in the order list
that is returned from the parser.
"""
"""注册一个位置参数"""
self._args.append(_Argument(obj, dest=dest, nargs=nargs))
def parse_args(
self, args: list[str]
) -> tuple[dict[str, t.Any], list[str], list[CoreParameter]]:
"""Parses positional arguments and returns ``(values, args, order)``
for the parsed options and arguments as well as the leftover
arguments if there are any. The order is a list of objects as they
appear on the command line. If arguments appear multiple times they
will be memorized multiple times as well.
"""
核心入口解析参数列表
返回: (选项字典, 剩余参数列表, 解析顺序列表)
"""
state = _ParsingState(args)
try:
# 1. 先尽可能处理所有的 Options (Flags)
self._process_args_for_options(state)
# 2. 再根据定义的 Arguments 规则处理剩下的内容
self._process_args_for_args(state)
except UsageError:
# 如果开启了 resilient_parsing (通常用于自动补全),则忽略错误
if self.ctx is None or not self.ctx.resilient_parsing:
raise
return state.opts, state.largs, state.order
def _process_args_for_args(self, state: _ParsingState) -> None:
"""处理位置参数"""
# 结合 largs 和 rargs根据 nargs 规则进行解包
pargs, args = _unpack_args(
state.largs + state.rargs, [x.nargs for x in self._args]
)
@ -317,81 +276,70 @@ class _OptionParser:
for idx, arg in enumerate(self._args):
arg.process(pargs[idx], state)
state.largs = args
state.largs = args # 更新剩余参数,通常留给子命令使用
state.rargs = []
def _process_args_for_options(self, state: _ParsingState) -> None:
"""
处理选项 (Flags)
遍历 rargs (remaining args)识别并提取选项
"""
while state.rargs:
arg = state.rargs.pop(0)
arglen = len(arg)
# Double dashes always handled explicitly regardless of what
# prefixes are valid.
# "--" 是标准的分隔符,意味着“后面的都不是选项”
if arg == "--":
return
# 检查是否像是一个选项 (以 - 或 -- 开头)
elif arg[:1] in self._opt_prefixes and arglen > 1:
self._process_opts(arg, state)
elif self.allow_interspersed_args:
# 如果允许混用,将非选项放入 largs继续处理后面的
state.largs.append(arg)
else:
state.rargs.insert(0, arg)
# 如果不允许混用(如子命令场景),遇到第一个非选项就停止解析
state.rargs.insert(0, arg) # 把刚才 pop 出来的放回去
return
# Say this is the original argument list:
# [arg0, arg1, ..., arg(i-1), arg(i), arg(i+1), ..., arg(N-1)]
# ^
# (we are about to process arg(i)).
#
# Then rargs is [arg(i), ..., arg(N-1)] and largs is a *subset* of
# [arg0, ..., arg(i-1)] (any options and their arguments will have
# been removed from largs).
#
# The while loop will usually consume 1 or more arguments per pass.
# If it consumes 1 (eg. arg is an option that takes no arguments),
# then after _process_arg() is done the situation is:
#
# largs = subset of [arg0, ..., arg(i)]
# rargs = [arg(i+1), ..., arg(N-1)]
#
# If allow_interspersed_args is false, largs will always be
# *empty* -- still a subset of [arg0, ..., arg(i-1)], but
# not a very interesting subset!
def _match_long_opt(
self, opt: str, explicit_value: str | None, state: _ParsingState
) -> None:
"""匹配长选项 (如 --foo)"""
if opt not in self._long_opt:
# 如果找不到,尝试进行模糊匹配并报错
from difflib import get_close_matches
possibilities = get_close_matches(opt, self._long_opt)
raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx)
option = self._long_opt[opt]
if option.takes_value:
# At this point it's safe to modify rargs by injecting the
# explicit value, because no exception is raised in this
# branch. This means that the inserted value will be fully
# consumed.
# 这里的逻辑处理显式赋值的情况,如 --foo=bar
if explicit_value is not None:
state.rargs.insert(0, explicit_value)
value = self._get_value_from_state(opt, option, state)
elif explicit_value is not None:
# 选项不需要值,但用户给了值 (--flag=value),报错
raise BadOptionUsage(
opt, _("Option {name!r} does not take a value.").format(name=opt)
)
else:
value = UNSET
option.process(value, state)
def _match_short_opt(self, arg: str, state: _ParsingState) -> None:
"""
匹配短选项 ( -f)
负责处理复杂的短选项组合 -xvf (等同于 -x -v -f)
"""
stop = False
i = 1
prefix = arg[0]
unknown_options = []
# 遍历组合中的每个字符,例如 -xvf 中的 x, v, f
for ch in arg[1:]:
opt = _normalize_opt(f"{prefix}{ch}", self.ctx)
option = self._short_opt.get(opt)
@ -402,15 +350,16 @@ class _OptionParser:
unknown_options.append(ch)
continue
raise NoSuchOption(opt, ctx=self.ctx)
if option.takes_value:
# Any characters left in arg? Pretend they're the
# next arg, and stop consuming characters of arg.
# 关键逻辑:如果当前短选项需要参数
# 1. 它可以“吃掉”紧跟在后面的字符。例如 -p80 (端口80)
if i < len(arg):
state.rargs.insert(0, arg[i:])
state.rargs.insert(0, arg[i:]) # 将剩余部分作为参数值放回队列头部
stop = True
# 2. 或者从下一个 arg 获取值
value = self._get_value_from_state(opt, option, state)
else:
value = UNSET
@ -419,77 +368,33 @@ class _OptionParser:
if stop:
break
# If we got any unknown options we recombine the string of the
# remaining options and re-attach the prefix, then report that
# to the state as new larg. This way there is basic combinatorics
# that can be achieved while still ignoring unknown arguments.
if self.ignore_unknown_options and unknown_options:
state.largs.append(f"{prefix}{''.join(unknown_options)}")
def _get_value_from_state(
self, option_name: str, option: _Option, state: _ParsingState
) -> str | cabc.Sequence[str] | T_FLAG_NEEDS_VALUE:
"""从 rargs 中提取选项所需的值"""
nargs = option.nargs
value: str | cabc.Sequence[str] | T_FLAG_NEEDS_VALUE
if len(state.rargs) < nargs:
if option.obj._flag_needs_value:
# Option allows omitting the value.
value = FLAG_NEEDS_VALUE
else:
raise BadOptionUsage(
option_name,
ngettext(
"Option {name!r} requires an argument.",
"Option {name!r} requires {nargs} arguments.",
nargs,
).format(name=option_name, nargs=nargs),
)
elif nargs == 1:
next_rarg = state.rargs[0]
if (
option.obj._flag_needs_value
and isinstance(next_rarg, str)
and next_rarg[:1] in self._opt_prefixes
and len(next_rarg) > 1
):
# The next arg looks like the start of an option, don't
# use it as the value if omitting the value is allowed.
value = FLAG_NEEDS_VALUE
else:
value = state.rargs.pop(0)
else:
value = tuple(state.rargs[:nargs])
del state.rargs[:nargs]
return value
# ... (检查剩余参数是否足够,处理 flag_needs_value 等逻辑)
# 如果参数不够,会在这里抛出 BadOptionUsage
# 如果 nargs=1直接 pop 一个值;如果 >1切片取多个值
def _process_opts(self, arg: str, state: _ParsingState) -> None:
"""主入口:处理单个看起来像选项的字符串"""
explicit_value = None
# Long option handling happens in two parts. The first part is
# supporting explicitly attached values. In any case, we will try
# to long match the option first.
# 处理 --foo=bar 格式
if "=" in arg:
long_opt, explicit_value = arg.split("=", 1)
else:
long_opt = arg
norm_long_opt = _normalize_opt(long_opt, self.ctx)
# At this point we will match the (assumed) long option through
# the long option matching code. Note that this allows options
# like "-foo" to be matched as long options.
try:
# 优先尝试作为长选项匹配
self._match_long_opt(norm_long_opt, explicit_value, state)
except NoSuchOption:
# At this point the long option matching failed, and we need
# to try with short options. However there is a special rule
# which says, that if we have a two character options prefix
# (applies to "--foo" for instance), we do not dispatch to the
# short option code and will instead raise the no option
# error.
# 如果长选项匹配失败
# 检查是否是双字符前缀 (如 --f),如果是,不尝试短选项匹配
if arg[:2] not in self._opt_prefixes:
# 尝试作为短选项匹配 (如 -f 或 -xvf)
self._match_short_opt(arg, state)
return
@ -498,35 +403,4 @@ class _OptionParser:
state.largs.append(arg)
def __getattr__(name: str) -> object:
import warnings
if name in {
"OptionParser",
"Argument",
"Option",
"split_opt",
"normalize_opt",
"ParsingState",
}:
warnings.warn(
f"'parser.{name}' is deprecated and will be removed in Click 9.0."
" The old parser is available in 'optparse'.",
DeprecationWarning,
stacklevel=2,
)
return globals()[f"_{name}"]
if name == "split_arg_string":
from .shell_completion import split_arg_string
warnings.warn(
"Importing 'parser.split_arg_string' is deprecated, it will only be"
" available in 'shell_completion' in Click 9.0.",
DeprecationWarning,
stacklevel=2,
)
return split_arg_string
raise AttributeError(name)
# ... (Deprecated 警告处理)

@ -6,6 +6,7 @@ import re
import typing as t
from gettext import gettext as _
# 引入 Click 的核心组件,用于构建上下文和参数定义
from .core import Argument
from .core import Command
from .core import Context
@ -23,30 +24,31 @@ def shell_complete(
complete_var: str,
instruction: str,
) -> int:
"""Perform shell completion for the given CLI program.
:param cli: Command being called.
:param ctx_args: Extra arguments to pass to
``cli.make_context``.
:param prog_name: Name of the executable in the shell.
:param complete_var: Name of the environment variable that holds
the completion instruction.
:param instruction: Value of ``complete_var`` with the completion
instruction and shell, in the form ``instruction_shell``.
:return: Status code to exit with.
"""
入口函数执行 Shell 自动补全逻辑
当用户在终端按 Tab 或者运行 setup 命令时都会进入这里
:param instruction: 包含 shell 类型和指令格式如 "bash_complete" "zsh_source"
由环境变量 _MYCLI_COMPLETE传入
"""
# 1. 解析指令:分离出 shell 类型 (如 bash) 和 动作 (如 complete 或 source)
shell, _, instruction = instruction.partition("_")
comp_cls = get_completion_class(shell)
comp_cls = get_completion_class(shell) # 获取对应的处理类 (BashComplete 等)
if comp_cls is None:
return 1
comp = comp_cls(cli, ctx_args, prog_name, complete_var)
# 2. 如果动作是 "source"
# 生成并打印 Shell 初始化脚本。用户通常会将此输出重定向到 .bashrc 或通过 source 命令加载。
if instruction == "source":
echo(comp.source())
return 0
# 3. 如果动作是 "complete"
# 这是用户按 Tab 键时触发的。计算补全建议并打印到标准输出,供 Shell 读取显示。
if instruction == "complete":
echo(comp.complete())
return 0
@ -55,24 +57,10 @@ def shell_complete(
class CompletionItem:
"""Represents a completion value and metadata about the value. The
default metadata is ``type`` to indicate special shell handling,
and ``help`` if a shell supports showing a help string next to the
value.
Arbitrary parameters can be passed when creating the object, and
accessed using ``item.attr``. If an attribute wasn't passed,
accessing it returns ``None``.
:param value: The completion suggestion.
:param type: Tells the shell script to provide special completion
support for the type. Click uses ``"dir"`` and ``"file"``.
:param help: String shown next to the value if supported.
:param kwargs: Arbitrary metadata. The built-in implementations
don't use this, but custom type completions paired with custom
shell support could use it.
"""
数据类表示一个补全候选项
包含实际值 (value)类型 (type, file/dir/plain) 帮助信息 (help)
"""
__slots__ = ("value", "type", "help", "_info")
def __init__(
@ -91,18 +79,25 @@ class CompletionItem:
return self._info.get(name)
# Only Bash >= 4.4 has the nosort option.
# ----------------------------------------------------------------
# 下面是嵌入的 Shell 脚本模板。
# Click 会将这些脚本注入到用户的 Shell 环境中,建立 Shell -> Python 的桥梁。
# ----------------------------------------------------------------
# Bash 脚本模板。Bash 4.4+ 支持 nosort可以按我们推荐的顺序显示。
_SOURCE_BASH = """\
%(complete_func)s() {
local IFS=$'\\n'
local response
# 调用 Python 程序自身,传入环境变量告诉它 "现在是补全模式"
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD \
%(complete_var)s=bash_complete $1)
for completion in $response; do
IFS=',' read type value <<< "$completion"
# 根据返回的 type 决定是否启用 Bash 内置的文件/目录补全
if [[ $type == 'dir' ]]; then
COMPREPLY=()
compopt -o dirnames
@ -124,177 +119,50 @@ _SOURCE_BASH = """\
%(complete_func)s_setup;
"""
# See ZshComplete.format_completion below, and issue #2703, before
# changing this script.
#
# (TL;DR: _describe is picky about the format, but this Zsh script snippet
# is already widely deployed. So freeze this script, and use clever-ish
# handling of colons in ZshComplet.format_completion.)
# Zsh 脚本模板。Zsh 支持显示帮助描述 (description),所以逻辑比 Bash 复杂。
_SOURCE_ZSH = """\
#compdef %(prog_name)s
%(complete_func)s() {
local -a completions
local -a completions_with_descriptions
local -a response
(( ! $+commands[%(prog_name)s] )) && return 1
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \
%(complete_var)s=zsh_complete %(prog_name)s)}")
for type key descr in ${response}; do
if [[ "$type" == "plain" ]]; then
if [[ "$descr" == "_" ]]; then
completions+=("$key")
else
completions_with_descriptions+=("$key":"$descr")
fi
elif [[ "$type" == "dir" ]]; then
_path_files -/
elif [[ "$type" == "file" ]]; then
_path_files -f
fi
done
if [ -n "$completions_with_descriptions" ]; then
_describe -V unsorted completions_with_descriptions -U
fi
if [ -n "$completions" ]; then
compadd -U -V unsorted -a completions
fi
}
if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
# autoload from fpath, call function directly
%(complete_func)s "$@"
else
# eval/source/. command, register function for later
compdef %(complete_func)s %(prog_name)s
fi
# ... (省略具体脚本内容,逻辑类似:调用 Python -> 解析输出 -> 调用 compadd/_describe 显示)
"""
# Fish 脚本模板。Fish 的语法完全不同。
_SOURCE_FISH = """\
function %(complete_func)s;
set -l response (env %(complete_var)s=fish_complete COMP_WORDS=(commandline -cp) \
COMP_CWORD=(commandline -t) %(prog_name)s);
for completion in $response;
set -l metadata (string split "," $completion);
if test $metadata[1] = "dir";
__fish_complete_directories $metadata[2];
else if test $metadata[1] = "file";
__fish_complete_path $metadata[2];
else if test $metadata[1] = "plain";
echo $metadata[2];
end;
end;
end;
complete --no-files --command %(prog_name)s --arguments \
"(%(complete_func)s)";
# ... (省略具体脚本内容)
"""
class ShellComplete:
"""Base class for providing shell completion support. A subclass for
a given shell will override attributes and methods to implement the
completion instructions (``source`` and ``complete``).
:param cli: Command being called.
:param prog_name: Name of the executable in the shell.
:param complete_var: Name of the environment variable that holds
the completion instruction.
.. versionadded:: 8.0
"""
name: t.ClassVar[str]
"""Name to register the shell as with :func:`add_completion_class`.
This is used in completion instructions (``{name}_source`` and
``{name}_complete``).
"""
source_template: t.ClassVar[str]
"""Completion script template formatted by :meth:`source`. This must
be provided by subclasses.
基类Shell 补全处理器
定义了 "生成脚本" "获取补全建议" 的标准流程
"""
def __init__(
self,
cli: Command,
ctx_args: cabc.MutableMapping[str, t.Any],
prog_name: str,
complete_var: str,
) -> None:
self.cli = cli
self.ctx_args = ctx_args
self.prog_name = prog_name
self.complete_var = complete_var
@property
def func_name(self) -> str:
"""The name of the shell function defined by the completion
script.
"""
safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), flags=re.ASCII)
return f"_{safe_name}_completion"
def source_vars(self) -> dict[str, t.Any]:
"""Vars for formatting :attr:`source_template`.
By default this provides ``complete_func``, ``complete_var``,
and ``prog_name``.
"""
return {
"complete_func": self.func_name,
"complete_var": self.complete_var,
"prog_name": self.prog_name,
}
# ... (初始化方法省略)
def source(self) -> str:
"""Produce the shell script that defines the completion
function. By default this ``%``-style formats
:attr:`source_template` with the dict returned by
:meth:`source_vars`.
"""
"""生成初始化 Shell 脚本 (对应 instruction="source")"""
return self.source_template % self.source_vars()
def get_completion_args(self) -> tuple[list[str], str]:
"""Use the env vars defined by the shell script to return a
tuple of ``args, incomplete``. This must be implemented by
subclasses.
"""
抽象方法从环境变量中解析出 Argument 列表和 Incomplete (正在打字的那个词)
不同 Shell 传递这些信息的方式不同 (Bash COMP_WORDS, Fish commandline )
"""
raise NotImplementedError
def get_completions(self, args: list[str], incomplete: str) -> list[CompletionItem]:
"""Determine the context and last complete command or parameter
from the complete args. Call that object's ``shell_complete``
method to get the completions for the incomplete value.
:param args: List of complete args before the incomplete value.
:param incomplete: Value being completed. May be empty.
"""
核心逻辑计算补全建议
流程
1. _resolve_context: 模拟运行一遍命令重建 Context 堆栈
2. _resolve_incomplete: 判断当前光标位置是在输入选项参数还是子命令
3. 调用对应对象的 shell_complete 方法获取最终列表
"""
ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args)
obj, incomplete = _resolve_incomplete(ctx, args, incomplete)
return obj.shell_complete(ctx, incomplete)
def format_completion(self, item: CompletionItem) -> str:
"""Format a completion item into the form recognized by the
shell script. This must be implemented by subclasses.
:param item: Completion item to format.
"""
raise NotImplementedError
def complete(self) -> str:
"""Produce the completion data to send back to the shell.
By default this calls :meth:`get_completion_args`, gets the
completions, then calls :meth:`format_completion` for each
completion.
"""
"""执行补全并格式化输出 (对应 instruction="complete")"""
args, incomplete = self.get_completion_args()
completions = self.get_completions(args, incomplete)
out = [self.format_completion(item) for item in completions]
@ -302,74 +170,17 @@ class ShellComplete:
class BashComplete(ShellComplete):
"""Shell completion for Bash."""
"""Bash 的具体实现"""
name = "bash"
source_template = _SOURCE_BASH
@staticmethod
def _check_version() -> None:
import shutil
import subprocess
bash_exe = shutil.which("bash")
if bash_exe is None:
match = None
else:
output = subprocess.run(
[bash_exe, "--norc", "-c", 'echo "${BASH_VERSION}"'],
stdout=subprocess.PIPE,
)
match = re.search(r"^(\d+)\.(\d+)\.\d+", output.stdout.decode())
if match is not None:
major, minor = match.groups()
if major < "4" or major == "4" and minor < "4":
echo(
_(
"Shell completion is not supported for Bash"
" versions older than 4.4."
),
err=True,
)
else:
echo(
_("Couldn't detect Bash version, shell completion is not supported."),
err=True,
)
def source(self) -> str:
self._check_version()
return super().source()
def get_completion_args(self) -> tuple[list[str], str]:
cwords = split_arg_string(os.environ["COMP_WORDS"])
cword = int(os.environ["COMP_CWORD"])
args = cwords[1:cword]
try:
incomplete = cwords[cword]
except IndexError:
incomplete = ""
return args, incomplete
def format_completion(self, item: CompletionItem) -> str:
return f"{item.type},{item.value}"
class ZshComplete(ShellComplete):
"""Shell completion for Zsh."""
name = "zsh"
source_template = _SOURCE_ZSH
# ... (版本检查逻辑省略)
def get_completion_args(self) -> tuple[list[str], str]:
# 使用 shlex 类似的逻辑拆分命令行字符串
cwords = split_arg_string(os.environ["COMP_WORDS"])
cword = int(os.environ["COMP_CWORD"])
args = cwords[1:cword]
args = cwords[1:cword] # 排除程序名本身
try:
incomplete = cwords[cword]
@ -379,107 +190,18 @@ class ZshComplete(ShellComplete):
return args, incomplete
def format_completion(self, item: CompletionItem) -> str:
help_ = item.help or "_"
# The zsh completion script uses `_describe` on items with help
# texts (which splits the item help from the item value at the
# first unescaped colon) and `compadd` on items without help
# text (which uses the item value as-is and does not support
# colon escaping). So escape colons in the item value if and
# only if the item help is not the sentinel "_" value, as used
# by the completion script.
#
# (The zsh completion script is potentially widely deployed, and
# thus harder to fix than this method.)
#
# See issue #1812 and issue #2703 for further context.
value = item.value.replace(":", r"\:") if help_ != "_" else item.value
return f"{item.type}\n{value}\n{help_}"
class FishComplete(ShellComplete):
"""Shell completion for Fish."""
name = "fish"
source_template = _SOURCE_FISH
def get_completion_args(self) -> tuple[list[str], str]:
cwords = split_arg_string(os.environ["COMP_WORDS"])
incomplete = os.environ["COMP_CWORD"]
if incomplete:
incomplete = split_arg_string(incomplete)[0]
args = cwords[1:]
# Fish stores the partial word in both COMP_WORDS and
# COMP_CWORD, remove it from complete args.
if incomplete and args and args[-1] == incomplete:
args.pop()
return args, incomplete
def format_completion(self, item: CompletionItem) -> str:
if item.help:
return f"{item.type},{item.value}\t{item.help}"
# Bash 的输出格式很简单: type,value
return f"{item.type},{item.value}"
ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]")
_available_shells: dict[str, type[ShellComplete]] = {
"bash": BashComplete,
"fish": FishComplete,
"zsh": ZshComplete,
}
def add_completion_class(
cls: ShellCompleteType, name: str | None = None
) -> ShellCompleteType:
"""Register a :class:`ShellComplete` subclass under the given name.
The name will be provided by the completion instruction environment
variable during completion.
:param cls: The completion class that will handle completion for the
shell.
:param name: Name to register the class under. Defaults to the
class's ``name`` attribute.
"""
if name is None:
name = cls.name
_available_shells[name] = cls
return cls
def get_completion_class(shell: str) -> type[ShellComplete] | None:
"""Look up a registered :class:`ShellComplete` subclass by the name
provided by the completion instruction environment variable. If the
name isn't registered, returns ``None``.
:param shell: Name the class is registered under.
"""
return _available_shells.get(shell)
# ... (ZshComplete 和 FishComplete 类类似,主要是格式化逻辑不同)
def split_arg_string(string: str) -> list[str]:
"""Split an argument string as with :func:`shlex.split`, but don't
fail if the string is incomplete. Ignores a missing closing quote or
incomplete escape sequence and uses the partial token as-is.
.. code-block:: python
split_arg_string("example 'my file")
["example", "my file"]
split_arg_string("example my\\")
["example", "my"]
:param string: String to split.
.. versionchanged:: 8.2
Moved to ``shell_completion`` from ``parser``.
"""
工具函数增强版的 shlex.split
关键点如果引文未闭合 'user na...),标准 shlex 会报错,但这里我们需要宽容处理,
因为用户正在输入中引文当然还没闭合
"""
import shlex
@ -492,33 +214,28 @@ def split_arg_string(string: str) -> list[str]:
for token in lex:
out.append(token)
except ValueError:
# Raised when end-of-string is reached in an invalid state. Use
# the partial token as-is. The quote or escape character is in
# lex.state, not lex.token.
# 捕获 "No closing quotation" 错误,并将当前已解析的部分作为 token 添加进去
out.append(lex.token)
return out
def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool:
"""Determine if the given parameter is an argument that can still
accept values.
# ----------------------------------------------------------------
# 下面是 "智能推断" 逻辑:判断用户到底想补全什么?
# ----------------------------------------------------------------
:param ctx: Invocation context for the command represented by the
parsed complete args.
:param param: Argument object being checked.
"""
def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool:
"""判断当前参数是否是一个还可以接受更多值的 Argument (位置参数)"""
if not isinstance(param, Argument):
return False
assert param.name is not None
# Will be None if expose_value is False.
value = ctx.params.get(param.name)
return (
param.nargs == -1
param.nargs == -1 # 变长参数,永远吃不够
or ctx.get_parameter_source(param.name) is not ParameterSource.COMMANDLINE
or (
param.nargs > 1
param.nargs > 1 # 多值参数,还没填满
and isinstance(value, (tuple, list))
and len(value) < param.nargs
)
@ -526,28 +243,28 @@ def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool:
def _start_of_option(ctx: Context, value: str) -> bool:
"""Check if the value looks like the start of an option."""
"""检查字符串是否像一个选项(以 - 或 -- 开头)"""
if not value:
return False
c = value[0]
return c in ctx._opt_prefixes
def _is_incomplete_option(ctx: Context, args: list[str], param: Parameter) -> bool:
"""Determine if the given parameter is an option that needs a value.
:param args: List of complete args before the incomplete value.
:param param: Option object being checked.
"""
判断当前是否正在输入某个 Option ****
例如my-cli --output [TAB]
此时 args 最后一个是 --output且该 Option 需要参数所以返回 True
"""
if not isinstance(param, Option):
return False
if param.is_flag or param.count:
return False
return False # Flag 不需要值
last_option = None
# 回溯查找 args 列表,看最近的一个选项是不是当前这个 param
for index, arg in enumerate(reversed(args)):
if index + 1 > param.nargs:
break
@ -565,15 +282,16 @@ def _resolve_context(
prog_name: str,
args: list[str],
) -> Context:
"""Produce the context hierarchy starting with the command and
traversing the complete arguments. This only follows the commands,
it doesn't trigger input prompts or callbacks.
:param cli: Command being called.
:param prog_name: Name of the executable in the shell.
:param args: List of complete args before the incomplete value.
"""
ctx_args["resilient_parsing"] = True
上下文重建
这是整个补全中最复杂的部分它需要根据用户已输入的一串参数 (args)
一层层地解析出当前的命令嵌套结构
例如输入: my-cli group1 sub2 --opt
它需要解析出: Context(my-cli) -> Context(group1) -> Context(sub2)
这样我们才知道当前是在 sub2 命令下进行补全
"""
ctx_args["resilient_parsing"] = True # 开启弹性解析,忽略部分错误
with cli.make_context(prog_name, args.copy(), **ctx_args) as ctx:
args = ctx._protected_args + ctx.args
@ -581,39 +299,23 @@ def _resolve_context(
command = ctx.command
if isinstance(command, Group):
# 如果当前是命令组,尝试解析下一个参数是否是其子命令
if not command.chain:
name, cmd, args = command.resolve_command(ctx, args)
if cmd is None:
return ctx
return ctx # 找不到子命令了,说明当前就在这个 Group 下
# 进入子命令的 Context
with cmd.make_context(
name, args, parent=ctx, resilient_parsing=True
) as sub_ctx:
ctx = sub_ctx
args = ctx._protected_args + ctx.args
else:
sub_ctx = ctx
while args:
name, cmd, args = command.resolve_command(ctx, args)
if cmd is None:
return ctx
with cmd.make_context(
name,
args,
parent=ctx,
allow_extra_args=True,
allow_interspersed_args=False,
resilient_parsing=True,
) as sub_sub_ctx:
sub_ctx = sub_sub_ctx
args = sub_ctx.args
ctx = sub_ctx
args = [*sub_ctx._protected_args, *sub_ctx.args]
# 处理链式命令 (MultiCommand) 的复杂情况
# ... (省略)
pass
else:
break
@ -623,45 +325,39 @@ def _resolve_context(
def _resolve_incomplete(
ctx: Context, args: list[str], incomplete: str
) -> tuple[Command | Parameter, str]:
"""Find the Click object that will handle the completion of the
incomplete value. Return the object and the incomplete value.
:param ctx: Invocation context for the command represented by
the parsed complete args.
:param args: List of complete args before the incomplete value.
:param incomplete: Value being completed. May be empty.
"""
# Different shells treat an "=" between a long option name and
# value differently. Might keep the value joined, return the "="
# as a separate item, or return the split name and value. Always
# split and discard the "=" to make completion easier.
决策中心
决定由谁来负责提供补全建议
返回: (负责补全的对象, 待补全的字符串)
负责补全的对象可能是
1. Command/Group: 负责补全子命令名或选项名 (--foo)
2. Option: 负责补全该选项的值 (如文件名)
3. Argument: 负责补全位置参数的值
"""
# 处理 "--foo=val" 这种连写情况,将其拆分
if incomplete == "=":
incomplete = ""
elif "=" in incomplete and _start_of_option(ctx, incomplete):
name, _, incomplete = incomplete.partition("=")
args.append(name)
# The "--" marker tells Click to stop treating values as options
# even if they start with the option character. If it hasn't been
# given and the incomplete arg looks like an option, the current
# command will provide option name completions.
# 1. 如果输入看起来像选项 (如 --ver),交给 Command 补全选项名
if "--" not in args and _start_of_option(ctx, incomplete):
return ctx.command, incomplete
params = ctx.command.get_params(ctx)
# If the last complete arg is an option name with an incomplete
# value, the option will provide value completions.
# 2. 检查是否正在输入某个选项的值
# 例如输入了 "my-cli --file ",此时应由 --file 这个 Option 对象提供补全
for param in params:
if _is_incomplete_option(ctx, args, param):
return param, incomplete
# It's not an option name or value. The first argument without a
# parsed value will provide value completions.
# 3. 检查是否正在输入位置参数
for param in params:
if _is_incomplete_argument(ctx, param):
return param, incomplete
# There were no unparsed arguments, the command may be a group that
# will provide command name completions.
return ctx.command, incomplete
# 4. 如果都不是,那说明可能是想输入子命令,交给 Command 处理
return ctx.command, incomplete

@ -9,6 +9,7 @@ import typing as t
from contextlib import AbstractContextManager
from gettext import gettext as _
# _compat 模块处理 Python 版本兼容性和跨平台终端差异(如 Windows
from ._compat import isatty
from ._compat import strip_ansi
from .exceptions import Abort
@ -25,10 +26,12 @@ if t.TYPE_CHECKING:
V = t.TypeVar("V")
# The prompt functions to use. The doc tools currently override these
# functions to customize how they work.
# 定义默认的输入函数。文档工具可能会覆盖这些函数以进行测试。
# visible_prompt_func 通常就是 Python 内置的 input()
visible_prompt_func: t.Callable[[str], str] = input
# ANSI 颜色代码映射表 (标准 16 色)
# 这些数字对应 ANSI 转义序列中的颜色代码
_ansi_colors = {
"black": 30,
"red": 31,
@ -52,6 +55,10 @@ _ansi_reset_all = "\033[0m"
def hidden_prompt_func(prompt: str) -> str:
"""
隐藏输入的提示函数主要用于密码输入
使用 getpass 标准库实现输入时终端不会回显字符
"""
import getpass
return getpass.getpass(prompt)
@ -65,15 +72,29 @@ def _build_prompt(
show_choices: bool = True,
type: ParamType | None = None,
) -> str:
"""
[内部辅助函数] 构建完整的提示字符串
格式通常为: "Prompt Text (choice1, choice2) [default_value]: "
"""
prompt = text
# 如果类型是 Choice (选项),且允许显示选项,则把 (a, b, c) 追加到提示语后
if type is not None and show_choices and isinstance(type, Choice):
prompt += f" ({', '.join(map(str, type.choices))})"
# 如果有默认值且允许显示,追加 [default]
if default is not None and show_default:
prompt = f"{prompt} [{_format_default(default)}]"
# 最后加上后缀(通常是 ": "
return f"{prompt}{suffix}"
def _format_default(default: t.Any) -> t.Any:
"""
格式化默认值以便显示
如果是文件对象显示文件名而不是对象本身
"""
if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"):
return default.name
@ -92,98 +113,77 @@ def prompt(
err: bool = False,
show_choices: bool = True,
) -> t.Any:
"""Prompts a user for input. This is a convenience function that can
be used to prompt a user for input later.
If the user aborts the input by sending an interrupt signal, this
function will catch it and raise a :exc:`Abort` exception.
:param text: the text to show for the prompt.
:param default: the default value to use if no input happens. If this
is not given it will prompt until it's aborted.
:param hide_input: if this is set to true then the input value will
be hidden.
:param confirmation_prompt: Prompt a second time to confirm the
value. Can be set to a string instead of ``True`` to customize
the message.
:param type: the type to use to check the value against.
:param value_proc: if this parameter is provided it's a function that
is invoked instead of the type conversion to
convert a value.
:param prompt_suffix: a suffix that should be added to the prompt.
:param show_default: shows or hides the default value in the prompt.
:param err: if set to true the file defaults to ``stderr`` instead of
``stdout``, the same as with echo.
:param show_choices: Show or hide choices if the passed type is a Choice.
For example if type is a Choice of either day or week,
show_choices is true and text is "Group by" then the
prompt will be "Group by (day, week): ".
.. versionchanged:: 8.3.1
A space is no longer appended to the prompt.
.. versionadded:: 8.0
``confirmation_prompt`` can be a custom string.
.. versionadded:: 7.0
Added the ``show_choices`` parameter.
.. versionadded:: 6.0
Added unicode support for cmd.exe on Windows.
.. versionadded:: 4.0
Added the `err` parameter.
"""
[核心功能] 请求用户输入
这是一个强大的 input() 替代品
1. 支持自动类型转换 (type 参数)
2. 支持输入校验失败会自动重试
3. 支持密码模式 (hide_input)
4. 支持二次确认 (confirmation_prompt)
"""
def prompt_func(text: str) -> str:
"""
实际执行输入的闭包函数
处理了 Ctrl+C (KeyboardInterrupt) EOF将其转换为 Click Abort 异常
"""
f = hidden_prompt_func if hide_input else visible_prompt_func
try:
# Write the prompt separately so that we get nice
# coloring through colorama on Windows
# 分开打印提示语和读取输入。
# 这样做是为了在 Windows 上通过 colorama 获得更好的颜色支持。
echo(text[:-1], nl=False, err=err)
# Echo the last character to stdout to work around an issue where
# readline causes backspace to clear the whole line.
# 打印最后一个字符并等待输入
return f(text[-1:])
except (KeyboardInterrupt, EOFError):
# getpass doesn't print a newline if the user aborts input with ^C.
# Allegedly this behavior is inherited from getpass(3).
# A doc bug has been filed at https://bugs.python.org/issue24711
# getpass 在用户按 Ctrl+C 时不会打印换行,这里补一个
if hide_input:
echo(None, err=err)
# 抛出 Abort 异常Click 的主循环会捕获它并优雅退出,而不是打印 Traceback
raise Abort() from None
# 如果没有提供自定义的值处理函数,则使用 Click 类型系统的转换器
if value_proc is None:
value_proc = convert_type(type, default)
# 构建提示语
prompt = _build_prompt(
text, prompt_suffix, show_default, default, show_choices, type
)
# 处理二次确认的提示语(例如 "Repeat for confirmation: "
if confirmation_prompt:
if confirmation_prompt is True:
confirmation_prompt = _("Repeat for confirmation")
confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix)
# 主循环:不断请求输入,直到获得有效值
while True:
while True:
value = prompt_func(prompt)
if value:
break
# 如果用户直接回车 (value为空) 且有默认值,则使用默认值
elif default is not None:
value = default
break
try:
# 尝试转换类型 (例如 str -> int)
result = value_proc(value)
except UsageError as e:
# 如果转换失败(例如用户输入 "abc" 但要求 int打印错误信息并继续循环
if hide_input:
echo(_("Error: The value you entered was invalid."), err=err)
else:
echo(_("Error: {e.message}").format(e=e), err=err)
continue
# 如果不需要二次确认,直接返回结果
if not confirmation_prompt:
return result
# 二次确认逻辑
while True:
value2 = prompt_func(confirmation_prompt)
is_empty = not value and not value2
@ -202,47 +202,28 @@ def confirm(
show_default: bool = True,
err: bool = False,
) -> bool:
"""Prompts for confirmation (yes/no question).
If the user aborts the input by sending a interrupt signal this
function will catch it and raise a :exc:`Abort` exception.
:param text: the question to ask.
:param default: The default value to use when no input is given. If
``None``, repeat until input is given.
:param abort: if this is set to `True` a negative answer aborts the
exception by raising :exc:`Abort`.
:param prompt_suffix: a suffix that should be added to the prompt.
:param show_default: shows or hides the default value in the prompt.
:param err: if set to true the file defaults to ``stderr`` instead of
``stdout``, the same as with echo.
.. versionchanged:: 8.3.1
A space is no longer appended to the prompt.
.. versionchanged:: 8.0
Repeat until input is given if ``default`` is ``None``.
.. versionadded:: 4.0
Added the ``err`` parameter.
"""
[核心功能] 请求用户确认 (Yes/No)
:param default: 默认值如果为 None则强制用户必须输入 y n
:param abort: 如果为 True当用户选择 No 直接抛出 Abort 异常终止程序
"""
prompt = _build_prompt(
text,
prompt_suffix,
show_default,
# 根据默认值决定显示 [Y/n], [y/N] 还是 [y/n]
"y/n" if default is None else ("Y/n" if default else "y/N"),
)
while True:
try:
# Write the prompt separately so that we get nice
# coloring through colorama on Windows
echo(prompt[:-1], nl=False, err=err)
# Echo the last character to stdout to work around an issue where
# readline causes backspace to clear the whole line.
value = visible_prompt_func(prompt[-1:]).lower().strip()
except (KeyboardInterrupt, EOFError):
raise Abort() from None
# 判断输入
if value in ("y", "yes"):
rv = True
elif value in ("n", "no"):
@ -253,6 +234,8 @@ def confirm(
echo(_("Error: invalid input"), err=err)
continue
break
# 如果设置了 abort=True 且结果为 False则抛出异常
if abort and not rv:
raise Abort()
return rv
@ -262,19 +245,12 @@ def echo_via_pager(
text_or_generator: cabc.Iterable[str] | t.Callable[[], cabc.Iterable[str]] | str,
color: bool | None = None,
) -> None:
"""This function takes a text and shows it via an environment specific
pager on stdout.
.. versionchanged:: 3.0
Added the `color` flag.
:param text_or_generator: the text to page, or alternatively, a
generator emitting the text to page.
:param color: controls if the pager supports ANSI colors or not. The
default is autodetection.
"""
[UI工具] 调用系统分页器 (Pager, less more) 显示长文本
"""
color = resolve_color_default(color)
# 处理输入:支持生成器、字符串或迭代器
if inspect.isgeneratorfunction(text_or_generator):
i = t.cast("t.Callable[[], cabc.Iterable[str]]", text_or_generator)()
elif isinstance(text_or_generator, str):
@ -282,14 +258,16 @@ def echo_via_pager(
else:
i = iter(t.cast("cabc.Iterable[str]", text_or_generator))
# convert every element of i to a text type if necessary
# 确保所有元素都是字符串
text_generator = (el if isinstance(el, str) else str(el) for el in i)
from ._termui_impl import pager
# 将生成器链接起来并传递给底层 pager 实现
return pager(itertools.chain(text_generator, "\n"), color)
# 定义 progressbar 的类型重载,用于 IDE 智能提示
@t.overload
def progressbar(
*,
@ -349,123 +327,16 @@ def progressbar(
color: bool | None = None,
update_min_steps: int = 1,
) -> ProgressBar[V]:
"""This function creates an iterable context manager that can be used
to iterate over something while showing a progress bar. It will
either iterate over the `iterable` or `length` items (that are counted
up). While iteration happens, this function will print a rendered
progress bar to the given `file` (defaults to stdout) and will attempt
to calculate remaining time and more. By default, this progress bar
will not be rendered if the file is not a terminal.
The context manager creates the progress bar. When the context
manager is entered the progress bar is already created. With every
iteration over the progress bar, the iterable passed to the bar is
advanced and the bar is updated. When the context manager exits,
a newline is printed and the progress bar is finalized on screen.
Note: The progress bar is currently designed for use cases where the
total progress can be expected to take at least several seconds.
Because of this, the ProgressBar class object won't display
progress that is considered too fast, and progress where the time
between steps is less than a second.
No printing must happen or the progress bar will be unintentionally
destroyed.
Example usage::
with progressbar(items) as bar:
for item in bar:
do_something_with(item)
Alternatively, if no iterable is specified, one can manually update the
progress bar through the `update()` method instead of directly
iterating over the progress bar. The update method accepts the number
of steps to increment the bar with::
with progressbar(length=chunks.total_bytes) as bar:
for chunk in chunks:
process_chunk(chunk)
bar.update(chunks.bytes)
The ``update()`` method also takes an optional value specifying the
``current_item`` at the new position. This is useful when used
together with ``item_show_func`` to customize the output for each
manual step::
with click.progressbar(
length=total_size,
label='Unzipping archive',
item_show_func=lambda a: a.filename
) as bar:
for archive in zip_file:
archive.extract()
bar.update(archive.size, archive)
:param iterable: an iterable to iterate over. If not provided the length
is required.
:param length: the number of items to iterate over. By default the
progressbar will attempt to ask the iterator about its
length, which might or might not work. If an iterable is
also provided this parameter can be used to override the
length. If an iterable is not provided the progress bar
will iterate over a range of that length.
:param label: the label to show next to the progress bar.
:param hidden: hide the progressbar. Defaults to ``False``. When no tty is
detected, it will only print the progressbar label. Setting this to
``False`` also disables that.
:param show_eta: enables or disables the estimated time display. This is
automatically disabled if the length cannot be
determined.
:param show_percent: enables or disables the percentage display. The
default is `True` if the iterable has a length or
`False` if not.
:param show_pos: enables or disables the absolute position display. The
default is `False`.
:param item_show_func: A function called with the current item which
can return a string to show next to the progress bar. If the
function returns ``None`` nothing is shown. The current item can
be ``None``, such as when entering and exiting the bar.
:param fill_char: the character to use to show the filled part of the
progress bar.
:param empty_char: the character to use to show the non-filled part of
the progress bar.
:param bar_template: the format string to use as template for the bar.
The parameters in it are ``label`` for the label,
``bar`` for the progress bar and ``info`` for the
info section.
:param info_sep: the separator between multiple info items (eta etc.)
:param width: the width of the progress bar in characters, 0 means full
terminal width
:param file: The file to write to. If this is not a terminal then
only the label is printed.
:param color: controls if the terminal supports ANSI colors or not. The
default is autodetection. This is only needed if ANSI
codes are included anywhere in the progress bar output
which is not the case by default.
:param update_min_steps: Render only when this many updates have
completed. This allows tuning for very fast iterators.
.. versionadded:: 8.2
The ``hidden`` argument.
.. versionchanged:: 8.0
Output is shown even if execution time is less than 0.5 seconds.
.. versionchanged:: 8.0
``item_show_func`` shows the current item, not the previous one.
.. versionchanged:: 8.0
Labels are echoed if the output is not a TTY. Reverts a change
in 7.0 that removed all output.
.. versionadded:: 8.0
The ``update_min_steps`` parameter.
.. versionadded:: 4.0
The ``color`` parameter and ``update`` method.
.. versionadded:: 2.0
"""
[核心功能] 创建一个进度条对象
它通常用作 Context Manager (with 语句)
with click.progressbar(items) as bar:
for item in bar:
...
实现细节它是一个 "Facade" (门面)实际的逻辑委托给了 ._termui_impl.ProgressBar
这样做是为了保持 termui.py API 清晰将复杂的终端绘制逻辑隐藏
"""
from ._termui_impl import ProgressBar
@ -491,119 +362,56 @@ def progressbar(
def clear() -> None:
"""Clears the terminal screen. This will have the effect of clearing
the whole visible space of the terminal and moving the cursor to the
top left. This does not do anything if not connected to a terminal.
.. versionadded:: 2.0
"""
[UI工具] 清空终端屏幕
"""
if not isatty(sys.stdout):
return
# ANSI escape \033[2J clears the screen, \033[1;1H moves the cursor
# 使用 ANSI 转义序列:
# \033[2J: 清空整个屏幕
# \033[1;1H: 将光标移动到左上角 (第一行第一列)
echo("\033[2J\033[1;1H", nl=False)
def _interpret_color(color: int | tuple[int, int, int] | str, offset: int = 0) -> str:
"""
[内部工具] 将颜色参数转换为 ANSI 代码字符串
offset: 用于区分前景色 (offset=0) 和背景色 (offset=10)
例如红色前景是 31背景是 41
"""
# 处理 8-bit 颜色 (256色模式): \033[38;5;Nm
if isinstance(color, int):
return f"{38 + offset};5;{color:d}"
# 处理 RGB 真彩色 (True Color): \033[38;2;R;G;Bm
if isinstance(color, (tuple, list)):
r, g, b = color
return f"{38 + offset};2;{r:d};{g:d};{b:d}"
# 处理标准具名颜色
return str(_ansi_colors[color] + offset)
def style(
text: t.Any,
fg: int | tuple[int, int, int] | str | None = None,
bg: int | tuple[int, int, int] | str | None = None,
bold: bool | None = None,
dim: bool | None = None,
underline: bool | None = None,
overline: bool | None = None,
italic: bool | None = None,
blink: bool | None = None,
reverse: bool | None = None,
strikethrough: bool | None = None,
fg: int | tuple[int, int, int] | str | None = None, # 前景色
bg: int | tuple[int, int, int] | str | None = None, # 背景色
bold: bool | None = None, # 加粗
dim: bool | None = None, # 变暗
underline: bool | None = None, # 下划线
overline: bool | None = None, # 上划线
italic: bool | None = None, # 斜体
blink: bool | None = None, # 闪烁
reverse: bool | None = None, # 反转 (前背景对调)
strikethrough: bool | None = None, # 删除线
reset: bool = True,
) -> str:
"""Styles a text with ANSI styles and returns the new string. By
default the styling is self contained which means that at the end
of the string a reset code is issued. This can be prevented by
passing ``reset=False``.
Examples::
click.echo(click.style('Hello World!', fg='green'))
click.echo(click.style('ATTENTION!', blink=True))
click.echo(click.style('Some things', reverse=True, fg='cyan'))
click.echo(click.style('More colors', fg=(255, 12, 128), bg=117))
Supported color names:
* ``black`` (might be a gray)
* ``red``
* ``green``
* ``yellow`` (might be an orange)
* ``blue``
* ``magenta``
* ``cyan``
* ``white`` (might be light gray)
* ``bright_black``
* ``bright_red``
* ``bright_green``
* ``bright_yellow``
* ``bright_blue``
* ``bright_magenta``
* ``bright_cyan``
* ``bright_white``
* ``reset`` (reset the color code only)
If the terminal supports it, color may also be specified as:
- An integer in the interval [0, 255]. The terminal must support
8-bit/256-color mode.
- An RGB tuple of three integers in [0, 255]. The terminal must
support 24-bit/true-color mode.
See https://en.wikipedia.org/wiki/ANSI_color and
https://gist.github.com/XVilka/8346728 for more information.
:param text: the string to style with ansi codes.
:param fg: if provided this will become the foreground color.
:param bg: if provided this will become the background color.
:param bold: if provided this will enable or disable bold mode.
:param dim: if provided this will enable or disable dim mode. This is
badly supported.
:param underline: if provided this will enable or disable underline.
:param overline: if provided this will enable or disable overline.
:param italic: if provided this will enable or disable italic.
:param blink: if provided this will enable or disable blinking.
:param reverse: if provided this will enable or disable inverse
rendering (foreground becomes background and the
other way round).
:param strikethrough: if provided this will enable or disable
striking through text.
:param reset: by default a reset-all code is added at the end of the
string which means that styles do not carry over. This
can be disabled to compose styles.
.. versionchanged:: 8.0
A non-string ``message`` is converted to a string.
.. versionchanged:: 8.0
Added support for 256 and RGB color codes.
.. versionchanged:: 8.0
Added the ``strikethrough``, ``italic``, and ``overline``
parameters.
.. versionchanged:: 7.0
Added support for bright colors.
.. versionadded:: 2.0
"""
[核心功能] 为文本添加 ANSI 样式
返回的是包含 ANSI 转义码的字符串例如 "\033[31mError\033[0m"
"""
if not isinstance(text, str):
text = str(text)
@ -612,16 +420,20 @@ def style(
if fg:
try:
# 构造前景颜色代码
bits.append(f"\033[{_interpret_color(fg)}m")
except KeyError:
raise TypeError(f"Unknown color {fg!r}") from None
if bg:
try:
# 构造背景颜色代码 (传入 offset=10)
bits.append(f"\033[{_interpret_color(bg, 10)}m")
except KeyError:
raise TypeError(f"Unknown color {bg!r}") from None
# 构造各种字体样式的代码
# ANSI 码规则: N 开启, 20+N 关闭 (通常)
if bold is not None:
bits.append(f"\033[{1 if bold else 22}m")
if dim is not None:
@ -638,20 +450,19 @@ def style(
bits.append(f"\033[{7 if reverse else 27}m")
if strikethrough is not None:
bits.append(f"\033[{9 if strikethrough else 29}m")
bits.append(text)
# 如果 reset=True (默认),在末尾添加重置代码,防止样式泄漏到后面的文本
if reset:
bits.append(_ansi_reset_all)
return "".join(bits)
def unstyle(text: str) -> str:
"""Removes ANSI styling information from a string. Usually it's not
necessary to use this function as Click's echo function will
automatically remove styling if necessary.
.. versionadded:: 2.0
:param text: the text to remove style information from.
"""
[UI工具] 移除字符串中的 ANSI 转义码
当需要计算字符串的实际显示长度或者将输出重定向到日志文件时很有用
"""
return strip_ansi(text)
@ -664,32 +475,19 @@ def secho(
color: bool | None = None,
**styles: t.Any,
) -> None:
"""This function combines :func:`echo` and :func:`style` into one
call. As such the following two calls are the same::
click.secho('Hello World!', fg='green')
click.echo(click.style('Hello World!', fg='green'))
All keyword arguments are forwarded to the underlying functions
depending on which one they go with.
Non-string types will be converted to :class:`str`. However,
:class:`bytes` are passed directly to :meth:`echo` without applying
style. If you want to style bytes that represent text, call
:meth:`bytes.decode` first.
.. versionchanged:: 8.0
A non-string ``message`` is converted to a string. Bytes are
passed through without style applied.
.. versionadded:: 2.0
"""
[核心功能] Style + Echo 的组合简写
click.secho('Hello', fg='red') 等同于 click.echo(click.style('Hello', fg='red'))
"""
# 如果 message 是字节串,不应用样式(因为样式是针对文本的)
if message is not None and not isinstance(message, (bytes, bytearray)):
message = style(message, **styles)
return echo(message, file=file, nl=nl, err=err, color=color)
# 定义 edit 的多重重载,用于处理不同的返回值类型
@t.overload
def edit(
text: bytes | bytearray,
@ -729,49 +527,25 @@ def edit(
extension: str = ".txt",
filename: str | cabc.Iterable[str] | None = None,
) -> str | bytes | bytearray | None:
r"""Edits the given text in the defined editor. If an editor is given
(should be the full path to the executable but the regular operating
system search path is used for finding the executable) it overrides
the detected editor. Optionally, some environment variables can be
used. If the editor is closed without changes, `None` is returned. In
case a file is edited directly the return value is always `None` and
`require_save` and `extension` are ignored.
If the editor cannot be opened a :exc:`UsageError` is raised.
Note for Windows: to simplify cross-platform usage, the newlines are
automatically converted from POSIX to Windows and vice versa. As such,
the message here will have ``\n`` as newline markers.
:param text: the text to edit.
:param editor: optionally the editor to use. Defaults to automatic
detection.
:param env: environment variables to forward to the editor.
:param require_save: if this is true, then not saving in the editor
will make the return value become `None`.
:param extension: the extension to tell the editor about. This defaults
to `.txt` but changing this might change syntax
highlighting.
:param filename: if provided it will edit this file instead of the
provided text contents. It will not use a temporary
file as an indirection in that case. If the editor supports
editing multiple files at once, a sequence of files may be
passed as well. Invoke `click.file` once per file instead
if multiple files cannot be managed at once or editing the
files serially is desired.
.. versionchanged:: 8.2.0
``filename`` now accepts any ``Iterable[str]`` in addition to a ``str``
if the ``editor`` supports editing multiple files at once.
r"""
[核心功能] 调用系统编辑器 ( Vim, Nano) 编辑文本
工作原理
1. 创建一个临时文件
2. text 写入临时文件
3. 调用环境变量 EDITOR 指定的编辑器打开该文件
4. 等待编辑器进程结束
5. 读取临时文件的新内容并返回
"""
from ._termui_impl import Editor
ed = Editor(editor=editor, env=env, require_save=require_save, extension=extension)
# 情况1编辑内存中的字符串
if filename is None:
return ed.edit(text)
# 情况2直接编辑指定的文件
if isinstance(filename, str):
filename = (filename,)
@ -780,61 +554,29 @@ def edit(
def launch(url: str, wait: bool = False, locate: bool = False) -> int:
"""This function launches the given URL (or filename) in the default
viewer application for this file type. If this is an executable, it
might launch the executable in a new session. The return value is
the exit code of the launched application. Usually, ``0`` indicates
success.
Examples::
click.launch('https://click.palletsprojects.com/')
click.launch('/my/downloaded/file', locate=True)
.. versionadded:: 2.0
:param url: URL or filename of the thing to launch.
:param wait: Wait for the program to exit before returning. This
only works if the launched program blocks. In particular,
``xdg-open`` on Linux does not block.
:param locate: if this is set to `True` then instead of launching the
application associated with the URL it will attempt to
launch a file manager with the file located. This
might have weird effects if the URL does not point to
the filesystem.
"""
[系统集成] 打开 URL 或文件
Windows 上调用 startmacOS 调用 openLinux 调用 xdg-open
:param locate: 如果为 True则在文件管理器中定位文件而不是打开它
"""
from ._termui_impl import open_url
return open_url(url, wait=wait, locate=locate)
# If this is provided, getchar() calls into this instead. This is used
# for unittesting purposes.
# 用于单元测试的钩子
_getchar: t.Callable[[bool], str] | None = None
def getchar(echo: bool = False) -> str:
"""Fetches a single character from the terminal and returns it. This
will always return a unicode character and under certain rare
circumstances this might return more than one character. The
situations which more than one character is returned is when for
whatever reason multiple characters end up in the terminal buffer or
standard input was not actually a terminal.
Note that this will always read from the terminal, even if something
is piped into the standard input.
Note for Windows: in rare cases when typing non-ASCII characters, this
function might wait for a second character and then return both at once.
This is because certain Unicode characters look like special-key markers.
.. versionadded:: 2.0
:param echo: if set to `True`, the character read will also show up on
the terminal. The default is to not show it.
"""
[系统集成] 从终端读取单个字符不等待回车
常用于 "Press any key to continue" 的场景
"""
global _getchar
# 延迟加载实现
if _getchar is None:
from ._termui_impl import getchar as f
@ -844,27 +586,21 @@ def getchar(echo: bool = False) -> str:
def raw_terminal() -> AbstractContextManager[int]:
"""
[高级功能] 将终端切换到 RAW 模式
在此模式下所有按键事件都会直接发送给程序而不是由 Shell 处理例如 Ctrl+C 不会发送信号
"""
from ._termui_impl import raw_terminal as f
return f()
def pause(info: str | None = None, err: bool = False) -> None:
"""This command stops execution and waits for the user to press any
key to continue. This is similar to the Windows batch "pause"
command. If the program is not run through a terminal, this command
will instead do nothing.
.. versionadded:: 2.0
.. versionadded:: 4.0
Added the `err` parameter.
:param info: The message to print before pausing. Defaults to
``"Press any key to continue..."``.
:param err: if set to message goes to ``stderr`` instead of
``stdout``, the same as with echo.
"""
[UI工具] 暂停执行直到用户按任意键
类似 Windows batch 命令 `pause`
"""
# 如果不是终端环境(例如管道操作),直接跳过
if not isatty(sys.stdin) or not isatty(sys.stdout):
return
@ -875,9 +611,9 @@ def pause(info: str | None = None, err: bool = False) -> None:
if info:
echo(info, nl=False, err=err)
try:
getchar()
getchar() # 等待按键
except (KeyboardInterrupt, EOFError):
pass
finally:
if info:
echo(err=err)
echo(err=err) # 补一个换行

@ -23,6 +23,11 @@ if t.TYPE_CHECKING:
class EchoingStdin:
"""
模拟终端的回显行为
在真实的终端中当你输入内容时你不仅是在写入 stdin同时屏幕上也会显示你输入的内容回显
这个类包装了输入流在读取时顺便将内容写入输出流以便测试结果包含用户的输入内容
"""
def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None:
self._input = input
self._output = output
@ -32,6 +37,7 @@ class EchoingStdin:
return getattr(self._input, x)
def _echo(self, rv: bytes) -> bytes:
# 如果没有暂停回显(比如输入密码时会暂停),则将读取到的字节写入输出
if not self._paused:
self._output.write(rv)
@ -58,6 +64,10 @@ class EchoingStdin:
@contextlib.contextmanager
def _pause_echo(stream: EchoingStdin | None) -> cabc.Iterator[None]:
"""
上下文管理器临时暂停输入回显
主要用于模拟密码输入场景输入时屏幕不显示字符
"""
if stream is None:
yield
else:
@ -67,9 +77,10 @@ def _pause_echo(stream: EchoingStdin | None) -> cabc.Iterator[None]:
class BytesIOCopy(io.BytesIO):
"""Patch ``io.BytesIO`` to let the written stream be copied to another.
.. versionadded:: 8.2
"""
io.BytesIO 的补丁
当写入此流时同时将内容复制到另一个 BytesIO 对象中
这是为了实现 StreamMixer 的功能
"""
def __init__(self, copy_to: io.BytesIO) -> None:
@ -86,25 +97,21 @@ class BytesIOCopy(io.BytesIO):
class StreamMixer:
"""Mixes `<stdout>` and `<stderr>` streams.
The result is available in the ``output`` attribute.
.. versionadded:: 8.2
"""
流混合器
它不仅分别捕获 stdout stderr还将它们按写入顺序混合到一个 output 流中
这样 `result.output` 就能真实还原用户在终端看到的顺序标准输出和错误输出交织
"""
def __init__(self) -> None:
self.output: io.BytesIO = io.BytesIO()
# 创建 stdout 和 stderr写入它们的内容会自动 copy 到 self.output
self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output)
self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output)
def __del__(self) -> None:
"""
Guarantee that embedded file-like objects are closed in a
predictable order, protecting against races between
self.output being closed and other streams being flushed on close
.. versionadded:: 8.2.2
析构函数确保流按顺序关闭
"""
self.stderr.close()
self.stdout.close()
@ -112,6 +119,10 @@ class StreamMixer:
class _NamedTextIOWrapper(io.TextIOWrapper):
"""
带名字的 TextIOWrapper
有些代码会检查 file.name (例如 `<stdin>`)为了兼容性需要这个包装器
"""
def __init__(
self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any
) -> None:
@ -131,7 +142,11 @@ class _NamedTextIOWrapper(io.TextIOWrapper):
def make_input_stream(
input: str | bytes | t.IO[t.Any] | None, charset: str
) -> t.BinaryIO:
# Is already an input stream.
"""
辅助函数将用户提供的输入可能是字符串字节或文件对象
统一转换为二进制输入流 (io.BytesIO)
"""
# 如果已经是输入流,尝试找到底层的二进制读取器
if hasattr(input, "read"):
rv = _find_binary_reader(t.cast("t.IO[t.Any]", input))
@ -149,25 +164,9 @@ def make_input_stream(
class Result:
"""Holds the captured result of an invoked CLI script.
:param runner: The runner that created the result
:param stdout_bytes: The standard output as bytes.
:param stderr_bytes: The standard error as bytes.
:param output_bytes: A mix of ``stdout_bytes`` and ``stderr_bytes``, as the
user would see it in its terminal.
:param return_value: The value returned from the invoked command.
:param exit_code: The exit code as integer.
:param exception: The exception that happened if one did.
:param exc_info: Exception information (exception type, exception instance,
traceback type).
.. versionchanged:: 8.2
``stderr_bytes`` no longer optional, ``output_bytes`` introduced and
``mix_stderr`` has been removed.
.. versionadded:: 8.0
Added ``return_value``.
"""
测试结果容器
保存了 `CliRunner.invoke` 执行后的所有状态
"""
def __init__(
@ -175,12 +174,12 @@ class Result:
runner: CliRunner,
stdout_bytes: bytes,
stderr_bytes: bytes,
output_bytes: bytes,
return_value: t.Any,
exit_code: int,
exception: BaseException | None,
output_bytes: bytes, # 混合后的输出
return_value: t.Any, # 命令行函数的返回值(如果有)
exit_code: int, # 退出码 (0 表示成功)
exception: BaseException | None, # 捕获的异常对象
exc_info: tuple[type[BaseException], BaseException, TracebackType]
| None = None,
| None = None, # 异常堆栈信息
):
self.runner = runner
self.stdout_bytes = stdout_bytes
@ -193,11 +192,8 @@ class Result:
@property
def output(self) -> str:
"""The terminal output as unicode string, as the user would see it.
.. versionchanged:: 8.2
No longer a proxy for ``self.stdout``. Now has its own independent stream
that is mixing `<stdout>` and `<stderr>`, in the order they were written.
"""
用户在终端看到的内容stdout + stderr 混合已解码为字符串
"""
return self.output_bytes.decode(self.runner.charset, "replace").replace(
"\r\n", "\n"
@ -205,18 +201,14 @@ class Result:
@property
def stdout(self) -> str:
"""The standard output as unicode string."""
"""标准输出(已解码为字符串)。"""
return self.stdout_bytes.decode(self.runner.charset, "replace").replace(
"\r\n", "\n"
)
@property
def stderr(self) -> str:
"""The standard error as unicode string.
.. versionchanged:: 8.2
No longer raise an exception, always returns the `<stderr>` string.
"""
"""标准错误(已解码为字符串)。"""
return self.stderr_bytes.decode(self.runner.charset, "replace").replace(
"\r\n", "\n"
)
@ -227,33 +219,19 @@ class Result:
class CliRunner:
"""The CLI runner provides functionality to invoke a Click command line
script for unittesting purposes in a isolated environment. This only
works in single-threaded systems without any concurrency as it changes the
global interpreter state.
:param charset: the character set for the input and output data.
:param env: a dictionary with environment variables for overriding.
:param echo_stdin: if this is set to `True`, then reading from `<stdin>` writes
to `<stdout>`. This is useful for showing examples in
some circumstances. Note that regular prompts
will automatically echo the input.
:param catch_exceptions: Whether to catch any exceptions other than
``SystemExit`` when running :meth:`~CliRunner.invoke`.
.. versionchanged:: 8.2
Added the ``catch_exceptions`` parameter.
.. versionchanged:: 8.2
``mix_stderr`` parameter has been removed.
"""
**核心类测试运行器**
用于在隔离环境中调用 Click 命令行脚本它不是线程安全的因为通过修改全局变量
(sys.stdout ) 来工作
"""
def __init__(
self,
charset: str = "utf-8",
env: cabc.Mapping[str, str | None] | None = None,
echo_stdin: bool = False,
catch_exceptions: bool = True,
echo_stdin: bool = False, # 是否回显输入
catch_exceptions: bool = True, # 是否捕获非 SystemExit 异常(设为 False 可以方便调试)
) -> None:
self.charset = charset
self.env: cabc.Mapping[str, str | None] = env or {}
@ -261,16 +239,13 @@ class CliRunner:
self.catch_exceptions = catch_exceptions
def get_default_prog_name(self, cli: Command) -> str:
"""Given a command object it will return the default program name
for it. The default is the `name` attribute or ``"root"`` if not
set.
"""
"""获取默认程序名 (例如 usage: root ...)"""
return cli.name or "root"
def make_env(
self, overrides: cabc.Mapping[str, str | None] | None = None
) -> cabc.Mapping[str, str | None]:
"""Returns the environment overrides for invoking a script."""
"""合并环境变量"""
rv = dict(self.env)
if overrides:
rv.update(overrides)
@ -283,64 +258,49 @@ class CliRunner:
env: cabc.Mapping[str, str | None] | None = None,
color: bool = False,
) -> cabc.Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]:
"""A context manager that sets up the isolation for invoking of a
command line tool. This sets up `<stdin>` with the given input data
and `os.environ` with the overrides from the given dictionary.
This also rebinds some internals in Click to be mocked (like the
prompt functionality).
This is automatically done in the :meth:`invoke` method.
:param input: the input stream to put into `sys.stdin`.
:param env: the environment overrides as dictionary.
:param color: whether the output should contain color codes. The
application can still override this explicitly.
.. versionadded:: 8.2
An additional output stream is returned, which is a mix of
`<stdout>` and `<stderr>` streams.
.. versionchanged:: 8.2
Always returns the `<stderr>` stream.
.. versionchanged:: 8.0
`<stderr>` is opened with ``errors="backslashreplace"``
instead of the default ``"strict"``.
.. versionchanged:: 4.0
Added the ``color`` parameter.
"""
关键方法环境隔离上下文管理器
这个方法执行了真正的黑魔法
1. 备份旧的 sys.stdin/out/err
2. 创建内存中的 BytesIO 流来替代标准流
3. 劫持 `termui` 模块中的 prompt getchar 函数以便在测试中注入输入
4. 设置临时的环境变量
"""
bytes_input = make_input_stream(input, self.charset)
echo_input = None
# 1. 备份全局状态
old_stdin = sys.stdin
old_stdout = sys.stdout
old_stderr = sys.stderr
old_forced_width = formatting.FORCED_WIDTH
formatting.FORCED_WIDTH = 80
formatting.FORCED_WIDTH = 80 # 强制终端宽度为 80保证测试输出格式一致
env = self.make_env(env)
stream_mixer = StreamMixer()
# 处理输入回显逻辑
if self.echo_stdin:
bytes_input = echo_input = t.cast(
t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout)
)
# 2. 替换 sys 标准流为我们构造的内存流
sys.stdin = text_input = _NamedTextIOWrapper(
bytes_input, encoding=self.charset, name="<stdin>", mode="r"
)
if self.echo_stdin:
# Force unbuffered reads, otherwise TextIOWrapper reads a
# large chunk which is echoed early.
# 强制无缓冲读取,避免一次读取过多导致回显过早
text_input._CHUNK_SIZE = 1 # type: ignore
sys.stdout = _NamedTextIOWrapper(
stream_mixer.stdout, encoding=self.charset, name="<stdout>", mode="w"
)
# stderr 默认使用 backslashreplace 错误处理,防止编码错误导致测试崩溃
sys.stderr = _NamedTextIOWrapper(
stream_mixer.stderr,
encoding=self.charset,
@ -349,10 +309,14 @@ class CliRunner:
errors="backslashreplace",
)
# --- 以下函数用于劫持 termui 模块中的交互函数 ---
# 模拟 visible_prompt_func (普通输入)
@_pause_echo(echo_input) # type: ignore
def visible_input(prompt: str | None = None) -> str:
sys.stdout.write(prompt or "")
try:
# 从预设的 input 流中读取一行
val = next(text_input).rstrip("\r\n")
except StopIteration as e:
raise EOFError() from e
@ -360,6 +324,7 @@ class CliRunner:
sys.stdout.flush()
return val
# 模拟 hidden_prompt_func (密码输入)
@_pause_echo(echo_input) # type: ignore
def hidden_input(prompt: str | None = None) -> str:
sys.stdout.write(f"{prompt or ''}\n")
@ -369,18 +334,18 @@ class CliRunner:
except StopIteration as e:
raise EOFError() from e
# 模拟 getchar (读取单个字符)
@_pause_echo(echo_input) # type: ignore
def _getchar(echo: bool) -> str:
char = sys.stdin.read(1)
if echo:
sys.stdout.write(char)
sys.stdout.flush()
return char
default_color = color
# 模拟颜色剥离逻辑
def should_strip_ansi(
stream: t.IO[t.Any] | None = None, color: bool | None = None
) -> bool:
@ -388,17 +353,20 @@ class CliRunner:
return not default_color
return not color
# 3. 备份并替换 termui 和 utils 中的函数
old_visible_prompt_func = termui.visible_prompt_func
old_hidden_prompt_func = termui.hidden_prompt_func
old__getchar_func = termui._getchar
old_should_strip_ansi = utils.should_strip_ansi # type: ignore
old__compat_should_strip_ansi = _compat.should_strip_ansi
termui.visible_prompt_func = visible_input
termui.hidden_prompt_func = hidden_input
termui._getchar = _getchar
utils.should_strip_ansi = should_strip_ansi # type: ignore
_compat.should_strip_ansi = should_strip_ansi
# 4. 设置环境变量
old_env = {}
try:
for key, value in env.items():
@ -410,8 +378,10 @@ class CliRunner:
pass
else:
os.environ[key] = value
# Yield 出去,此时环境已隔离,用户代码在这里运行
yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output)
finally:
# 5. 清理与还原:恢复环境变量和全局函数
for key, value in old_env.items():
if value is None:
try:
@ -440,58 +410,24 @@ class CliRunner:
color: bool = False,
**extra: t.Any,
) -> Result:
"""Invokes a command in an isolated environment. The arguments are
forwarded directly to the command line script, the `extra` keyword
arguments are passed to the :meth:`~clickpkg.Command.main` function of
the command.
This returns a :class:`Result` object.
:param cli: the command to invoke
:param args: the arguments to invoke. It may be given as an iterable
or a string. When given as string it will be interpreted
as a Unix shell command. More details at
:func:`shlex.split`.
:param input: the input data for `sys.stdin`.
:param env: the environment overrides.
:param catch_exceptions: Whether to catch any other exceptions than
``SystemExit``. If :data:`None`, the value
from :class:`CliRunner` is used.
:param extra: the keyword arguments to pass to :meth:`main`.
:param color: whether the output should contain color codes. The
application can still override this explicitly.
.. versionadded:: 8.2
The result object has the ``output_bytes`` attribute with
the mix of ``stdout_bytes`` and ``stderr_bytes``, as the user would
see it in its terminal.
.. versionchanged:: 8.2
The result object always returns the ``stderr_bytes`` stream.
.. versionchanged:: 8.0
The result object has the ``return_value`` attribute with
the value returned from the invoked command.
.. versionchanged:: 4.0
Added the ``color`` parameter.
.. versionchanged:: 3.0
Added the ``catch_exceptions`` parameter.
.. versionchanged:: 3.0
The result object has the ``exc_info`` attribute with the
traceback if available.
"""
调用命令的主入口
:param cli: 要运行的 Click 命令对象
:param args: 命令行参数字符串或列表
:param input: 模拟用户的输入数据字符串或字节
"""
exc_info = None
if catch_exceptions is None:
catch_exceptions = self.catch_exceptions
# 进入隔离环境
with self.isolation(input=input, env=env, color=color) as outstreams:
return_value = None
exception: BaseException | None = None
exit_code = 0
# 处理参数:如果是字符串(如 "run --host=localhost"),则用 shlex 切分
if isinstance(args, str):
args = shlex.split(args)
@ -501,8 +437,10 @@ class CliRunner:
prog_name = self.get_default_prog_name(cli)
try:
# 真正执行命令!调用 click.Command.main()
return_value = cli.main(args=args or (), prog_name=prog_name, **extra)
except SystemExit as e:
# 捕获正常退出 (sys.exit())
exc_info = sys.exc_info()
e_code = t.cast("int | t.Any | None", e.code)
@ -512,6 +450,7 @@ class CliRunner:
if e_code != 0:
exception = e
# 如果 exit code 不是整数,打印它并返回 1
if not isinstance(e_code, int):
sys.stdout.write(str(e_code))
sys.stdout.write("\n")
@ -520,18 +459,22 @@ class CliRunner:
exit_code = e_code
except Exception as e:
# 捕获其他异常(程序崩溃)
if not catch_exceptions:
raise
exception = e
exit_code = 1
exc_info = sys.exc_info()
finally:
# 确保所有内容都已写入流
sys.stdout.flush()
sys.stderr.flush()
# 获取捕获到的输出内容
stdout = outstreams[0].getvalue()
stderr = outstreams[1].getvalue()
output = outstreams[2].getvalue()
# 返回结果对象
return Result(
runner=self,
stdout_bytes=stdout,
@ -547,17 +490,12 @@ class CliRunner:
def isolated_filesystem(
self, temp_dir: str | os.PathLike[str] | None = None
) -> cabc.Iterator[str]:
"""A context manager that creates a temporary directory and
changes the current working directory to it. This isolates tests
that affect the contents of the CWD to prevent them from
interfering with each other.
:param temp_dir: Create the temporary directory under this
directory. If given, the created directory is not removed
when exiting.
.. versionchanged:: 8.0
Added the ``temp_dir`` parameter.
"""
文件系统隔离上下文管理器
创建一个临时目录 cd 进去
测试结束后自动切回原来的目录并删除临时目录
这对于测试生成文件读取配置等涉及文件操作的命令非常有用
"""
cwd = os.getcwd()
dt = tempfile.mkdtemp(dir=temp_dir)
@ -568,10 +506,11 @@ class CliRunner:
finally:
os.chdir(cwd)
# 如果不是用户指定的 temp_dir则自动删除
if temp_dir is None:
import shutil
try:
shutil.rmtree(dt)
except OSError:
pass
pass

File diff suppressed because it is too large Load Diff

@ -9,6 +9,7 @@ from functools import update_wrapper
from types import ModuleType
from types import TracebackType
# 导入内部兼容性模块,处理 Python 2/3 和不同操作系统的差异
from ._compat import _default_text_stderr
from ._compat import _default_text_stdout
from ._compat import _find_binary_writer
@ -30,12 +31,18 @@ R = t.TypeVar("R")
def _posixify(name: str) -> str:
"""
[内部工具] 将名称标准化为 POSIX 风格小写空格变连字符
用于生成配置文件目录名例如 "My App" -> "my-app"
"""
return "-".join(name.split()).lower()
def safecall(func: t.Callable[P, R]) -> t.Callable[P, R | None]:
"""Wraps a function so that it swallows exceptions."""
"""
装饰器包装一个函数使其吞掉所有异常并返回 None
通常用于析构函数或清理操作如关闭文件即使失败也不应该让程序崩溃
"""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
try:
return func(*args, **kwargs)
@ -47,7 +54,10 @@ def safecall(func: t.Callable[P, R]) -> t.Callable[P, R | None]:
def make_str(value: t.Any) -> str:
"""Converts a value into a valid string."""
"""
强健的字符串转换函数
尝试使用文件系统编码将 bytes 解码为 str如果失败则回退到 UTF-8 replace 模式
"""
if isinstance(value, bytes):
try:
return value.decode(sys.getfilesystemencoding())
@ -57,20 +67,28 @@ def make_str(value: t.Any) -> str:
def make_default_short_help(help: str, max_length: int = 45) -> str:
"""Returns a condensed version of help string."""
# Consider only the first paragraph.
"""
[帮助信息处理]
从完整的 help 字符串中提取简短的摘要用于 ` --help` 命令列表显示
逻辑
1. 只取第一段
2. 截断到 max_length 长度
3. 如果在句号处截断不加 "..."否则加 "..."
"""
# 只考虑第一段
paragraph_end = help.find("\n\n")
if paragraph_end != -1:
help = help[:paragraph_end]
# Collapse newlines, tabs, and spaces.
# 合并换行、制表符和空格
words = help.split()
if not words:
return ""
# The first paragraph started with a "no rewrap" marker, ignore it.
# 如果第一段以 "\b" 开头Click 的不换行标记),忽略它
if words[0] == "\b":
words = words[1:]
@ -80,21 +98,21 @@ def make_default_short_help(help: str, max_length: int = 45) -> str:
for i, word in enumerate(words):
total_length += len(word) + (i > 0)
if total_length > max_length: # too long, truncate
if total_length > max_length: # 太长了,截断
break
if word[-1] == ".": # sentence end, truncate without "..."
if word[-1] == ".": # 句子结束,不需要 "..."
return " ".join(words[: i + 1])
if total_length == max_length and i != last_index:
break # not at sentence end, truncate with "..."
break # 不是句子结尾但长度到了,截断并加 "..."
else:
return " ".join(words) # no truncation needed
return " ".join(words) # 不需要截断
# Account for the length of the suffix.
# 预留 "..." 的长度
total_length += len("...")
# remove words until the length is short enough
# 移除单词直到长度合适
while i > 0:
total_length -= len(words[i]) + (i > 0)
@ -107,10 +125,19 @@ def make_default_short_help(help: str, max_length: int = 45) -> str:
class LazyFile:
"""A lazy file works like a regular file but it does not fully open
the file but it does perform some basic checks early to see if the
filename parameter does make sense. This is useful for safely opening
files for writing.
"""
[核心类] 惰性文件对象
作用
类似于普通文件但它不会立即执行 `open()`直到第一次进行 IO 操作或属性访问时才打开
场景
这对于写入文件非常有用如果用户指定了一个输出文件但程序在写入前就出错了
LazyFile 可以避免创建一个空的或破坏现有的文件
特性
- 支持 '-' 代表 stdin/stdout
- 绑定到 Context 可以智能关闭标准流不关普通文件关闭
"""
def __init__(
@ -130,17 +157,18 @@ class LazyFile:
self.should_close: bool
if self.name == "-":
# 如果是标准流,立即关联
self._f, self.should_close = open_stream(filename, mode, encoding, errors)
else:
# 如果是读模式,尝试立即打开并关闭一次,以快速检查文件是否存在/权限是否正确。
# 这是一个“快速失败”机制,避免程序运行很久后才发现文件读不了。
if "r" in mode:
# Open and close the file in case we're opening it for
# reading so that we can catch at least some errors in
# some cases early.
open(filename, mode).close()
self._f = None
self.should_close = True
def __getattr__(self, name: str) -> t.Any:
# 访问任何属性(如 .write, .read时触发打开
return getattr(self.open(), name)
def __repr__(self) -> str:
@ -149,10 +177,7 @@ class LazyFile:
return f"<unopened file '{format_filename(self.name)}' {self.mode}>"
def open(self) -> t.IO[t.Any]:
"""Opens the file if it's not yet open. This call might fail with
a :exc:`FileError`. Not handling this error will produce an error
that Click shows.
"""
"""打开文件(如果尚未打开)。"""
if self._f is not None:
return self._f
try:
@ -161,19 +186,21 @@ class LazyFile:
)
except OSError as e:
from .exceptions import FileError
# 将 OSError 转换为 Click 友好的 FileError
raise FileError(self.name, hint=e.strerror) from e
self._f = rv
return rv
def close(self) -> None:
"""Closes the underlying file, no matter what."""
"""强制关闭底层文件。"""
if self._f is not None:
self._f.close()
def close_intelligently(self) -> None:
"""This function only closes the file if it was opened by the lazy
file wrapper. For instance this will never close stdin.
"""
智能关闭
如果是通过 open_stream 打开的普通文件则关闭
如果是 stdin/stdout则不关闭防止破坏后续输出
"""
if self.should_close:
self.close()
@ -195,6 +222,11 @@ class LazyFile:
class KeepOpenFile:
"""
上下文管理器包装器
防止在 `with` 语句块结束时关闭文件
用于保护 stdin/stdout 不被意外关闭
"""
def __init__(self, file: t.IO[t.Any]) -> None:
self._file: t.IO[t.Any] = file
@ -210,6 +242,7 @@ class KeepOpenFile:
exc_value: BaseException | None,
tb: TracebackType | None,
) -> None:
# 故意不做任何事(不调用 close
pass
def __repr__(self) -> str:
@ -226,43 +259,21 @@ def echo(
err: bool = False,
color: bool | None = None,
) -> None:
"""Print a message and newline to stdout or a file. This should be
used instead of :func:`print` because it provides better support
for different data, files, and environments.
Compared to :func:`print`, this does the following:
- Ensures that the output encoding is not misconfigured on Linux.
- Supports Unicode in the Windows console.
- Supports writing to binary outputs, and supports writing bytes
to text outputs.
- Supports colors and styles on Windows.
- Removes ANSI color and style codes if the output does not look
like an interactive terminal.
- Always flushes the output.
:param message: The string or bytes to output. Other objects are
converted to strings.
:param file: The file to write to. Defaults to ``stdout``.
:param err: Write to ``stderr`` instead of ``stdout``.
:param nl: Print a newline after the message. Enabled by default.
:param color: Force showing or hiding colors and other styles. By
default Click will remove color if the output does not look like
an interactive terminal.
.. versionchanged:: 6.0
Support Unicode output on the Windows console. Click does not
modify ``sys.stdout``, so ``sys.stdout.write()`` and ``print()``
will still not support Unicode.
.. versionchanged:: 4.0
Added the ``color`` parameter.
.. versionadded:: 3.0
Added the ``err`` parameter.
.. versionchanged:: 2.0
Support colors on Windows if colorama is installed.
"""
[核心功能] 打印消息到 stdout/stderr 或文件
这是 Click Python `print` 的增强替代品它解决了以下痛点
1. **编码问题**Linux locale 配置错误时的 Unicode 错误
2. **Windows 兼容性**支持 Windows 控制台的 Unicode 输出
3. **颜色支持**检测是否是 TTY如果在管道中运行则自动剥离 ANSI 颜色码 Windows 上调用 API 支持颜色
4. **二进制安全**可以写入 bytes stdout
5. **强制刷新**默认 flush防止输出滞后
:param message: 要打印的内容
:param file: 目标文件默认为 stdout
:param err: 如果为 True默认使用 stderr
:param nl: 是否在末尾追加换行符
:param color: 强制开启/关闭颜色None 表示自动检测
"""
if file is None:
if err:
@ -270,12 +281,11 @@ def echo(
else:
file = _default_text_stdout()
# There are no standard streams attached to write to. For example,
# pythonw on Windows.
# 如果没有标准流(例如 pythonw windows GUI 模式),直接返回
if file is None:
return
# Convert non bytes/text into the native string type.
# 转换非 bytes/text 对象为字符串
if message is not None and not isinstance(message, (str, bytes, bytearray)):
out: str | bytes | bytearray | None = str(message)
else:
@ -292,10 +302,8 @@ def echo(
file.flush()
return
# If there is a message and the value looks like bytes, we manually
# need to find the binary stream and write the message in there.
# This is done separately so that most stream types will work as you
# would expect. Eg: you can write to StringIO for other cases.
# 二进制写入处理:如果 message 是 bytes需要找到底层的 buffer 进行写入
# 避免将 bytes 写入 TextIO 导致错误
if isinstance(out, (bytes, bytearray)):
binary_file = _find_binary_writer(file)
@ -305,14 +313,16 @@ def echo(
binary_file.flush()
return
# ANSI style code support. For no message or bytes, nothing happens.
# When outputting to a file instead of a terminal, strip codes.
# 文本/ANSI 处理
else:
color = resolve_color_default(color)
# 核心逻辑:是否剥离 ANSI 颜色码
# 如果不是 TTY (例如重定向到文件) 且没有强制开启颜色 -> 剥离
if should_strip_ansi(file, color):
out = strip_ansi(out)
elif WIN:
# Windows 特殊处理:使用 colorama 或相关 API 包装流以支持 ANSI
if auto_wrap_for_ansi is not None:
file = auto_wrap_for_ansi(file, color) # type: ignore
elif not color:
@ -323,11 +333,7 @@ def echo(
def get_binary_stream(name: t.Literal["stdin", "stdout", "stderr"]) -> t.BinaryIO:
"""Returns a system stream for byte processing.
:param name: the name of the stream to open. Valid names are ``'stdin'``,
``'stdout'`` and ``'stderr'``
"""
"""获取系统原始的二进制流 (buffer)"""
opener = binary_streams.get(name)
if opener is None:
raise TypeError(f"Unknown standard stream '{name}'")
@ -339,16 +345,7 @@ def get_text_stream(
encoding: str | None = None,
errors: str | None = "strict",
) -> t.TextIO:
"""Returns a system stream for text processing. This usually returns
a wrapped stream around a binary stream returned from
:func:`get_binary_stream` but it also can take shortcuts for already
correctly configured streams.
:param name: the name of the stream to open. Valid names are ``'stdin'``,
``'stdout'`` and ``'stderr'``
:param encoding: overrides the detected default encoding.
:param errors: overrides the default error mode.
"""
"""获取系统文本流,允许覆盖编码"""
opener = text_streams.get(name)
if opener is None:
raise TypeError(f"Unknown standard stream '{name}'")
@ -363,33 +360,14 @@ def open_file(
lazy: bool = False,
atomic: bool = False,
) -> t.IO[t.Any]:
"""Open a file, with extra behavior to handle ``'-'`` to indicate
a standard stream, lazy open on write, and atomic write. Similar to
the behavior of the :class:`~click.File` param type.
If ``'-'`` is given to open ``stdout`` or ``stdin``, the stream is
wrapped so that using it in a context manager will not close it.
This makes it possible to use the function without accidentally
closing a standard stream:
.. code-block:: python
with open_file(filename) as f:
...
:param filename: The name or Path of the file to open, or ``'-'`` for
``stdin``/``stdout``.
:param mode: The mode in which to open the file.
:param encoding: The encoding to decode or encode a file opened in
text mode.
:param errors: The error handling mode.
:param lazy: Wait to open the file until it is accessed. For read
mode, the file is temporarily opened to raise access errors
early, then closed until it is read again.
:param atomic: Write to a temporary file and replace the given file
on close.
.. versionadded:: 3.0
"""
[公共 API] 打开文件的推荐方式
相比 Python 原生 `open()` 的增强
1. 处理 '-' 为标准输入/输出
2. 支持 `lazy=True` 延迟打开
3. 支持 `atomic=True` 原子写入写入临时文件完成后重命名
4. 自动处理标准流的防止关闭逻辑 (KeepOpenFile)
"""
if lazy:
return t.cast(
@ -398,6 +376,8 @@ def open_file(
f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic)
# 如果打开的是标准流should_close=False使用 KeepOpenFile 包装
# 这样用户在 `with open_file(...)` 块结束时不会意外关闭 stdout
if not should_close:
f = t.cast("t.IO[t.Any]", KeepOpenFile(f))
@ -408,28 +388,10 @@ def format_filename(
filename: str | bytes | os.PathLike[str] | os.PathLike[bytes],
shorten: bool = False,
) -> str:
"""Format a filename as a string for display. Ensures the filename can be
displayed by replacing any invalid bytes or surrogate escapes in the name
with the replacement character ``<EFBFBD>``.
Invalid bytes or surrogate escapes will raise an error when written to a
stream with ``errors="strict"``. This will typically happen with ``stdout``
when the locale is something like ``en_GB.UTF-8``.
Many scenarios *are* safe to write surrogates though, due to PEP 538 and
PEP 540, including:
- Writing to ``stderr``, which uses ``errors="backslashreplace"``.
- The system has ``LANG=C.UTF-8``, ``C``, or ``POSIX``. Python opens
stdout and stderr with ``errors="surrogateescape"``.
- None of ``LANG/LC_*`` are set. Python assumes ``LANG=C.UTF-8``.
- Python is started in UTF-8 mode with ``PYTHONUTF8=1`` or ``-X utf8``.
Python opens stdout and stderr with ``errors="surrogateescape"``.
:param filename: formats a filename for UI display. This will also convert
the filename into unicode without failing.
:param shorten: this optionally shortens the filename to strip of the
path that leads up to it.
"""
格式化文件名用于显示
主要用于处理包含非法 Unicode 字符的文件名将其替换为 '' (U+FFFD)
防止在打印错误信息时因为编码问题再次抛出异常
"""
if shorten:
filename = os.path.basename(filename)
@ -446,36 +408,17 @@ def format_filename(
return filename
def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str:
r"""Returns the config folder for the application. The default behavior
is to return whatever is most appropriate for the operating system.
To give you an idea, for an app called ``"Foo Bar"``, something like
the following folders could be returned:
Mac OS X:
``~/Library/Application Support/Foo Bar``
Mac OS X (POSIX):
``~/.foo-bar``
Unix:
``~/.config/foo-bar``
Unix (POSIX):
``~/.foo-bar``
Windows (roaming):
``C:\Users\<user>\AppData\Roaming\Foo Bar``
Windows (not roaming):
``C:\Users\<user>\AppData\Local\Foo Bar``
.. versionadded:: 2.0
:param app_name: the application name. This should be properly capitalized
and can contain whitespace.
:param roaming: controls if the folder should be roaming or not on Windows.
Has no effect otherwise.
:param force_posix: if this is set to `True` then on any POSIX system the
folder will be stored in the home folder with a leading
dot instead of the XDG config home or darwin's
application support folder.
r"""
[核心功能] 获取应用程序的配置目录路径
跨平台逻辑
- Windows (Roaming): C:\Users\<user>\AppData\Roaming\App Name
- Windows (Local): C:\Users\<user>\AppData\Local\App Name
- Mac OS X: ~/Library/Application Support/App Name
- Linux/Unix (XDG): ~/.config/app-name (遵循 XDG Base Directory 规范)
- POSIX Fallback: ~/.app-name
"""
if WIN:
key = "APPDATA" if roaming else "LOCALAPPDATA"
@ -496,12 +439,13 @@ def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False)
class PacifyFlushWrapper:
"""This wrapper is used to catch and suppress BrokenPipeErrors resulting
from ``.flush()`` being called on broken pipe during the shutdown/final-GC
of the Python interpreter. Notably ``.flush()`` is always called on
``sys.stdout`` and ``sys.stderr``. So as to have minimal impact on any
other cleanup code, and the case where the underlying file is not a broken
pipe, all calls and attributes are proxied.
"""
这是一个特殊的包装器用于处理 BrokenPipeError (errno.EPIPE)
场景
当你运行 `python myscript.py | head -n 1` `head` 读完第一行就关闭了管道
Python 脚本尝试继续写入或在退出时刷新 stdout会触发 BrokenPipeError
这个类会捕获并忽略这个特定的错误使程序能优雅退出而不是打印 traceback
"""
def __init__(self, wrapped: t.IO[t.Any]) -> None:
@ -523,25 +467,13 @@ class PacifyFlushWrapper:
def _detect_program_name(
path: str | None = None, _main: ModuleType | None = None
) -> str:
"""Determine the command used to run the program, for use in help
text. If a file or entry point was executed, the file name is
returned. If ``python -m`` was used to execute a module or package,
``python -m name`` is returned.
This doesn't try to be too precise, the goal is to give a concise
name for help text. Files are only shown as their name without the
path. ``python`` is only shown for modules, and the full path to
``sys.executable`` is not shown.
:param path: The Python file being executed. Python puts this in
``sys.argv[0]``, which is used by default.
:param _main: The ``__main__`` module. This should only be passed
during internal testing.
.. versionadded:: 8.0
Based on command args detection in the Werkzeug reloader.
:meta private:
"""
[内部工具] 检测程序是如何被调用的用于生成帮助信息中的 "Usage: ..." 部分
能够区分
- 直接执行文件: `python app.py` -> `app.py`
- 模块执行: `python -m mylib.cli` -> `python -m mylib.cli`
- Entry Point / Exe: `my-cli` -> `my-cli`
"""
if _main is None:
_main = sys.modules["__main__"]
@ -549,26 +481,20 @@ def _detect_program_name(
if not path:
path = sys.argv[0]
# The value of __package__ indicates how Python was called. It may
# not exist if a setuptools script is installed as an egg. It may be
# set incorrectly for entry points created with pip on Windows.
# It is set to "" inside a Shiv or PEX zipapp.
# 判断是否作为包执行
if getattr(_main, "__package__", None) in {None, ""} or (
os.name == "nt"
and _main.__package__ == ""
and not os.path.exists(path)
and os.path.exists(f"{path}.exe")
):
# Executed a file, like "python app.py".
# 执行的是文件
return os.path.basename(path)
# Executed a module, like "python -m example".
# Rewritten by Python from "-m script" to "/path/to/script.py".
# Need to look at main module to determine how it was executed.
# 执行的是模块 (python -m ...)
py_module = t.cast(str, _main.__package__)
name = os.path.splitext(os.path.basename(path))[0]
# A submodule like "example.cli".
if name != "__main__":
py_module = f"{py_module}.{name}"
@ -582,26 +508,15 @@ def _expand_args(
env: bool = True,
glob_recursive: bool = True,
) -> list[str]:
"""Simulate Unix shell expansion with Python functions.
See :func:`glob.glob`, :func:`os.path.expanduser`, and
:func:`os.path.expandvars`.
This is intended for use on Windows, where the shell does not do any
expansion. It may not exactly match what a Unix shell would do.
:param args: List of command line arguments to expand.
:param user: Expand user home directory.
:param env: Expand environment variables.
:param glob_recursive: ``**`` matches directories recursively.
.. versionchanged:: 8.1
Invalid glob patterns are treated as empty expansions rather
than raising an error.
.. versionadded:: 8.0
:meta private:
"""
模拟 Unix Shell 的参数展开行为
主要用于 Windows因为 Windows CMD/PowerShell 默认不展开通配符
Unix Shell (Bash/Zsh) 会在传递给程序前自动展开
功能
- user=True: 展开 `~` (home目录)
- env=True: 展开环境变量 `$VAR`
- glob: 展开 `*.txt`, `**/*.py` 等通配符
"""
from glob import glob
@ -624,4 +539,4 @@ def _expand_args(
else:
out.extend(matches)
return out
return out
Loading…
Cancel
Save