conda install
#
在本文档中,我们将探讨从用户键入安装命令的那一刻起,到该过程成功完成为止,Conda 中发生了什么。为了完整起见,我们将考虑以下情况
用户正在 Linux x64 机器上运行命令,该机器上安装了可用的 Miniconda。
这意味着我们有一个
base
环境,其中包含conda
、python
及其依赖项。对于 Bash,
base
环境已预先激活。有关激活的更多详细信息,请查看 conda init 和 conda activate。
好的,那么……当您运行 conda install numpy
时会发生什么?大体上,这些步骤
命令行界面
argparse
解析器环境变量
配置文件
上下文初始化
任务的委派
获取索引
检索所有通道和平台
关于通道优先级的说明
解决安装请求
请求的软件包 + 前缀状态 = 软件包规范列表
索引缩减(有时)
运行求解器
后处理软件包列表
生成事务和相应的操作
下载和解压缩
完整性验证
链接和取消链接文件
链接后和激活后任务
命令行界面#
首先,快速说明一下可能不明显的实现细节。
当您在终端中键入 conda install numpy
时,Bash 会获取这三个词并查找一个 conda
命令来传递一个参数列表 ['conda', 'install', 'numpy']
。在找到位于 CONDA_HOME/condabin
的 conda
可执行文件之前,它可能会找到 此处 定义的 shell 函数。如果需要,此 shell 函数会在 shell 上运行激活/停用逻辑,否则会委托给实际的 Python 入口点。此部分逻辑可以在 conda.shell
中找到。
一旦我们运行了 Python 入口点,我们就进入了 conda.cli
领域。入口点调用的函数是 conda.cli.main:main()
。在这里,对 shell.*
子命令进行了另一次检查,它会在 ~/.bashrc
等中生成您看到的 shell 初始化器。如果您想知道这是在哪里发生的,那就是 conda.activate
。
由于我们的命令是 conda install ...
,因此我们仍然需要到达其他地方。您会注意到,其余逻辑被委托给 conda.cli.main:_main()
,它将调用解析器生成器、初始化上下文和记录器,并最终将参数列表传递给相应的命令函数。这四个步骤在四个函数/类中实现
conda.cli.conda_argparse:generate_parser()
: 此函数使用argparse
生成 CLI。每个子命令都在单独的函数中初始化。请注意,命令行选项不是从Context
对象动态生成的,而是手动注释的。如果需要动态生成(例如,--repodata-fn
在Context.repodata_fn
中公开),每个 CLI 选项的dest
变量应 与上下文对象中的目标属性匹配。conda.base.context.Context
: 此对象存储conda
中的配置选项,并在初始化时考虑上述步骤中解析的参数,以及其他因素。 这将在单独的深入探讨中详细介绍:conda 配置和上下文。conda.gateways.logging:initialize_logging()
: 这部分代码比较简单明了,无需过多解释。conda.cli.conda_argparse:do_call()
: 解析后的参数将填充一个func
值,该值包含负责该子命令的函数的导入路径。 例如,conda install
由 由conda.cli.main_install
处理。 根据设计,func
报告的所有模块都必须包含一个execute()
函数来实现命令逻辑。execute()
将解析后的参数和解析器本身作为参数。 例如,在conda install
的情况下,execute()
只是 重定向 到conda.cli.install:install()
中的特定模式。
现在让我们看看那个模块。 conda.cli.install:install()
实现 conda create
、conda install
、conda update
和 conda remove
背后的逻辑。 本质上,它们都处理同一任务:更改环境中存在的软件包。 如果您阅读该函数,您将看到有几行代码处理不同的情况(新环境、克隆等),然后我们进入下一部分。 我们在这里不讨论它们,但您可以随意探索 那一部分。 它主要确保目标前缀存在,无论我们是在创建一个新环境还是调整一些命令行标志,这些标志允许我们跳过求解器(例如 --clone
)。
关于环境的更多信息
查看 环境 的概念。
获取索引#
现在,我们已准备好开始工作! 所有之前的代码都在告诉我们该做什么,现在我们知道了。 我们希望 conda
在我们的 base
环境中安装 numpy
。 我们需要知道的第一个问题是,我们可以在哪里找到名称为 numpy
的软件包。 答案是……通道!
用户从 conda
通道下载软件包。 这些通常托管在 anaconda.org
上。 通道本质上是一个具有以下元素的目录结构
<channel>
├── channeldata.json
├── index.html
├── <platform> (e.g. linux-64)
│ ├── current_repodata.json
│ ├── current_repodata.json.bz2
│ ├── index.html
│ ├── repodata.json
│ ├── repodata.json.bz2
│ ├── repodata_from_packages.json
│ └── repodata_from_packages.json.bz2
└── noarch
├── current_repodata.json
├── current_repodata.json.bz2
├── index.html
├── repodata.json
├── repodata.json.bz2
├── repodata_from_packages.json
└── repodata_from_packages.json.bz2
重点是
一个通道包含一个或多个特定于平台的目录(
linux-64
、osx-64
等),以及一个与平台无关的目录,名为noarch
。 在conda
行话中,这些也被称为通道子目录。 正式来说,noarch
子目录足以使其成为conda
通道;例如,不需要平台子目录。每个子目录至少包含一个
repodata.json
文件:一个包含该平台上可用每个软件包的所有元数据的巨大字典。在大多数情况下,相同的子目录还包含每个已发布软件包的
*.tar.bz2
文件。 这是conda
在求解完成后下载和解压缩的内容。 这些文件的结构在内容和命名结构方面都有明确的定义。 有关更多详细信息,请参阅 什么是软件包?、软件包元数据 和/或 软件包命名约定。
此外,通道的主目录可能包含一个 channeldata.json
文件,其中包含通道范围内的元数据(这并非特定于每个平台)。 并非所有通道都包含此文件,并且通常它不是当前常用的内容。
由于 conda 的理念是保留所有已发布的软件包以实现可重复性,因此 repodata.json
始终在增长,这对于下载本身和求解器引擎都提出了问题。 为了减少下载时间和带宽使用量,repodata.json
也以 BZIP2 压缩文件的形式提供,即 repodata.json.bz2
。 这是大多数 conda
客户端最终下载的内容。
关于“current_repodata.json”的说明
在某些通道中可以找到更多repodatas 变体,但它们始终是主要版本缩减版,以提高性能。 例如,current_repodata.json
只包含每个软件包的最新版本及其依赖项。 这种优化技巧背后的原理可以 在此 找到。
因此,从本质上讲,获取通道信息可以用伪代码表示如下
platform = {}
noarch = {}
for channel in reversed(context.channels):
platform_repodata = fetch_extract_and_read(
channel.full_url / context.subdir / "repodata.json.bz2"
)
platform.update(platform_repodata)
noarch_repodata = fetch_extract_and_read(
channel.full_url / "noarch" / "repodata.json.bz2"
)
noarch.update(noarch_repodata)
请注意,这些字典按文件名为键,因此优先级较高的通道将覆盖具有完全相同文件名的条目(例如 numpy-1.19-py36h87ha43_0.tar.bz2
)。 如果它们没有相同的文件名(例如,相同版本和构建号,但哈希不同),则将在求解器中解决这种歧义,同时考虑通道优先级模式。
在此示例中,context.channels
已通过不同的级联机制填充
在
~/.condarc
或等效文件中找到的默认设置。CONDA_CHANNELS
环境变量(很少使用)。命令行标志,例如
-c <channel>
、--use-local
或--override-channels
。命令行 规范 中存在的通道。 请记住,用户可以输入
channel::numpy
而不是简单的numpy
来要求 numpy 来自该特定通道。 这意味着该通道的 repodata 也需要被获取!
context.channels
中的项目应该是 conda.models.channels.Channel
对象,但求解器 API 也允许字符串,这些字符串引用其名称、别名或完整 URL。 在这种情况下,您可以使用 Channel
对象使用 Channel.urls()
方法解析并检索每个子目录的完整 URL。 如果需要,可以在 conda.core.index
中找到几个辅助函数。
遗憾的是,fetch_extract_and_read()
不存在,但它是一组对象的组合。 主要驱动函数实际上是 get_index()
,它将通道 URL 传递给 fetch_index
,一个直接委托给 conda.core.subdir_data.SubdirData
对象的包装器。 此对象实现缓存、身份验证、代理和其他使“只需下载文件”这一简单想法变得复杂的事项。 大多数逻辑都在 SubdirData._load()
中,它最终调用 conda.core.subdir_data.fetch_repodata_remote_request()
来处理请求。 最后,SubdirData._process_raw_repodata_str()
完成解析和加载工作。
在内部,SubdirData
将所有包元数据存储为 PackageRecord
对象的列表。它的主要用途是通过 .query()
(一次一个结果)或 .query_all()
(所有可能的匹配项)来访问。这些 .query*
方法接受规范字符串(例如 numpy =1.14
)、MatchSpec
和 PackageRecord
实例。或者,如果您想要所有记录而无需查询,请使用 SubdirData.iter_records()
。
减少索引大小的技巧
conda
支持尝试使用索引的不同版本来缩小解决方案空间的概念。毕竟,较小的索引意味着更快的搜索!默认逻辑从通道中的 current_repodata.json
文件开始,这些文件只包含每个包的最新版本及其依赖项。如果失败,则使用完整的 repodata.json
。这发生在 Solver
甚至被调用之前。
第二个技巧是在经典求解器逻辑(pycosat)中完成的:一种有信息的索引缩减。本质上,索引(无论是 current_repodata.json
还是完整的 repodata.json
)都会被求解器修剪,试图只保留它预期会需要的部分。更多详细信息可以在 get_reduced_index
函数 中找到。有趣的是,这种优化步骤随着索引的增大也会花费更长时间。
通道优先级#
context.channels
返回一个包含 Channel
对象的 IndexedSet
;本质上是一个唯一项列表。此列表中的不同通道可能具有针对同一包名称的重叠甚至冲突的信息。例如,defaults
和 conda-forge
一定会包含满足 conda install numpy
请求的包。在这种情况下,conda
选择哪个?这取决于 context.channel_priority
设置:从帮助消息
接受“strict”、“flexible”和“disabled”的值。默认值为“flexible”。使用严格的通道优先级,如果同一名称的包出现在更高优先级的通道中,则不会考虑较低优先级通道中的包。使用灵活的通道优先级,求解器可能会进入较低优先级的通道来满足依赖关系,而不是引发无法满足的错误。禁用通道优先级后,包版本优先,仅使用通道的配置优先级来解决冲突。
实际上,对于大多数用户来说,channel_priority=strict
通常是推荐的设置。它求解速度更快,而且不会在以后造成问题。查看更多详细信息 此处。
解决安装请求#
此时,我们可以开始向求解器提出问题。毕竟,我们已经将通道加载到我们的索引中,构建了可供安装的可用包和版本的目录。我们还有自定义求解器请求所需的命令行指令和配置。所以,让我们开始吧:“求解器,请使用这些通道作为包源,在该前缀上安装 numpy”。
详细信息很复杂,但本质上,Solver
会
将请求的包、命令行选项和前缀状态表示为
MatchSpec
对象查询索引以找到最符合这些约束的最佳匹配项
返回一个
PackageRecord
对象列表
如果您好奇,完整详细信息在 求解器 中介绍。请记住,第 (1) 点是 conda 特定的,而第 (2) 点原则上可以由任何 SAT 求解器解决。
生成事务和相应的操作#
求解器 API 定义了三个公共方法
.solve_final_state()
:这是核心函数,在上面的部分中进行了描述。给定一些输入状态,它会返回一个IndexedSet
的PackageRecord
对象,这些对象反映了环境的最终状态应该是什么样子。这是最大的方法,其详细信息在 此处 中进行了详细介绍。.solve_for_diff()
:此方法获取最终状态并将其与环境的当前状态进行比较,发现哪些旧记录需要删除,哪些需要添加。.solve_for_transaction()
:此方法获取 diff 并为该操作创建一个Transaction
对象。这是主要 CLI 逻辑从求解器中接收的内容。
那么 Transaction
对象是什么,为什么需要它? 事务性操作 在 conda 4.3 中引入。它们似乎是一组旨在检查 conda
是否能够下载和链接所需包(例如检查磁盘是否有足够的空间、用户是否对目标路径有足够的权限等)更改的最后迭代。有关更多信息,请参阅 PR #3571、#3301 和 #3034。
事务本质上是一组 action
对象。每个操作都允许运行一些检查,以确定它是否可以成功执行。如果不是,失败的检查将向父事务发出信号,表明整个操作需要中止并回滚,以将事物保留在运行该 conda
命令之前的状态。它还负责您在 CLI 输出中看到的一些消息,例如有关将要安装、更新或删除的内容的报告。
事务和并行性
由于事务对象了解需要发生的所有操作,因此它还为验证、下载和(取消)链接任务启用并行性。可以通过以下 context
设置更改并行性级别
default_threads
verify_threads
execute_threads
repodata_threads
fetch_threads
conda
中只有一种事务:LinkUnlinkTransaction
。它只接受一个输入参数:一个 PrefixSetup
对象列表,这些对象只是带有以下字段的 namedtuple
对象。这些由 Solver.solve_for_transaction
在运行 Solver.solve_for_diff
后填充
target_prefix
:命令运行的环境路径。unlink_precs
:需要取消链接(删除)的PackageRecord
对象。link_precs
:需要链接(添加)的PackageRecord
对象。remove_specs
:需要在历史记录中标记为已删除的MatchSpec
对象(用户要求卸载这些包)。update_specs
:需要在历史记录中标记为已添加的MatchSpec
对象(用户要求安装或更新这些包)。neutered_specs
:在历史记录中已存在但必须放松以避免解决冲突的MatchSpec
对象。
实例化后发生的事情取决于这些 PrefixSetup
对象的内容。有时,事务不会导致任何操作(参见 nothing_to_do
属性),因为用户请求的环境当前状态已满足。
但是,大多数情况下,事务将涉及许多操作。这是通过两个公共方法完成的
download_and_extract()
:本质上是实例化并调用ProgressiveFetchExtract
的转发器,负责确定哪些PackageRecords
需要下载并提取到包缓存中。execute()
:核心逻辑在此处布置。它涉及准备、验证和执行其余操作。除此之外取消链接包(从环境中删除包)
链接(将包添加到环境中)
编译字节码(为每个
py
模块生成pyc
对应项)添加入口点(为配置的函数生成命令行可执行文件)
添加 JSON 记录(对于每个包,都会将一个 JSON 文件添加到
conda-meta/
中)创建菜单项(为在
Menu/
下具有 JSON 文件的包创建快捷方式)删除菜单项(删除该包创建的快捷方式)
需要注意的是,下载和解压缩与所有其他操作分开进行。这种分离很重要,也是conda
环境理念的核心。本质上,当你创建一个新的conda
环境时,你并不一定是在将文件复制到目标前缀位置。相反,conda
会维护一个缓存,存储磁盘上曾经下载的所有软件包(包括压缩包和解压缩后的内容)。为了节省空间并加快环境创建和删除速度,文件不会被复制,而是通过链接(通常是硬链接)进行关联。这就是为什么这两个任务在事务逻辑中被分开的:你不需要下载和解压缩已经存在于缓存中的软件包,你只需要链接它们!
事务也驱动报告
动作的类型和数量也可以通过_make_legacy_action_groups()
计算,它返回一个动作组列表(每个PrefixSetup
一个)。每个动作组只是一个字典,遵循以下规范
{
"FETCH": Iterable[PackageRecord], # estimated by `ProgressiveFetchExtract`
"PREFIX": str,
"UNLINK": Iterable[PackageRecord],
"LINK: Iterable[PackageRecord],
}
这些更简单的动作组仅用于报告,无论是通过处理后的文本报告(通过print_transaction_summary
)还是原始 JSON(通过stdout_json_success
)。如你所见,它们不了解其他类型的任务。
下载和解压缩#
conda
维护一个下载的压缩包及其解压缩内容的缓存,以节省磁盘空间并提高环境修改的性能。这需要一些代码来检查给定的PackageRecord
是否已存在于缓存中,如果不存在,如何以高效的方式下载压缩包并解压缩其内容。所有这些都由ProgressiveFetchExtract
类处理,该类可以为每个传入的PackageRecord
实例化最多两个Action
对象
CacheUrlAction
:下载(如果是远程)或复制(如果是本地)压缩包到缓存位置。ExtractPackageAction
:解压缩压缩包的内容。
这两个操作仅在软件包尚未缓存并且已解压缩的情况下才会执行。它们也可以在事务中止时(无论是由于错误还是用户按下 Ctrl+C)撤销更改。
填充前缀#
当所有必需的软件包都已下载并解压缩到缓存中时,就该开始用所需文件填充前缀了。这意味着我们需要
对于每个需要取消链接的软件包,运行预取消链接逻辑(
deactivate
和pre-unlink
脚本,以及根据需要删除快捷方式),然后取消链接软件包文件。对于每个需要链接的软件包,创建链接并运行后链接逻辑(
post-link
和activate
脚本,以及根据需要创建快捷方式)。
请注意,当你更新软件包版本时,实际上是完全删除了已安装的版本,然后添加新的版本。换句话说,更新只是取消链接+链接。
这是如何实现的?对于每个传递给UnlinkLinkTransaction
的PrefixSetup
对象,将实例化若干个名为ActionGroup
的命名元组(每个任务类别一个),并将它们一起分组到名为PrefixActionGroup
的命名元组中。然后将它们传递给.verify()
。此方法将获取每个操作,运行其检查,如果所有检查都通过,则将允许我们在.execute()
中执行实际操作。如果其中一个操作失败,则可以中止事务并回滚。
为了使所有这些都能正常工作,每个操作对象都遵循PathAction
API 契约
class PathAction:
_verified = False
def verify(self):
"Run checks to assess if the action can proceed"
def execute(self):
"Perform the action"
def reverse(self):
"Undo execute"
def cleanup(self):
"Remove artifacts from verification, execution or reversal"
@property
def verified(self):
"True if verification was run and successful"
其他PathAction
子类将添加更多方法和属性,但这是事务执行逻辑所期望的。为了支持填充前缀涉及的所有不同操作,PathAction
类树保持相当复杂的图
PathAction
PrefixPathAction
CreateInPrefixPathAction
LinkPathAction
PrefixReplaceLinkAction
MakeMenuAction
CreateNonadminAction
CreatePythonEntryPointAction
CreatePrefixRecordAction
UpdateHistoryAction
RemoveFromPrefixPathAction
UnlinkPathAction
RemoveLinkedPackageRecordAction
RemoveMenuAction
RegisterEnvironmentLocationAction
UnregisterEnvironmentLocationAction
CacheUrlAction
ExtractPackageAction
MultiPathAction
CompileMultiPycAction
AggregateCompileMultiPycAction
欢迎阅读每个类的文档字符串,以了解每个类在做什么;所有类都列在conda.core.path_actions
下。在接下来的部分中,我们将只评论最重要的类。
链接环境中的文件#
当 conda 将文件从缓存位置链接到前缀位置时,它实际上可以意味着三种不同的操作
创建软链接
创建硬链接
复制文件
软链接和硬链接之间的区别很细微,但很重要。你可以在其他地方找到更多关于差异的信息(例如这里),但对于我们的目的,这意味着
硬链接解析成本更低,行为就像一个真实文件,但只能链接同一个挂载点中的文件。
软链接可以跨挂载点链接文件,但它们的行为不像真实文件(更像是转发器),因此它们可能会破坏某些代码部分所做的假设。
在大多数情况下,conda
会尝试硬链接文件,如果失败,则会将它们复制。复制文件是一个昂贵的磁盘操作,无论是在时间上还是空间上,因此它应该是最后的选择。但是,有时这是唯一的办法。尤其是,当文件需要修改才能在目标前缀中使用时。
嗯……什么?为什么conda
要修改文件才能安装它?这与可重定位性有关。当创建 conda
软件包时,conda-build
会创建最多三个临时环境
构建环境:编译器和其他构建工具安装在其中,与主机环境分离,以支持交叉编译。
主机环境:构建时依赖项与你正在构建的软件包一起安装在其中。
测试环境:运行时依赖项与你刚刚构建的软件包一起安装在其中。它模拟用户安装软件包时会发生的情况,因此你可以对软件包运行任意检查。
当你构建软件包时,对构建时路径的引用可能会泄漏到某些文件(文本和二进制)的内容中。对于从源代码构建自己软件包的用户来说,这不是问题,因为他们可以选择此路径并将文件保留在那里。但是,对于conda
软件包来说,这种情况几乎永远不会发生。它们是在一台机器上创建的,并安装在另一台机器上。为了避免“找不到路径”问题和其他问题,conda-build
会标记那些包含构建时路径引用的软件包,将它们替换为占位符。在安装时,conda
将使用目标前缀替换这些占位符,一切都将正常工作!
但存在一个问题:我们不能修改缓存位置上的文件,因为它们可能被跨环境使用(具有明显不同的路径)。在这些情况下,文件不会被链接,而是被复制;当然,路径替换只发生在目标副本上!
conda
如何知道如何链接给定软件包,更准确地说,如何链接其解压缩后的文件?所有这些都在UnlinkLinkTransaction._prepare()
(更具体地说,通过determine_link_type()
)以及LinkPathAction.create_file_link_actions()
中包含的准备例程中确定。
请注意,(取消)链接操作还包括执行预(取消)链接和后(取消)链接脚本(如果列出)。
动作组和操作,详细#
一旦旧的软件包被删除,新的软件包通过适当的方式被链接,我们就完成了,对吧?还没有!还剩最后一步:后链接逻辑。
事实证明,为了使conda
尽可能方便,还需要执行许多较小的任务。你可以在上面几段中找到所有这些任务的列表,但我们也会在这里介绍它们。执行顺序在UnlinLinkTransaction._execute
中确定。所有可能的组都列在PrefixActionGroup
中。它们的顺序大致是它们在实践中发生的顺序
remove_menu_action_groups
,由RemoveMenuAction
操作组成。unlink_action_groups
,包括UnlinkPathAction
、RemoveLinkedPackageRecordAction
,以及运行预取消链接和后取消链接脚本的逻辑。unregister_action_groups
,基本上是一个UnregisterEnvironmentLocationAction
操作。link_action_groups
,包括LinkPathAction
、PrefixReplaceLinkAction
,以及运行预链接和后链接脚本的逻辑。entry_point_action_groups
,一个CreatePythonEntryPointAction
操作的集合。register_action_groups
,一个RegisterEnvironmentLocationAction
操作。compile_action_groups
,多个CompileMultiPycAction
最终聚合为一个AggregateCompileMultiPycAction
以提高性能。make_menu_action_groups
,由MakeMenuAction
操作组成。prefix_record_groups
,通过CreatePrefixRecordAction
操作记录环境中安装的软件包。
让我们讨论一下本指南中描述的命令:conda install numpy
所涉及的动作组。求解器给出的解决方案表明,我们需要
取消链接 Python 3.9.6
链接 Python 3.9.9
链接 numpy 1.19
以下是将要发生的事情
没有菜单项被删除,因为 Python 3.9.6 没有创建任何菜单项。
Python 3.9.6 的预解链接脚本将会运行,但本例中没有。
Python 3.9.6 文件从环境中删除。此过程可以并行化。
如果存在,则运行后解链接脚本。
如果存在,则运行 Python 3.9.9 和 numpy 1.19 的预链接脚本。
Python 3.9.9 和 numpy 1.19 包中的文件被链接和/或复制到前缀。此过程可以并行化。
如果存在,则为新包创建入口点。
运行后链接脚本。
pyc
文件将为新包生成。新包将在
conda-meta/
下注册。如果存在,则为新包创建菜单快捷方式。
任何这些步骤都可能因特定异常而失败。如果是这种情况,第一个异常将被打印到 STDOUT。此外,如果 rollback_enabled
在 context
中被正确配置,则交易将通过从最后一个动作到第一个动作调用每个动作的 .reverse()
方法进行回滚。
如果没有报告任何异常,则操作可以运行其清理例程。
就是这样!如果此命令导致创建了新环境,您将收到一条消息,告诉您如何激活新创建的环境。
结论#
这就是您键入 conda install
时发生的事情。这可能比您最初想象的要复杂一些,但它最终只涉及一些步骤。TL;DR
解析参数并初始化上下文
下载并构建索引
告诉求解器我们想要什么
将解决方案转换为交易
验证并运行交易中包含的每个操作