openroboto-protocol 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,376 @@
1
+ """可提交的 checkpoint 必须长什么样 —— 布局层的准入契约。
2
+
3
+ 契约含义
4
+ --------
5
+ 矿工照它导出,后端照它准入,评测器照它拒绝。三方看同一份规则,矿工才能在
6
+ **烧 TAO 之前**知道自己会不会被拒。现在这条规则有两份实现(后端
7
+ ``hf_validate.py`` 判 HF 仓库树,评测器 ``libero_eval/check_model.py`` 判本地目录),
8
+ 矿工要 clone 第二个仓才能自查 —— 烧完才发现格式错,TAO 白扔。
9
+
10
+ 输入是一份**文件清单**(路径 + 字节数),清单从哪来这里不管:后端从 HF tree API
11
+ 拿,矿工从本地目录 ``os.walk`` 拿,评测器从下载完的目录拿。同一份清单,三方得到
12
+ 同一个结论。
13
+
14
+ 不负责(这些都要读文件内容,留在评测器里)
15
+ ------------------------------------------
16
+ - safetensors 头、orbax ``params/_METADATA``、参数量区间、norm_stats 里的数值;
17
+ - 架构是不是 π0.5(``time_mlp_*`` vs ``state_proj``);
18
+ - 下载、解析 HF API 响应、判断 revision 存不存在。
19
+
20
+ **布局过了不等于评测一定能加载。** 这里过了只说明「值得花 GPU 时间试一试」。
21
+
22
+ errors 与 warnings 的分工
23
+ -------------------------
24
+ ``errors`` 复刻**生产准入**的判定(``hf_validate.validate_file_list``),
25
+ 一条不多一条不少 —— 它决定一笔已经烧掉的 TAO 算不算数,不是能顺手收紧的地方。
26
+ ``warnings`` 是已知的「准入放行、但评测器加载不了」的分歧,不改变判定,
27
+ 只是把矿工会踩的坑提前说出来。分歧本身待裁决(见包内 openQuestions)。
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Iterable
33
+ from dataclasses import dataclass
34
+ from enum import StrEnum
35
+ from typing import Final
36
+
37
+
38
+ class CheckpointKind(StrEnum):
39
+ """checkpoint 的两种权重形态。字面量和评测器的 ``checkpoint_type`` 一致。
40
+
41
+ 两种同时存在时 openpi 加载 PyTorch 权重、忽略 ``params/``,
42
+ 所以 :func:`check_checkpoint_layout` 也按这个优先级报。
43
+ """
44
+
45
+ PYTORCH = "pytorch"
46
+ JAX = "jax"
47
+
48
+
49
+ class FormatIssueCode(StrEnum):
50
+ """拒绝 / 提醒的稳定机器码。
51
+
52
+ ``message`` 面向人(原样回传矿工,会变),``code`` 面向程序(矿工侧脚本、
53
+ 前端文案、告警规则都可以匹配它,**不许改**)。加新码是 minor,改旧码是 major。
54
+ """
55
+
56
+ MISSING_WEIGHTS = "missing_weights"
57
+ """既没有 ``model.safetensors`` 也没有 ``params/`` —— 不是一个 checkpoint。"""
58
+
59
+ BARE_LORA_ADAPTER = "bare_lora_adapter"
60
+ """只有 LoRA adapter,没有合并后的完整权重。评测器不做合并。"""
61
+
62
+ MISSING_NORM_STATS = "missing_norm_stats"
63
+ """缺归一化统计量,推理时无法归一化输入 / 反归一化动作。"""
64
+
65
+ LEFTOVER_UPLOAD_STATE = "leftover_upload_state"
66
+ """``.git`` / ``.cache`` 这类误传上来的仓库内部状态。"""
67
+
68
+ INCOMPLETE_FILE = "incomplete_file"
69
+ """``.tmp`` / ``.partial`` 之类没传完的文件。"""
70
+
71
+ TOTAL_SIZE_TOO_SMALL = "total_size_too_small"
72
+ """整个仓库小于 10 MB —— 多半只传了 LFS 指针,没传权重。"""
73
+
74
+ UNLOADABLE_WEIGHTS_FORMAT = "unloadable_weights_format"
75
+ """(warning)准入认的权重文件名,评测器加载不了。"""
76
+
77
+ NON_CANONICAL_NORM_STATS = "non_canonical_norm_stats"
78
+ """(warning)norm_stats 在准入认的备用位置,评测器只读规范位置。"""
79
+
80
+ NESTED_TOO_DEEP = "nested_too_deep"
81
+ """(warning)checkpoint 嵌得比评测器搜索的层数还深。"""
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class OpenpiLayout:
86
+ """openpi checkpoint 的布局。这几个字段必须同源,所以绑在一起。
87
+
88
+ ``norm_stats_relpath`` 是**推导出来的**而不是另写一个常量 —— 评测器就是
89
+ ``assets / asset_id / "norm_stats.json"`` 这么拼的,写成两个独立常量迟早会漂。
90
+ """
91
+
92
+ asset_id: str
93
+ """norm stats 挂在 ``assets/<asset_id>/`` 下。LIBERO 是上游 openpi 的资产 id。"""
94
+
95
+ pytorch_weights_file: str
96
+ """openpi PyTorch checkpoint 的权重文件名。"""
97
+
98
+ jax_params_dir: str
99
+ """openpi JAX checkpoint 的 orbax OCDBT 目录名。"""
100
+
101
+ @property
102
+ def norm_stats_relpath(self) -> str:
103
+ """归一化统计量相对 checkpoint 根的路径。"""
104
+ return f"assets/{self.asset_id}/norm_stats.json"
105
+
106
+
107
+ LIBERO_LAYOUT: Final = OpenpiLayout(
108
+ asset_id="physical-intelligence/libero",
109
+ pytorch_weights_file="model.safetensors",
110
+ jax_params_dir="params",
111
+ )
112
+ """子网当前唯一接受的布局:openpi + LIBERO 资产。"""
113
+
114
+ LEGACY_PYTORCH_WEIGHTS_FILE: Final = "pytorch_model.bin"
115
+ """生产准入历史上认这个名字。评测器**加载不了**它 —— 放行但出 warning。"""
116
+
117
+ LEGACY_NORM_STATS_RELPATHS: Final = ("assets/libero/norm_stats.json", "norm_stats.json")
118
+ """生产准入历史上认的备用 norm_stats 位置。评测器只读规范位置 —— 放行但出 warning。"""
119
+
120
+ LORA_ADAPTER_MARKERS: Final = frozenset(
121
+ {"adapter_config.json", "adapter_model.safetensors", "adapter_model.bin"}
122
+ )
123
+ """裸 LoRA adapter 的特征文件名。识别它只为了给一条**说得清**的拒绝理由。"""
124
+
125
+ REJECTED_PATH_SEGMENTS: Final = frozenset(
126
+ {
127
+ ".git",
128
+ ".cache",
129
+ ".locks",
130
+ ".no_exist",
131
+ ".ipynb_checkpoints",
132
+ ".DS_Store",
133
+ ".Trash",
134
+ }
135
+ )
136
+ """误传上来的仓库内部状态,明确列举。
137
+
138
+ ⚠️ **不能改成「以 . 开头就拒」。** HF 建仓库时自动生成 ``.gitattributes``,
139
+ 2026-08-14 按前缀拒的规则接上生产,误拒了 uid 221 / 231 两个**已经烧过 TAO**
140
+ 的提交;当时抽查榜上 8 个已通过评测的仓库,8/8 都有这个文件 —— 规则一旦生效
141
+ 等于拒绝所有人。加名字要有具体理由。
142
+ """
143
+
144
+ INCOMPLETE_FILE_SUFFIXES: Final = frozenset(
145
+ {".tmp", ".temp", ".partial", ".crdownload", ".download", ".lock", ".swp", ".swo"}
146
+ )
147
+ """没传完 / 临时文件的后缀。"""
148
+
149
+ MIN_TOTAL_SIZE_BYTES: Final = 10 * 1024 * 1024
150
+ """整仓最小体积。低于这个数基本只有 LFS 指针,没有真权重。"""
151
+
152
+ MAX_CHECKPOINT_NESTING_DEPTH: Final = 2
153
+ """评测器只在根、``*/``、``*/*/`` 三层里找 checkpoint。埋得更深它找不到。"""
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class CheckpointFile:
158
+ """清单里的一个文件。路径和体积必须同源 —— 判定同时用到这两个。
159
+
160
+ ``path`` 是相对 checkpoint 根(或仓库根)的 POSIX 路径,不以 ``/`` 开头。
161
+ 只传文件;目录条目传进来无害(体积 0、匹配不上任何规则),但不要指望它被判定。
162
+ """
163
+
164
+ path: str
165
+ size_bytes: int
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class FormatIssue:
170
+ """一条判定结果。``message`` 是原样回传矿工的英文原因。"""
171
+
172
+ code: FormatIssueCode
173
+ message: str
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class FormatReport:
178
+ """一次布局判定的完整结论。"""
179
+
180
+ kind: CheckpointKind | None
181
+ """识别出的权重形态;``None`` = 没识别出可加载的 checkpoint。"""
182
+
183
+ errors: tuple[FormatIssue, ...]
184
+ """非空 = 拒绝提交。顺序:逐文件问题 → 缺权重 → 缺 norm_stats → 体积过小。"""
185
+
186
+ warnings: tuple[FormatIssue, ...]
187
+ """不影响判定,但矿工大概率会在评测阶段踩到。"""
188
+
189
+ counted_size_bytes: int
190
+ """参与体积判定的字节数。dotfile 与被拒文件不计入。
191
+
192
+ 别在调用方重新求和 —— 求和口径不一样,「体积过小」这条判定就会对不上。
193
+ """
194
+
195
+ @property
196
+ def ok(self) -> bool:
197
+ """能不能提交。"""
198
+ return not self.errors
199
+
200
+
201
+ def _suffix(basename: str) -> str:
202
+ """取**最后**一个点之后的后缀(含点);没有则空串。
203
+
204
+ 取第一个点时 ``checkpoint.001.tmp`` 的后缀会算成 ``.001.tmp``,匹配不上
205
+ :data:`INCOMPLETE_FILE_SUFFIXES` —— 任何多点文件名都能绕过临时文件检查,
206
+ 而拦残留上传正是这条检查的全部目的。
207
+ """
208
+ idx = basename.rfind(".")
209
+ return basename[idx:] if idx > 0 else ""
210
+
211
+
212
+ def _matches(path: str, relpath: str) -> bool:
213
+ """路径是不是 ``relpath`` 本身,或嵌在任意层子目录下的 ``relpath``。
214
+
215
+ 嵌套必须认:uid 130 把整个 openpi checkpoint 放在 ``merged/`` 下面
216
+ (10.8 GB,norm_stats 齐全,合法提交),只认仓库根会把它判成「缺模型文件」。
217
+ """
218
+ return path == relpath or path.endswith("/" + relpath)
219
+
220
+
221
+ def check_checkpoint_layout(
222
+ files: Iterable[CheckpointFile],
223
+ *,
224
+ allowed_path_segments: frozenset[str] = frozenset(),
225
+ ) -> FormatReport:
226
+ """判定一份文件清单能不能作为 checkpoint 提交。
227
+
228
+ ``allowed_path_segments`` 覆盖 :data:`REJECTED_PATH_SEGMENTS`
229
+ (对应生产配置 ``scanner.hf_allow_dotfiles``)。正常不需要设 ——
230
+ 默认规则已经只拒明确有问题的名字;它是误拒真实矿工时的现场逃生口。
231
+ """
232
+ errors: list[FormatIssue] = []
233
+ warnings: list[FormatIssue] = []
234
+ counted_size = 0
235
+
236
+ has_pytorch = has_jax = has_legacy_weights = False
237
+ has_canonical_stats = has_legacy_stats = has_adapter = False
238
+ # 权重可能在多个层级各有一份,评测器取最浅的那份,所以只有最浅的深度算数。
239
+ weights_depths: list[int] = []
240
+
241
+ for file in files:
242
+ parts = file.path.split("/")
243
+
244
+ if any(
245
+ p in REJECTED_PATH_SEGMENTS and p not in allowed_path_segments
246
+ for p in parts
247
+ ):
248
+ errors.append(
249
+ FormatIssue(
250
+ FormatIssueCode.LEFTOVER_UPLOAD_STATE,
251
+ f"leftover upload state in the repo: {file.path} — remove the "
252
+ "repository-internal directory and re-upload",
253
+ )
254
+ )
255
+ continue
256
+
257
+ # 其余 dotfile(.gitattributes / .gitignore …)放行,且不计入体积:
258
+ # 它们不是模型内容,HF 自己会生成。
259
+ if any(p.startswith(".") for p in parts):
260
+ continue
261
+
262
+ counted_size += file.size_bytes
263
+ basename = parts[-1]
264
+
265
+ if _suffix(basename) in INCOMPLETE_FILE_SUFFIXES:
266
+ errors.append(
267
+ FormatIssue(
268
+ FormatIssueCode.INCOMPLETE_FILE,
269
+ f"incomplete or temporary file: {file.path} — "
270
+ "the upload did not finish",
271
+ )
272
+ )
273
+ continue
274
+
275
+ # 目录型标记看路径中间那几段,文件型标记看整条路径。
276
+ if LIBERO_LAYOUT.jax_params_dir in parts[:-1]:
277
+ has_jax = True
278
+ weights_depths.append(parts.index(LIBERO_LAYOUT.jax_params_dir))
279
+ if _matches(file.path, LIBERO_LAYOUT.pytorch_weights_file):
280
+ has_pytorch = True
281
+ weights_depths.append(len(parts) - 1)
282
+ if _matches(file.path, LEGACY_PYTORCH_WEIGHTS_FILE):
283
+ has_legacy_weights = True
284
+ if _matches(file.path, LIBERO_LAYOUT.norm_stats_relpath):
285
+ has_canonical_stats = True
286
+ if any(_matches(file.path, rel) for rel in LEGACY_NORM_STATS_RELPATHS):
287
+ has_legacy_stats = True
288
+ if basename in LORA_ADAPTER_MARKERS:
289
+ has_adapter = True
290
+
291
+ if not (has_pytorch or has_jax or has_legacy_weights):
292
+ if has_adapter:
293
+ errors.append(
294
+ FormatIssue(
295
+ FormatIssueCode.BARE_LORA_ADAPTER,
296
+ "this is a bare LoRA adapter, not a checkpoint — the "
297
+ "evaluator does no merging. Merge the adapter back into the "
298
+ "pi0.5 base and upload the full "
299
+ f"checkpoint ('{LIBERO_LAYOUT.pytorch_weights_file}' or "
300
+ f"'{LIBERO_LAYOUT.jax_params_dir}/').",
301
+ )
302
+ )
303
+ else:
304
+ errors.append(
305
+ FormatIssue(
306
+ FormatIssueCode.MISSING_WEIGHTS,
307
+ "no model weights found — expected openpi PyTorch weights "
308
+ f"('{LIBERO_LAYOUT.pytorch_weights_file}') or a JAX orbax "
309
+ "checkpoint "
310
+ f"('{LIBERO_LAYOUT.jax_params_dir}/')",
311
+ )
312
+ )
313
+ elif has_legacy_weights and not (has_pytorch or has_jax):
314
+ warnings.append(
315
+ FormatIssue(
316
+ FormatIssueCode.UNLOADABLE_WEIGHTS_FORMAT,
317
+ f"'{LEGACY_PYTORCH_WEIGHTS_FILE}' passes submission admission but the "
318
+ f"evaluator loads only '{LIBERO_LAYOUT.pytorch_weights_file}' or "
319
+ f"'{LIBERO_LAYOUT.jax_params_dir}/' — it will be rejected before "
320
+ "the GPU runs",
321
+ )
322
+ )
323
+
324
+ depth = min(weights_depths, default=0)
325
+ if depth > MAX_CHECKPOINT_NESTING_DEPTH:
326
+ warnings.append(
327
+ FormatIssue(
328
+ FormatIssueCode.NESTED_TOO_DEEP,
329
+ f"the checkpoint is nested {depth} levels deep; the evaluator only "
330
+ f"searches {MAX_CHECKPOINT_NESTING_DEPTH} levels below the repo root",
331
+ )
332
+ )
333
+
334
+ if not (has_canonical_stats or has_legacy_stats):
335
+ errors.append(
336
+ FormatIssue(
337
+ FormatIssueCode.MISSING_NORM_STATS,
338
+ f"missing normalization stats: {LIBERO_LAYOUT.norm_stats_relpath} — "
339
+ "inference cannot normalize the state or unnormalize the "
340
+ "actions without them",
341
+ )
342
+ )
343
+ elif not has_canonical_stats:
344
+ warnings.append(
345
+ FormatIssue(
346
+ FormatIssueCode.NON_CANONICAL_NORM_STATS,
347
+ "normalization stats are not at the canonical path "
348
+ f"{LIBERO_LAYOUT.norm_stats_relpath}; the evaluator reads only "
349
+ "that path",
350
+ )
351
+ )
352
+
353
+ # 只在没有别的问题时才报体积。缺文件的仓库本来就小,两条一起报是噪音。
354
+ if counted_size < MIN_TOTAL_SIZE_BYTES and not errors:
355
+ errors.append(
356
+ FormatIssue(
357
+ FormatIssueCode.TOTAL_SIZE_TOO_SMALL,
358
+ f"total size {counted_size / 1024 / 1024:.1f} MB is below the "
359
+ f"{MIN_TOTAL_SIZE_BYTES // 1024 // 1024} MB minimum — the weights are "
360
+ "probably git-lfs pointers, not the real files",
361
+ )
362
+ )
363
+
364
+ kind = (
365
+ CheckpointKind.PYTORCH
366
+ if has_pytorch
367
+ else CheckpointKind.JAX
368
+ if has_jax
369
+ else None
370
+ )
371
+ return FormatReport(
372
+ kind=kind,
373
+ errors=tuple(errors),
374
+ warnings=tuple(warnings),
375
+ counted_size_bytes=counted_size,
376
+ )
@@ -0,0 +1,104 @@
1
+ """模型指纹:把一个 HuggingFace 仓库的 LFS 内容折成一个 64 位十六进制串。
2
+
3
+ 契约含义
4
+ --------
5
+ 两份提交的指纹相同 = 同一份权重。后端据此判抄袭:同一轮里指纹撞了,
6
+ 链上 ``commit_block`` 更早的是正版,晚的被标 ``rejected``。
7
+ 所以这个算法必须能被矿工独立复算 —— 算出来和后端不一致,赔的是矿工的钱。
8
+
9
+ **空串是哨兵,不是指纹。** 后端拿到空串直接拒(``reject_reason=model_hash_empty``),
10
+ 它的含义是「这个仓库没给出任何 LFS 指纹」,不是「指纹恰好为空」。
11
+
12
+ 只搬了算法,没搬 I/O
13
+ --------------------
14
+ 取仓库文件列表那半留在调用方(后端 scanner 用 HF tree API:token、超时、
15
+ 重试、404 不重试的判定都在那边)。这个包一旦发网络请求,两边就无法再被
16
+ 证明一致 —— 而"能被证明一致"是它唯一的存在理由。
17
+
18
+ HF 有两条列表接口,形状不同,这里都能拼:
19
+
20
+ - ``/api/models/<id>/tree/<rev>?recursive=true`` → 条目带 ``type`` 字段,
21
+ 直接用 :func:`model_hash_from_hf_tree`;
22
+ - ``/api/models/<id>`` 的 ``siblings`` / ``lfsFiles`` 回退路径 → **没有** ``type``
23
+ 字段,调用方自己用 :func:`extract_lfs_sha256` + :func:`fingerprint_lfs_sha256` 拼。
24
+
25
+ 不负责
26
+ ------
27
+ - 不取网络、不认 token、不重试;
28
+ - 不校验字符串是不是合法的 64 位 sha256(生产从不校验,这里保持一致);
29
+ - 指纹撞了之后谁是正版由后端按 ``commit_block`` 判,不在这里。
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ from collections.abc import Iterable, Mapping
36
+ from typing import Any, Final
37
+
38
+ # 指纹算法的三个参数。改任何一个 = 链上所有历史指纹全部作废,
39
+ # 抄袭判定跟着全错。要动必须走 major,并且给出链上数据迁移方案。
40
+ _SEPARATOR: Final = "\n"
41
+ _ENCODING: Final = "utf-8"
42
+
43
+ # HF 的 LFS 字段用 ``sha256:<hex>`` 这种带前缀的字符串形态出现过,认它。
44
+ _SHA256_PREFIX: Final = "sha256:"
45
+
46
+
47
+ def extract_lfs_sha256(lfs_field: object) -> str:
48
+ """从 HF tree 条目的 ``lfs`` 字段里取出那条 sha256;取不到返回空串。
49
+
50
+ 契约:HF 的 REST API 在 LFS 字段里**只给 ``oid``,不给 ``sha256`` 键**,
51
+ 而那个 ``oid`` 就是内容的 sha256 —— huggingface_hub 自己也这么映射
52
+ (``BlobLfsInfo(sha256=lfs["oid"])``)。两个键都认,``sha256`` 优先。
53
+ 真实数据佐证:round 1 冠军仓库的 tree 响应里只有 ``oid``,
54
+ 所以 ``or oid`` 这条回退是**主路径**,不是防御性代码。
55
+
56
+ ⚠️ 只认 ``dict`` 和 ``"sha256:..."`` 字符串两种形态,和生产一致。
57
+ 用 huggingface_hub SDK 的调用方拿到的 ``RepoFile.lfs`` 是 ``BlobLfsInfo``
58
+ **对象**,两个分支都不匹配 → 静默得到空串 → 整个仓库算不出指纹。
59
+ 要走 SDK 就自己先转成 ``{"sha256": info.sha256}``。
60
+
61
+ 非字符串的 sha 值故意抛 ``TypeError`` 而不是静默跳过:生产里它会在
62
+ ``"\\n".join`` 那一步炸,被调用方的 ``except Exception`` 接住并整条拒绝。
63
+ 静默跳过会把「该拒的」变成「用剩下的文件算个指纹」,那是改行为。
64
+ """
65
+ if isinstance(lfs_field, dict):
66
+ sha = lfs_field.get("sha256", "") or lfs_field.get("oid", "")
67
+ if not isinstance(sha, str):
68
+ raise TypeError(f"LFS sha256 must be a string, got {type(sha).__name__}")
69
+ return sha
70
+ if isinstance(lfs_field, str) and lfs_field.startswith(_SHA256_PREFIX):
71
+ return lfs_field[len(_SHA256_PREFIX) :]
72
+ return ""
73
+
74
+
75
+ def fingerprint_lfs_sha256(lfs_sha256s: Iterable[str]) -> str:
76
+ """一组 LFS sha256 → 单个指纹。空集合返回空串(= 没有指纹)。
77
+
78
+ 算法:字典序排序 → 换行连接 → UTF-8 编码 → sha256 → 小写十六进制。
79
+ 排序让文件顺序无关 —— 同一份权重换个上传顺序必须得到同一个指纹,
80
+ 否则矿工重传一次就"洗白"了抄袭。
81
+
82
+ 重复的 sha 不去重(同一份内容在仓库里出现两次会进两条),和生产一致。
83
+ """
84
+ ordered = sorted(lfs_sha256s)
85
+ if not ordered:
86
+ return ""
87
+ return hashlib.sha256(_SEPARATOR.join(ordered).encode(_ENCODING)).hexdigest()
88
+
89
+
90
+ def model_hash_from_hf_tree(entries: Iterable[Mapping[str, Any]]) -> str:
91
+ """HF tree API(``?recursive=true``)的返回 → 指纹。
92
+
93
+ 只看 ``type == "file"`` 的条目 —— HF 返回的目录 type 是 ``"directory"``,
94
+ 并且目录条目没有 ``lfs``,两层都挡得住。
95
+
96
+ **只有 LFS 文件进指纹。** 走普通 git blob 的小文件(``config.json``、
97
+ ``norm_stats.json``、``round_info.json``)不参与,所以指纹覆盖的是权重本身:
98
+ 改一行 ``round_info.json`` 不会换指纹,这是有意的 —— 抄袭者改元数据逃不掉。
99
+ """
100
+ return fingerprint_lfs_sha256(
101
+ sha
102
+ for entry in entries
103
+ if entry.get("type") == "file" and (sha := extract_lfs_sha256(entry.get("lfs")))
104
+ )
File without changes