5.9.2 工作区元数据

原文链接: https://docs.astral.sh/uv/reference/internals/metadata/

5.9.2 工作区元数据

uv workspace metadata 把 uv 关于你的工作区或 PEP 723 脚本的信息导出为 JSON,供其他工具使用。特别是,如果你想访问 uv.lock 或脚本锁文件中的信息,应优先使用该命令的输出,因为锁文件并非我们做出任何保证的稳定格式。传入 --script path/to/script.py 可请求某个脚本的元数据。

传入 --sync 会在收集模块归属信息之前安装所选包。同步默认会保留无关的已安装包;加上 --exact 可移除它们。

主要结构是 resolution 字段,它包含依赖图以及 uv.lock 所编码的确切包版本。

图的边是每个节点定义的 dependencies。这些是为了安装该节点而必须一并安装的东西(以及它们递归的 dependencies,请记住在这个图中遇到环是完全正常的)。每个依赖条目都会包含它所引用节点的 id,以及一个可选的 marker,用于指明该依赖在哪些平台上被需要(如果没有标记,则该依赖始终需要)。

图中由包派生的节点由包 name、version、source 和 kind 唯一标识。脚本节点和工作区节点由其路径标识。工作区根的依赖组节点由其组名和工作区路径标识。所有节点 id 都应视为不透明。

图中有 5 种节点:

  • "script" —— 一个 PEP 723 脚本及其直接依赖
  • "workspace" —— 一个工作区根及其工作区专属依赖组
  • "package" —— 包本身
  • { "extra": "extraname" } —— 包定义的某个 extra
  • { "group": "groupname" } —— 包或工作区根定义的依赖组

(将来我们会为构建环境的依赖添加 "build" 节点。)

如果你想安装 mypackage,请找到它的 "kind": "package" 节点。该节点还会包含关于其 sdist、wheel、extras(optional_dependencies)和依赖组(dependency_groups)的信息。

如果你想安装 mypackage[myextra],请找到 mypackage 的 "kind": { "extra": "myextra" } 节点(该节点始终依赖 mypackage)。如果你想安装 mypackage[extra1, extra2],请找到 mypackage[extra1] 和 mypackage[extra2] 这两个节点。

如果你想安装依赖组 mypackage:mygroup,请找到 mypackage 的 "kind": { "group": "mygroup" } 节点(该节点不依赖 mypackage,因为依赖组只是在处理该包本身时你可能想要的东西列表)。

如果工作区根定义了依赖组但自身不是包,它的 "workspace" 节点会通过 dependency_groups 提供对应的组节点 id。

处理同一个包的多个版本

同一个包的两个版本无法安装到同一个 Python 环境,但依赖图仍可能包含同一个包的多个版本。这可能由两个不同原因造成。

第一种是不同平台具有冲突的要求,从而被迫使用同一个包的不同版本。

第二种是工作区存在冲突,意味着某些工作区成员或其 extras 互斥,一次只能安装其中一个。关于冲突的信息可以在顶层 conflicts 字段中找到。

我们提供的具体保证是:对于标记的任何具体取值,如果你选择一组要安装的包且其中没有冲突,那么最终的待安装包集合中不会有同一个包的多个版本。

如果你只是想得到“该工作区用到的 pydantic 的所有版本”,可以自由遍历节点列表并收集每个实例。但如果你想专门分析图并获得实际的解析结果,你很可能需要查阅 conflicts,并理解如何为特定平台解析 markers。

处理同一个包的多个版本时,避免错误的最佳方式是让对依赖图的查询始终以对工作区根、工作区成员或所请求脚本的操作为起点。它们是图的自然入口点,能为诸如“安装工作区的 dev 组”、“安装 member1 和 member2[extra]”或“安装该脚本声明的依赖”等操作给出一致的响应。

换一种说法:只要可能,你应当避免遍历 resolution 对象来查找节点。只应像使用映射那样,用元数据其他部分提供的 id 去访问 resolution。对于工作区,工作区根是 workspace.id,包入口点在 members 数组中列出。对于脚本,初始 id 是 script.id。从这里你可以沿依赖边递归发现其他包。

因此,与其试图直接在依赖图中查找 anyio 的节点,你应当先决定要以“将要安装”的方式分析哪些工作区成员。遍历你想安装的东西的 dependencies 时,你可能访问到 anyio 的某个实例,那就是你应使用的实例。如果你访问到 anyio 的多个实例,那就意味着你选择了一组相互冲突的待安装项,而 uv 永远不会如此选择。

所以,如果你想分析(例如)安装工作区成员 mypackage 的 dev 依赖组,大致如下:

1
2
3
4
5
member = find_by_name(metadata.members, "mypackage")
member_node = metadata.resolution[member.id]
group = find_by_name(member_node.dependency_groups, "dev")
group_node = metadata.resolution[group.id]
visit(metadata, [group_node])

对于定义在工作区根上的依赖组,请通过工作区节点查找:

