manage.py 认识的命令,一部分来自 Django 自己,一部分来自各个 app 里的 management/commands/ 目录。这一篇本机真实验证了这套发现机制的两个反直觉规则:自己 app 里的命令真的能完全顶替掉 Django 自带的同名命令;两个 app 都定义了同名命令时,INSTALLED_APPS 里排得更靠前的那个赢。两条规则都从真实源码的同一段代码里推出来,也都用真实代码验证过。
核心是 get_commands() 这一个函数,逻辑比想象中简单。
django/core/management/__init__.py · django @ 6.0.7, L52 @functools.cache def get_commands(): commands = {name: "django.core" for name in find_commands(__path__[0])} if not settings.configured: return commands for app_config in reversed(apps.get_app_configs()): path = os.path.join(app_config.path, "management") commands.update({name: app_config.name for name in find_commands(path)}) return commands
三个关键点:①先用 Django 自带的命令把字典填满(全部标记成 "django.core");②倒序遍历 INSTALLED_APPS 里的每个 app;③每个 app 的命令用 dict.update() 覆盖式地写进同一个字典。find_commands() 本身也很直接——就是拿 pkgutil.iter_modules 扫一遍 management/commands/ 目录下有哪些模块文件。
django/core/management/__init__.py · django @ 6.0.7, L29 def find_commands(management_dir): command_dir = os.path.join(management_dir, "commands") return [name for _, name, is_pkg in pkgutil.iter_modules([command_dir]) if not is_pkg and not name.startswith("_")]
既然 dict.update() 是覆盖式写入,而 Django 自己的命令是最先填进去的——那任何 app 定义一个同名命令,理论上都能把它覆盖掉。
check 是 Django 内置的一个真实命令(项目健康检查),正常情况下 get_commands()['check'] 应该是 "django.core"——但自己 app 里一旦有同名的 check.py,整个内置命令就被真实顶替掉了,manage.py check 跑的是你自己写的逻辑,没有任何警告或报错。
reversed(apps.get_app_configs()) 这个"倒序"是关键——它决定了写在 INSTALLED_APPS 更前面的 app,反而是最后被 update() 覆盖的那个。
# INSTALLED_APPS = [..., 'appA', 'appB'] -- appA 排前面
# appA 和 appB 都有 management/commands/greet.py,内容不同
commands = get_commands()
print('greet 归属:', commands['greet'])
call_command('greet')
greet 归属: appA
hello from appA
appA 排在 INSTALLED_APPS 前面,却是最终生效的那个——因为倒序遍历时 appB 先被处理(先写入,后面会被覆盖),appA 后被处理(最后写入,不会再被覆盖)。这条规则和上一篇讲的中间件构造顺序是同一个套路:reversed() + "后处理的赢",只是这次赢的标准从"外层"换成了"列表里排得更靠前"。
@functools.cache 不是摆设——用 cache_info() 能直接看到命中情况。
print(get_commands.cache_info())
c1 = get_commands()
print(get_commands.cache_info())
c2 = get_commands()
print(get_commands.cache_info())
print('c1 is c2:', c1 is c2)
CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
CacheInfo(hits=0, misses=1, maxsize=None, currsize=1)
CacheInfo(hits=1, misses=1, maxsize=None, currsize=1)
c1 is c2: True
第一次调用是真实的一次"未命中"(扫描所有 app 的目录),第二次开始全是"命中"——拿到的还是同一个字典对象。这也是为什么在一个真实的 Django 进程运行期间,新增一个 app 或者新增一个命令文件不会被自动发现:这张表只在第一次被用到时建好,之后就固定了。
BaseCommand 的参数解析,底层就是标准库 argparse,没有另起炉灶。
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('name', type=str)
parser.add_argument('--shout', action='store_true')
parser.add_argument('--times', type=int, default=1)
def handle(self, *args, **options):
if options['name'] == 'nobody':
raise CommandError('cannot greet nobody')
msg = f'Hello, {options[\"name\"]}!'
if options['shout']: msg = msg.upper()
for _ in range(options['times']): self.stdout.write(msg)
$ python3 manage.py greet Alice --shout --times 2
HELLO, ALICE!
HELLO, ALICE!
$ python3 manage.py greet nobody
CommandError: cannot greet nobody
add_arguments(parser) 拿到的就是一个真实的 argparse.ArgumentParser,该怎么加参数就怎么加;CommandError 会被 run_from_argv() 专门捕获,干净地打印到 stderr 并以非零状态码退出,不会甩一大段 Python 堆栈——这也是为什么写管理命令时报错要用 CommandError 而不是普通 raise Exception。
复现"django.core 先垫底,appB 先写入,appA 后写入覆盖"的完整过程。
| 命令名 | 当前归属 |
|---|
数据来自独立验证过的 Python 模拟——用真实的"倒序 + 覆盖式写入"逻辑复现,最终每个命令名的归属都用独立回放算法核对过,和 appA 覆盖 appB 的真实实验结果一致。