1
2
3
4
workspace_node = metadata.resolution[metadata.workspace.id]
group = find_by_name(workspace_node.dependency_groups, "dev")
group_node = metadata.resolution[group.id]
visit(metadata, [group_node])

如果你想分析两个特定的工作区成员一起安装的情况,大致如下:

1
2
3
4
5
6
to_analyze = []
for member_name in ["package1", "package2"]:
    member = find_by_name(metadata.members, member_name)
    member_node = metadata.resolution[member.id]
    to_analyze.append(member_node)
visit(metadata, to_analyze)

对于脚本,同样从它的 resolution 节点开始:

1
2
script_node = metadata.resolution[metadata.script.id]
visit(metadata, [script_node])

其中 visit 是你最喜欢的图遍历算法,例如深度优先搜索:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def visit(metadata: UvMetadata, to_analyze: list[Node]):
    visited = set()
    while len(to_analyze) > 0:
        node = to_analyze.pop()

        # 通过避免重复访问节点来处理环
        if node.id in visited:
            continue
        visited.add(node.id)

        # 我们还需要分析它的依赖
        for dependency in node.dependencies:
            # 只有当边满足目标平台的标记时才继续沿着它走
            if dependency.marker and not satisfies(platform, dependency.marker):
                continue
            to_analyze.append(metadata.resolution[dependency.id])

        # 分析我们遇到的任何包节点
        if node.kind == "package":
            print(node.name, node.version, node.source)

Schema

该格式的完整 JSON schema 会在格式定稿时提供。

下面是带注释、便于阅读的示例:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
{
  // 关于该输出 schema 的信息
  "schema": {
    // 该输出的版本,目前是 "preview"
    "version": "preview"
  },
  // 可以找到 uv.lock 的目录
  "workspace_root": "/workspace",
  // 关于环境的信息,环境存在或使用 `--sync` 时可用
  "environment": {
    // 环境根目录的绝对路径
    "root": "/workspace/.venv",
    // 关于环境中 Python 解释器的信息
    "python": {
      // Python 可执行文件的绝对路径
      "path": "/workspace/.venv/bin/python",
      // 完整的 Python 版本
      "version": "3.12.12",
      // Python 实现名
      "implementation": "cpython"
    }
  },
  // 关于脚本目标的信息,只有使用 `--script` 时才存在。
  // 工作区元数据改用下面的 `workspace` 和 `members` 作为图的入口点。
  "script": {
    // 脚本的绝对路径
    "path": "/workspace/script.py",
    // 脚本节点在下面 `resolution` 映射中的 id
    "id": "script+/workspace/script.py"
  },
  // 关于工作区目标的信息,使用 `--script` 时省略。
  "workspace": {
    // 工作区根的绝对路径
    "path": "/workspace",
    // 工作区节点在下面 `resolution` 映射中的 id
    "id": "workspace+/workspace"
  },
  // 该工作区对 Python 版本的任何要求
  //
  // 所有 `marker` 字段都隐含该约束,为简洁起见被省略
  "requires_python": ">=3.12",
  // 工作区成员列表
  "members": [
    {
      // 包名
      "name": "mypackage",
      // 包含其 pyproject.toml 的目录
      "path": "/workspace/packages/mypackage",
      // 该包信息在下面 `resolution` 映射中的 id
      "id": "mypackage==0.1.0@editable+/workspace/packages/mypackage"
    },
  ],
  // 一组组互斥安装的工作区条目,
  // 大概是因为它们需要安装同一个包的不同版本。
  //
  // 任何试图安装属于同一组的两项的尝试都必须被拒绝。
  //
  // 有 3 种条目:
  //
  // * 项目 -- "kind": "project"
  // * Extra -- "kind": { "extra": "extraname" }
  // * 组   -- "kind": { "group": "groupname" }
  "conflicts": {
    "sets": [
      {
        "items": [
          {
            "package": "mypackage",
            "kind": { "extra": "myextra" }
            "id": "mypackage[myextra]==0.1.0@editable+/workspace/packages/mypackage",
          }
          {
            "package": "mypackage",
            "kind": { "group": "mygroup" }
            "id": "mypackage:mygroup==0.1.0@editable+/workspace/packages/mypackage",
          }
        ]
      }
    ]
  }
  // 关于包与依赖的已解析信息。
  //
  // 该映射中的每个条目都是依赖图中的一个节点。目前依赖图中有
  // 5 种节点,将来还计划加入更多。
  //
  // * 脚本     -- "kind": "script"
  // * 工作区   -- "kind": "workspace"
  // * 包       -- "kind": "package"
  // * Extras   -- "kind": { "extra": "extraname" }
  // * 组       -- "kind": { "group": "groupname" }
  //
  // 包节点包含大部分元数据,其他节点大多只是一个依赖列表。之所以把不同种类的节点
  // 这样包含进来,是为了鼓励对图做正确的分析。例如 `mypackage[someextra]` 的节点
  // 始终依赖 `mypackage`,而 `mypackage:somegroup` 不是(因为依赖组只是在处理
  // `mypackage` 时你可能想安装的包列表)。像 `mypackage[extra1, extra2]` 这样的
  // 语法糖会被拆解为对 `mypackage[extra1]` 和 `mypackage[extra2]` 的独立依赖。
  //
  // 这里使用的 id 便于阅读,但应当被视为不透明(节点以更方便的形式包含同样的信息)。
  "resolution": {

    // 使用 `--script` 请求元数据时会出现脚本节点。它的依赖就是脚本声明的直接要求。
    "script+/workspace/script.py": {
      "kind": "script",
      "path": "/workspace/script.py",
      "dependencies": [
        {
          "id": "iniconfig==2.0.0@registry+https://pypi.org/simple"
        }
      ]
    },

    // 工作区节点拥有直接定义在工作区根上的元数据。
    "workspace+/workspace": {
      "kind": "workspace",
      "path": "/workspace",
      "dependencies": [],
      "dependency_groups": [
        {
          "name": "dev",
          "id": "workspace+/workspace:dev"
        }
      ]
    },

    // 该节点是定义在非包工作区根上的依赖组。
    "workspace+/workspace:dev": {
      "kind": { "group": "dev" },
      "path": "/workspace",
      "dependencies": [
        {
          "id": "iniconfig==2.0.0@registry+https://pypi.org/simple"
        }
      ]
    },

    // 该节点是一个工作区成员
    "mypackage==0.1.0@editable+/workspace/packages/mypackage": {
      // 包名
      "name": "mypackage",
      // 包版本(可能缺失,因为源码树不需要版本)
      "version": "0.1.0",
      // 包的来源,这里是可编辑安装,其相对 `workspace_root` 的路径为
      // `./packages/mypackage`
      "source": {
        "editable": "/workspace/packages/mypackage"
      },
      // 节点种类,这里是 "package"(细节见上面关于 `resolution` 的文档)
      "kind": "package",
      // 为把该节点也安装进环境而必须安装的依赖
      "dependencies": [
        {
          // 用于查找详情的节点 id
          "id": "iniconfig==2.0.0@registry+https://pypi.org/simple"
          "marker": "marker": "sys_platform == 'linux'"
        }
      ],
      // 该包定义的 extras
      "optional_dependencies": [
        {
          "name": "myextra",
          "id": "mypackage[myextra]==0.1.0@editable+/workspace/packages/mypackage"
        }
      ],
      // 该包定义的依赖组
      "dependency_groups": [
        {
          "name": "mygroup",
          "id": "mypackage:mygroup==0.1.0@editable+/workspace/packages/mypackage"
        }
      ]
    },

    // 该节点是工作区成员上的一个 extra
    "mypackage[myextra]==0.1.0@editable+/workspace/packages/mypackage": {
      // 这些字段与上面的包节点一致
      "name": "mypackage",
      "version": "0.1.0",
      "source": {
        "editable": "/workspace/packages/mypackage"
      },
      // 但这两个字段与上面的包节点不同
      "kind": { "extra": "myextra" },
      "dependencies": [
        {
          "id": "mypackage==0.1.0@editable+/workspace/packages/mypackage"
        }
        {
          "id": "anyio==2.0.0@registry+https://pypi.org/simple"
        }
      ]
    },

    // 该节点是工作区成员上的依赖组
    "mypackage:mygroup==0.1.0@editable+/workspace/packages/mypackage": {
      // 这些字段与上面的包节点一致
      "name": "mypackage",
      "version": "0.1.0",
      "source": {
        "editable": "/workspace/packages/mypackage"
      },
      // 但这两个字段与上面的包节点不同
      "kind": { "extra": "myextra" },
      "dependencies": [
        {
          "id": "anyio==1.0.0@registry+https://pypi.org/simple"
        }
      ]
    },

    // 该节点是 PyPI 上的一个包
    "iniconfig==2.0.0@registry+https://pypi.org/simple": {
      "name": "iniconfig",
      "version": "2.0.0",
      // registry 来源看起来是这样
      "source": {
        "registry": {
          "url": "https://pypi.org/simple"
        }
      },
      "kind": "package",
      "dependencies": [],
      // 该包源码分发的细节
      "sdist": {
        // 也可能是 `path`
        "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz",
        "hashes": {
          "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"
        },
        "size": 4646,
        "upload_time": "2023-01-07T11:08:11.254Z"
      },
      // 我们为该包找到的 wheel
      "wheels": [
        {
          // 也可能是 `path`
          "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl",
          "hashes": {
            "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"
          },
          "size": 5892,
          "upload_time": "2023-01-07T11:08:09.864Z",
          // 解析该名称即可知道 wheel 支持哪个平台
          "filename": "iniconfig-2.0.0-py3-none-any.whl"
        }
      ]
    }

    // ...依此类推
    "anyio==1.0.0@registry+https://pypi.org/simple": { ... }
    "anyio==2.0.0@registry+https://pypi.org/simple": { ... }
  }
}
最后修改 September 25, 2026: 更新 (221c74c33)