atomgit 1.0.9__tar.gz → 1.0.11__tar.gz
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.
- {atomgit-1.0.9 → atomgit-1.0.11}/PKG-INFO +1 -1
- {atomgit-1.0.9 → atomgit-1.0.11}/__init__.py +3 -3
- {atomgit-1.0.9 → atomgit-1.0.11}/api.py +22 -18
- {atomgit-1.0.9 → atomgit-1.0.11}/atomgit.egg-info/PKG-INFO +1 -1
- {atomgit-1.0.9 → atomgit-1.0.11}/atomgit.egg-info/SOURCES.txt +4 -1
- {atomgit-1.0.9 → atomgit-1.0.11}/atomgit_hub.py +121 -135
- {atomgit-1.0.9 → atomgit-1.0.11}/cli.py +2 -2
- {atomgit-1.0.9 → atomgit-1.0.11}/rate_limiter.py +15 -2
- {atomgit-1.0.9 → atomgit-1.0.11}/setup.py +2 -2
- atomgit-1.0.11/tests/test_download_real.py +146 -0
- atomgit-1.0.11/tests/test_endpoint_config.py +78 -0
- atomgit-1.0.11/tests/test_repo_id_normalize.py +69 -0
- atomgit-1.0.11/tests/test_repo_name_validation.py +53 -0
- atomgit-1.0.9/tests/test_rate_limiter.py +0 -72
- {atomgit-1.0.9 → atomgit-1.0.11}/MANIFEST.in +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/README.md +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/__main__.py +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/atomgit.egg-info/dependency_links.txt +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/atomgit.egg-info/entry_points.txt +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/atomgit.egg-info/requires.txt +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/atomgit.egg-info/top_level.txt +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/config.py +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/requirements.txt +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/setup.cfg +0 -0
- {atomgit-1.0.9 → atomgit-1.0.11}/utils.py +0 -0
|
@@ -8,7 +8,7 @@ AtomGit CLI - 基于Transformers和Hugging Face Hub的模型文件上传下载
|
|
|
8
8
|
支持模型和数据集的上传、下载等操作。
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
__version__ = '1.0.
|
|
11
|
+
__version__ = '1.0.5'
|
|
12
12
|
__author__ = 'AtomGit CLI Team'
|
|
13
13
|
__description__ = 'AtomGit模型文件上传下载CLI工具'
|
|
14
14
|
|
|
@@ -31,6 +31,6 @@ except ImportError:
|
|
|
31
31
|
|
|
32
32
|
__all__ = [
|
|
33
33
|
'config', 'api', 'cli',
|
|
34
|
-
'snapshot_download', 'hub_download_url', 'download_file',
|
|
34
|
+
'snapshot_download', 'hub_download_url', 'download_file',
|
|
35
35
|
'upload_folder', 'create_repository'
|
|
36
|
-
]
|
|
36
|
+
]
|
|
@@ -5,8 +5,8 @@ import json
|
|
|
5
5
|
import urllib.request
|
|
6
6
|
import urllib.error
|
|
7
7
|
|
|
8
|
-
# 设置Hugging Face Hub的API端点为AtomGit
|
|
9
|
-
os.environ
|
|
8
|
+
# 设置Hugging Face Hub的API端点为AtomGit(可用环境变量覆盖,便于联调测试环境)
|
|
9
|
+
os.environ.setdefault("HF_ENDPOINT", "https://hub.atomgit.com")
|
|
10
10
|
# 禁用Xet协议,避免 xet-write-token 请求
|
|
11
11
|
os.environ["HF_HUB_DISABLE_XET"] = "1"
|
|
12
12
|
# 设置缓存目录
|
|
@@ -15,15 +15,20 @@ os.makedirs(cache_dir, exist_ok=True)
|
|
|
15
15
|
os.environ["HF_HOME"] = cache_dir
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
from huggingface_hub import hf_hub_download, upload_folder, create_repo,
|
|
18
|
+
from huggingface_hub import hf_hub_download, upload_folder, create_repo, constants as hf_constants
|
|
19
|
+
|
|
19
20
|
try:
|
|
20
|
-
from .
|
|
21
|
+
from . import atomgit_hub
|
|
21
22
|
except ImportError:
|
|
22
|
-
|
|
23
|
+
import atomgit_hub
|
|
24
|
+
|
|
23
25
|
try:
|
|
24
26
|
from .rate_limiter import install_hf_rate_limiter
|
|
25
27
|
except ImportError:
|
|
26
|
-
|
|
28
|
+
try:
|
|
29
|
+
from rate_limiter import install_hf_rate_limiter
|
|
30
|
+
except ImportError:
|
|
31
|
+
from atomgit.rate_limiter import install_hf_rate_limiter
|
|
27
32
|
|
|
28
33
|
install_hf_rate_limiter()
|
|
29
34
|
|
|
@@ -61,7 +66,7 @@ class HuggingFaceAPI:
|
|
|
61
66
|
|
|
62
67
|
# 二层或单层格式直接返回
|
|
63
68
|
return repo_id
|
|
64
|
-
|
|
69
|
+
|
|
65
70
|
def login(self, token: str) -> bool:
|
|
66
71
|
"""登录验证"""
|
|
67
72
|
if not token or len(token) < 10:
|
|
@@ -215,7 +220,7 @@ class HuggingFaceAPI:
|
|
|
215
220
|
def download_repo(self, repo_id: str, local_path: Path = None, force_download: bool = False) -> bool:
|
|
216
221
|
"""下载仓库 - 使用Hugging Face Hub SDK,支持公开仓库无需token和断点续传"""
|
|
217
222
|
try:
|
|
218
|
-
#
|
|
223
|
+
# repo_id 交给 atomgit_hub 归一化(三层 -> dash),探测也在那边完成
|
|
219
224
|
normalized_repo_id = self._normalize_repo_id(repo_id)
|
|
220
225
|
|
|
221
226
|
if local_path is None:
|
|
@@ -224,15 +229,16 @@ class HuggingFaceAPI:
|
|
|
224
229
|
# 创建本地目录
|
|
225
230
|
local_path.mkdir(parents=True, exist_ok=True)
|
|
226
231
|
|
|
227
|
-
# 首先尝试不使用token下载(适用于公开仓库)
|
|
228
232
|
credentials = config.get_credentials()
|
|
229
233
|
try:
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
# 复用 atomgit_hub:内部会先探测仓库可访问性,令牌无效但仓库公开时自动降级匿名,
|
|
235
|
+
# 避免 huggingface_hub 在 403/404 时静默回退本地目录导致假成功
|
|
236
|
+
atomgit_hub.snapshot_download(
|
|
237
|
+
repo_id=repo_id,
|
|
232
238
|
local_dir=str(local_path),
|
|
233
|
-
force_download=force_download,
|
|
239
|
+
force_download=force_download,
|
|
234
240
|
token=credentials['token'] if credentials and 'token' in credentials else None,
|
|
235
|
-
max_workers=8
|
|
241
|
+
max_workers=8,
|
|
236
242
|
)
|
|
237
243
|
print(f"✅ 仓库下载成功")
|
|
238
244
|
return True
|
|
@@ -258,14 +264,11 @@ class HuggingFaceAPI:
|
|
|
258
264
|
local_path.mkdir(parents=True, exist_ok=True)
|
|
259
265
|
|
|
260
266
|
# 首先尝试不使用token下载(适用于公开仓库)
|
|
261
|
-
credentials = config.get_credentials()
|
|
262
267
|
try:
|
|
263
268
|
hf_hub_download(
|
|
264
269
|
repo_id=normalized_repo_id,
|
|
265
270
|
filename=filename,
|
|
266
|
-
local_dir=str(local_path)
|
|
267
|
-
token=credentials['token'] if credentials else None,
|
|
268
|
-
force_download=force_download
|
|
271
|
+
local_dir=str(local_path)
|
|
269
272
|
)
|
|
270
273
|
print(f"✅ 文件下载成功")
|
|
271
274
|
return True
|
|
@@ -278,6 +281,7 @@ class HuggingFaceAPI:
|
|
|
278
281
|
print("检测到认证问题,尝试使用token下载...")
|
|
279
282
|
|
|
280
283
|
# 如果公开下载失败,尝试使用token下载
|
|
284
|
+
credentials = config.get_credentials()
|
|
281
285
|
if credentials:
|
|
282
286
|
try:
|
|
283
287
|
hf_hub_download(
|
|
@@ -320,4 +324,4 @@ class HuggingFaceAPI:
|
|
|
320
324
|
|
|
321
325
|
|
|
322
326
|
# 全局API实例
|
|
323
|
-
api = HuggingFaceAPI()
|
|
327
|
+
api = HuggingFaceAPI()
|
|
@@ -24,4 +24,7 @@ atomgit.egg-info/dependency_links.txt
|
|
|
24
24
|
atomgit.egg-info/entry_points.txt
|
|
25
25
|
atomgit.egg-info/requires.txt
|
|
26
26
|
atomgit.egg-info/top_level.txt
|
|
27
|
-
tests/
|
|
27
|
+
tests/test_download_real.py
|
|
28
|
+
tests/test_endpoint_config.py
|
|
29
|
+
tests/test_repo_id_normalize.py
|
|
30
|
+
tests/test_repo_name_validation.py
|
|
@@ -9,11 +9,13 @@ AtomGit Hub SDK - 类似huggingface_hub的SDK接口
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import os
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
12
14
|
from typing import Optional, Union, List, Dict, Any
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
|
|
15
|
-
# 设置Hugging Face Hub的API端点为AtomGit
|
|
16
|
-
os.environ
|
|
17
|
+
# 设置Hugging Face Hub的API端点为AtomGit(可用环境变量覆盖,便于联调测试环境)
|
|
18
|
+
os.environ.setdefault("HF_ENDPOINT", "https://hub.atomgit.com")
|
|
17
19
|
# 禁用Xet协议,避免 xet-write-token 请求
|
|
18
20
|
os.environ["HF_HUB_DISABLE_XET"] = "1"
|
|
19
21
|
# 设置缓存目录
|
|
@@ -22,9 +24,8 @@ os.makedirs(cache_dir, exist_ok=True)
|
|
|
22
24
|
os.environ["HF_HOME"] = cache_dir
|
|
23
25
|
|
|
24
26
|
from huggingface_hub import snapshot_download as hf_snapshot_download
|
|
25
|
-
from huggingface_hub import hf_hub_download,
|
|
26
|
-
|
|
27
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
27
|
+
from huggingface_hub import hf_hub_download, upload_folder as hf_upload_folder, create_repo
|
|
28
|
+
|
|
28
29
|
try:
|
|
29
30
|
from .rate_limiter import install_hf_rate_limiter
|
|
30
31
|
except ImportError:
|
|
@@ -88,52 +89,76 @@ def _get_token() -> Optional[str]:
|
|
|
88
89
|
return None
|
|
89
90
|
|
|
90
91
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
token:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
from huggingface_hub.hf_api import RepoFile
|
|
104
|
-
|
|
105
|
-
api = HfApi()
|
|
106
|
-
files = [
|
|
107
|
-
item.path
|
|
108
|
-
for item in api.list_repo_tree(
|
|
109
|
-
repo_id=repo_id, recursive=True, revision=revision, token=token
|
|
92
|
+
def _probe_status(repo_id: str, token: Optional[str] = None) -> Optional[int]:
|
|
93
|
+
"""探测仓库可访问性,返回 HTTP 状态码;网络异常返回 None。"""
|
|
94
|
+
endpoint = os.environ.get("HF_ENDPOINT", "https://hub.atomgit.com").rstrip("/")
|
|
95
|
+
headers = {"User-Agent": "atomgit-cli"}
|
|
96
|
+
if token:
|
|
97
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
98
|
+
|
|
99
|
+
status = None
|
|
100
|
+
# 默认分支可能是 main 或 master,404 时再试一个
|
|
101
|
+
for revision in ("main", "master"):
|
|
102
|
+
request = urllib.request.Request(
|
|
103
|
+
f"{endpoint}/api/models/{repo_id}/revision/{revision}", headers=headers
|
|
110
104
|
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(request, timeout=15):
|
|
107
|
+
return 200
|
|
108
|
+
except urllib.error.HTTPError as e:
|
|
109
|
+
status = e.code
|
|
110
|
+
if status != 404:
|
|
111
|
+
break
|
|
112
|
+
except Exception as e:
|
|
113
|
+
# 网络抖动不阻断下载,交给 SDK 自己重试
|
|
114
|
+
print(f"⚠ 仓库探测失败,跳过可访问性检查: {e}")
|
|
115
|
+
return None
|
|
116
|
+
return status
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _probe_repo(repo_id: str, token: Optional[str] = None) -> Optional[str]:
|
|
120
|
+
"""下载前确认仓库可访问,返回最终应使用的 token。
|
|
121
|
+
|
|
122
|
+
huggingface_hub 在远端 403/404 且 local_dir 已存在时,会打印一行
|
|
123
|
+
"Returning existing local_dir ..." 并静默返回本地目录,表现为"下载成功",
|
|
124
|
+
实际一个文件都没下。这里提前探测一次,给出明确错误。
|
|
125
|
+
|
|
126
|
+
返回值:
|
|
127
|
+
- 令牌有效:原 token
|
|
128
|
+
- 令牌无效但仓库公开:None(降级匿名下载,避免 snapshot 列表接口 401)
|
|
129
|
+
- 私有仓库无权限 / 仓库不存在:抛异常
|
|
130
|
+
- 网络异常:不阻断,返回原 token
|
|
131
|
+
"""
|
|
132
|
+
if token:
|
|
133
|
+
status = _probe_status(repo_id, token)
|
|
134
|
+
if status is None or 200 <= status < 300:
|
|
135
|
+
return token
|
|
136
|
+
if status in (401, 403):
|
|
137
|
+
anonymous = _probe_status(repo_id, None)
|
|
138
|
+
if anonymous is not None and 200 <= anonymous < 300:
|
|
139
|
+
print("⚠ 令牌无效或与仓库不匹配,但该仓库可公开访问,已降级为匿名下载")
|
|
140
|
+
return None
|
|
141
|
+
if status == 401:
|
|
142
|
+
raise Exception(
|
|
143
|
+
"认证失败:token 无效或已过期(401)。请使用 'atomgit login' 重新登录。"
|
|
144
|
+
)
|
|
145
|
+
raise Exception(
|
|
146
|
+
f"无权访问:{repo_id}(403)。请确认 token 拥有该仓库的读权限,或该仓库是否为私有。"
|
|
147
|
+
)
|
|
148
|
+
if status == 404:
|
|
149
|
+
raise Exception(f"仓库不存在:{repo_id}。请检查仓库名称是否正确。")
|
|
150
|
+
return token
|
|
151
|
+
|
|
152
|
+
status = _probe_status(repo_id, None)
|
|
153
|
+
if status is None or 200 <= status < 300:
|
|
154
|
+
return None
|
|
155
|
+
if status == 404:
|
|
156
|
+
raise Exception(f"仓库不存在:{repo_id}。请检查仓库名称是否正确。")
|
|
157
|
+
if status in (401, 403):
|
|
158
|
+
raise Exception(
|
|
159
|
+
f"无权访问:{repo_id}(403)。该仓库为私有,请先登录或提供有读权限的 token。"
|
|
132
160
|
)
|
|
133
|
-
|
|
134
|
-
with ThreadPoolExecutor(max_workers=max(1, max_workers)) as executor:
|
|
135
|
-
list(executor.map(download_one, files))
|
|
136
|
-
return str(destination.resolve())
|
|
161
|
+
return None
|
|
137
162
|
|
|
138
163
|
|
|
139
164
|
def snapshot_download(
|
|
@@ -225,30 +250,20 @@ def snapshot_download(
|
|
|
225
250
|
'tqdm_class': tqdm_class,
|
|
226
251
|
})
|
|
227
252
|
|
|
253
|
+
# 下载前探测:确认可访问;无效令牌在公开仓库上自动降级为匿名
|
|
254
|
+
if not local_files_only:
|
|
255
|
+
token = _probe_repo(normalized_repo_id, token)
|
|
256
|
+
if token is None:
|
|
257
|
+
kwargs.pop('token', None)
|
|
258
|
+
else:
|
|
259
|
+
kwargs['token'] = token
|
|
260
|
+
|
|
228
261
|
try:
|
|
229
262
|
# 使用token下载
|
|
230
263
|
result = hf_snapshot_download(**{k: v for k, v in kwargs.items() if v is not None})
|
|
231
264
|
return result
|
|
232
265
|
except Exception as e:
|
|
233
266
|
error_msg = str(e)
|
|
234
|
-
if local_dir is not None and (
|
|
235
|
-
"min() iterable argument is empty" in error_msg
|
|
236
|
-
or "min() arg is an empty sequence" in error_msg
|
|
237
|
-
or "404 Not Found" in error_msg
|
|
238
|
-
):
|
|
239
|
-
try:
|
|
240
|
-
return _snapshot_download_from_tree(
|
|
241
|
-
repo_id=normalized_repo_id,
|
|
242
|
-
local_dir=local_dir,
|
|
243
|
-
revision=revision,
|
|
244
|
-
token=token,
|
|
245
|
-
force_download=force_download,
|
|
246
|
-
allow_patterns=allow_patterns,
|
|
247
|
-
ignore_patterns=ignore_patterns,
|
|
248
|
-
max_workers=max_workers,
|
|
249
|
-
)
|
|
250
|
-
except Exception as fallback_error:
|
|
251
|
-
error_msg = str(fallback_error)
|
|
252
267
|
if "401" in error_msg or "403" in error_msg:
|
|
253
268
|
raise Exception(f"认证失败:{error_msg}。请检查token是否正确,或使用 'atomgit login' 重新登录。")
|
|
254
269
|
elif "404" in error_msg:
|
|
@@ -333,6 +348,13 @@ def download_file(
|
|
|
333
348
|
if force_download:
|
|
334
349
|
kwargs['force_download'] = force_download
|
|
335
350
|
|
|
351
|
+
# 下载前探测:确认可访问;无效令牌在公开仓库上自动降级为匿名
|
|
352
|
+
token = _probe_repo(normalized_repo_id, token)
|
|
353
|
+
if token is None:
|
|
354
|
+
kwargs.pop('token', None)
|
|
355
|
+
else:
|
|
356
|
+
kwargs['token'] = token
|
|
357
|
+
|
|
336
358
|
try:
|
|
337
359
|
result = hf_hub_download(**{k: v for k, v in kwargs.items() if v is not None})
|
|
338
360
|
return result
|
|
@@ -359,21 +381,18 @@ def upload_folder(
|
|
|
359
381
|
upload_timeout: float = 300.0,
|
|
360
382
|
) -> str:
|
|
361
383
|
"""
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
自动判断传入路径是文件还是文件夹,两者都支持。
|
|
365
|
-
|
|
384
|
+
上传文件夹到AtomGit Hub
|
|
385
|
+
|
|
366
386
|
参数:
|
|
367
|
-
folder_path (str 或 Path):
|
|
387
|
+
folder_path (str 或 Path): 本地文件夹路径
|
|
368
388
|
repo_id (str): 仓库ID
|
|
369
389
|
token (str, 可选): 认证token
|
|
370
390
|
repo_type (str, 可选): 仓库类型
|
|
371
391
|
revision (str, 可选): 分支名
|
|
372
392
|
commit_message (str, 可选): 提交消息
|
|
373
393
|
commit_description (str, 可选): 提交描述
|
|
374
|
-
path_in_repo (str, 可选):
|
|
375
|
-
|
|
376
|
-
ignore_patterns (List[str], 可选): 要忽略的文件模式(仅文件夹上传生效)
|
|
394
|
+
path_in_repo (str, 可选): 在仓库中的路径,默认为根目录
|
|
395
|
+
ignore_patterns (List[str], 可选): 要忽略的文件模式
|
|
377
396
|
upload_timeout (float, 可选): 上传超时时间(秒),默认60秒(1分钟)。
|
|
378
397
|
对于大文件,服务器处理响应可能需要较长时间。
|
|
379
398
|
|
|
@@ -384,7 +403,6 @@ def upload_folder(
|
|
|
384
403
|
>>> upload_folder("./my-model/", "username/repo-name")
|
|
385
404
|
>>> upload_folder("./data/", "username/repo", path_in_repo="datasets/")
|
|
386
405
|
>>> upload_folder("./big-files/", "username/repo", upload_timeout=1200.0) # 20分钟超时
|
|
387
|
-
>>> upload_folder("./weights.pth", "username/repo", path_in_repo="checkpoints/") # 上传单个文件
|
|
388
406
|
"""
|
|
389
407
|
# 标准化仓库ID
|
|
390
408
|
normalized_repo_id = _normalize_repo_id(repo_id)
|
|
@@ -393,10 +411,10 @@ def upload_folder(
|
|
|
393
411
|
folder_path = Path(folder_path)
|
|
394
412
|
|
|
395
413
|
if not folder_path.exists():
|
|
396
|
-
raise FileNotFoundError(f"
|
|
397
|
-
|
|
398
|
-
if not folder_path.
|
|
399
|
-
raise
|
|
414
|
+
raise FileNotFoundError(f"文件夹不存在: {folder_path}")
|
|
415
|
+
|
|
416
|
+
if not folder_path.is_dir():
|
|
417
|
+
raise NotADirectoryError(f"路径不是目录: {folder_path}")
|
|
400
418
|
|
|
401
419
|
# 如果没有提供token,尝试使用保存的token
|
|
402
420
|
if token is None:
|
|
@@ -404,62 +422,36 @@ def upload_folder(
|
|
|
404
422
|
if token is None:
|
|
405
423
|
raise Exception("上传需要认证token,请先使用 'atomgit login' 登录,或提供token参数。")
|
|
406
424
|
|
|
407
|
-
#
|
|
408
|
-
from huggingface_hub import constants as hf_constants
|
|
425
|
+
# 直接使用原始目录,或者创建临时目录来重新组织结构
|
|
409
426
|
import tempfile
|
|
410
427
|
import shutil
|
|
411
428
|
|
|
429
|
+
if path_in_repo == "./" or path_in_repo == "." or path_in_repo == "":
|
|
430
|
+
# 如果要上传到根目录,直接使用源文件夹
|
|
431
|
+
upload_path = str(folder_path)
|
|
432
|
+
else:
|
|
433
|
+
# 如果要上传到特定路径,需要重新组织目录结构
|
|
434
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
435
|
+
temp_path = Path(temp_dir)
|
|
436
|
+
|
|
437
|
+
# 创建目标路径
|
|
438
|
+
target_path = temp_path / path_in_repo.strip('./')
|
|
439
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
|
|
441
|
+
# 复制整个目录树
|
|
442
|
+
shutil.copytree(folder_path, target_path, dirs_exist_ok=True)
|
|
443
|
+
|
|
444
|
+
upload_path = str(temp_path)
|
|
445
|
+
# 使用 Monkey Patch 方式临时修改 huggingface_hub 的默认超时配置
|
|
446
|
+
from huggingface_hub import constants as hf_constants
|
|
447
|
+
|
|
412
448
|
# 保存原始超时配置
|
|
413
449
|
original_timeout = hf_constants.DEFAULT_REQUEST_TIMEOUT
|
|
414
|
-
|
|
450
|
+
|
|
415
451
|
# 临时修改超时配置
|
|
416
452
|
hf_constants.DEFAULT_REQUEST_TIMEOUT = upload_timeout
|
|
417
|
-
|
|
418
|
-
temp_dir = None
|
|
453
|
+
|
|
419
454
|
try:
|
|
420
|
-
# 上传单个文件
|
|
421
|
-
if folder_path.is_file():
|
|
422
|
-
# path_in_repo 按目录语义处理(与文件夹分支一致,去掉首尾 ./ 和 /),
|
|
423
|
-
# 文件名自动拼接到末尾
|
|
424
|
-
clean_path = path_in_repo.strip().strip("./")
|
|
425
|
-
if not clean_path:
|
|
426
|
-
target_path = folder_path.name
|
|
427
|
-
else:
|
|
428
|
-
target_path = clean_path + "/" + folder_path.name
|
|
429
|
-
commit_msg = commit_message or f"Upload file {folder_path.name}"
|
|
430
|
-
result = hf_upload_file(
|
|
431
|
-
repo_id=normalized_repo_id,
|
|
432
|
-
path_or_fileobj=str(folder_path),
|
|
433
|
-
path_in_repo=target_path,
|
|
434
|
-
token=token,
|
|
435
|
-
commit_message=commit_msg
|
|
436
|
-
)
|
|
437
|
-
return result
|
|
438
|
-
|
|
439
|
-
# 上传文件夹:直接使用原始目录,或者创建临时目录来重新组织结构
|
|
440
|
-
if path_in_repo == "./" or path_in_repo == "." or path_in_repo == "":
|
|
441
|
-
# 如果要上传到根目录,直接使用源文件夹
|
|
442
|
-
upload_path = str(folder_path)
|
|
443
|
-
else:
|
|
444
|
-
# 如果要上传到特定路径,需要重新组织目录结构。
|
|
445
|
-
# 注意:不能提前退出 with TemporaryDirectory(),否则上传时临时目录已被删除。
|
|
446
|
-
temp_dir = tempfile.mkdtemp(prefix="atomgit_upload_")
|
|
447
|
-
try:
|
|
448
|
-
temp_path = Path(temp_dir)
|
|
449
|
-
|
|
450
|
-
# 创建目标路径
|
|
451
|
-
target_path = temp_path / path_in_repo.strip('./')
|
|
452
|
-
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
453
|
-
|
|
454
|
-
# 复制整个目录树
|
|
455
|
-
shutil.copytree(folder_path, target_path, dirs_exist_ok=True)
|
|
456
|
-
|
|
457
|
-
except Exception:
|
|
458
|
-
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
459
|
-
raise
|
|
460
|
-
|
|
461
|
-
upload_path = str(temp_path)
|
|
462
|
-
|
|
463
455
|
# 使用huggingface_hub的upload_folder上传
|
|
464
456
|
commit_msg = commit_message or f"Upload folder {folder_path.name}"
|
|
465
457
|
result = hf_upload_folder(
|
|
@@ -468,7 +460,7 @@ def upload_folder(
|
|
|
468
460
|
token=token,
|
|
469
461
|
commit_message=commit_msg
|
|
470
462
|
)
|
|
471
|
-
|
|
463
|
+
|
|
472
464
|
return result
|
|
473
465
|
|
|
474
466
|
except Exception as e:
|
|
@@ -479,12 +471,6 @@ def upload_folder(
|
|
|
479
471
|
raise Exception(f"仓库不存在:{repo_id}")
|
|
480
472
|
else:
|
|
481
473
|
raise Exception(f"上传失败:{error_msg}")
|
|
482
|
-
finally:
|
|
483
|
-
# 恢复原始超时配置
|
|
484
|
-
hf_constants.DEFAULT_REQUEST_TIMEOUT = original_timeout
|
|
485
|
-
# 清理临时目录(仅重新组织目录结构时创建)
|
|
486
|
-
if temp_dir is not None:
|
|
487
|
-
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
488
474
|
|
|
489
475
|
|
|
490
476
|
def create_repository(
|
|
@@ -39,7 +39,7 @@ except ImportError:
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
@click.group()
|
|
42
|
-
@click.version_option(version='1.0.
|
|
42
|
+
@click.version_option(version='1.0.5')
|
|
43
43
|
def cli():
|
|
44
44
|
"""AtomGit CLI - 基于Transformers和Hugging Face Hub的AtomGit平台模型文件上传下载工具"""
|
|
45
45
|
pass
|
|
@@ -292,4 +292,4 @@ def config_show():
|
|
|
292
292
|
|
|
293
293
|
|
|
294
294
|
if __name__ == '__main__':
|
|
295
|
-
cli()
|
|
295
|
+
cli()
|
|
@@ -75,6 +75,17 @@ def install_hf_rate_limiter() -> bool:
|
|
|
75
75
|
except (ImportError, AttributeError):
|
|
76
76
|
return False
|
|
77
77
|
|
|
78
|
+
# huggingface_hub 1.x 默认请求/etag 超时是 10s,且不读取 HF_HUB_ETAG_TIMEOUT /
|
|
79
|
+
# HF_HUB_DOWNLOAD_TIMEOUT 环境变量;而 AtomGit 的 /tree 接口在大仓库上可能
|
|
80
|
+
# 耗时 15s 以上(实测 308 个文件的仓库约 18s),会直接 read timeout。
|
|
81
|
+
try:
|
|
82
|
+
from huggingface_hub import constants as hf_constants
|
|
83
|
+
|
|
84
|
+
hf_constants.DEFAULT_REQUEST_TIMEOUT = max(hf_constants.DEFAULT_REQUEST_TIMEOUT, 60)
|
|
85
|
+
hf_constants.DEFAULT_ETAG_TIMEOUT = max(hf_constants.DEFAULT_ETAG_TIMEOUT, 60)
|
|
86
|
+
except (ImportError, AttributeError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
78
89
|
# AtomGit's file metadata endpoint is limited to 50 requests/minute.
|
|
79
90
|
# Keep a safety margin and leave ordinary file transfer requests untouched.
|
|
80
91
|
limiter = SharedRateLimiter(rate_per_minute=45.0, burst=2)
|
|
@@ -102,6 +113,8 @@ def install_hf_rate_limiter() -> bool:
|
|
|
102
113
|
time.sleep(min(delay, 60.0))
|
|
103
114
|
attempts += 1
|
|
104
115
|
|
|
105
|
-
|
|
116
|
+
# httpx.Client 默认超时只有 5s,会覆盖 huggingface_hub 自己的超时配置;
|
|
117
|
+
# AtomGit 的 /tree 接口在较大仓库上可能耗时 15s 以上,必须显式给足超时
|
|
118
|
+
client_timeout = httpx.Timeout(60.0, connect=15.0)
|
|
119
|
+
set_client_factory(lambda: LimitedClient(timeout=client_timeout))
|
|
106
120
|
return True
|
|
107
|
-
_thread_lock = threading.Lock()
|
|
@@ -31,7 +31,7 @@ def read_requirements():
|
|
|
31
31
|
|
|
32
32
|
setup(
|
|
33
33
|
name='atomgit',
|
|
34
|
-
version='1.0.
|
|
34
|
+
version='1.0.11',
|
|
35
35
|
author='AtomGit CLI Team',
|
|
36
36
|
author_email='sa@atomgit.com',
|
|
37
37
|
description='AtomGit模型文件上传下载CLI工具',
|
|
@@ -69,4 +69,4 @@ setup(
|
|
|
69
69
|
'Bug Reports': 'https://atomgit.com/gitcode-ai/atomgit_cli/issues',
|
|
70
70
|
'Source': 'https://atomgit.com/gitcode-ai/atomgit_cli',
|
|
71
71
|
},
|
|
72
|
-
)
|
|
72
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""下载语义测试 —— 全部直连 https://hub.atomgit.com,不做任何网络 mock。
|
|
5
|
+
|
|
6
|
+
覆盖的优化点:
|
|
7
|
+
1. 三层仓库 ID 归一化后能真正下载到文件(端到端验证);
|
|
8
|
+
2. 仓库 / 文件不存在时返回 False,即使本地目录已有残留也不能报成功;
|
|
9
|
+
3. 无效令牌必须失败,不能静默成功;
|
|
10
|
+
4. 公开仓库匿名可下载。
|
|
11
|
+
|
|
12
|
+
说明:
|
|
13
|
+
- 除 ``config.get_credentials``(本地凭证来源)外,不 patch 任何东西;
|
|
14
|
+
- 本文件依赖 pre 环境与公网,环境不可用时失败属于预期结果。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import io
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
import unittest
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from unittest import mock
|
|
24
|
+
|
|
25
|
+
# 必须在导入 api 之前固定端点与超时:api.py / atomgit_hub.py 用 setdefault,不会覆盖这里的值
|
|
26
|
+
os.environ["HF_ENDPOINT"] = "https://hub.atomgit.com"
|
|
27
|
+
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "60")
|
|
28
|
+
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "120")
|
|
29
|
+
|
|
30
|
+
import api # noqa: E402
|
|
31
|
+
import atomgit_hub # noqa: E402
|
|
32
|
+
from huggingface_hub import constants as hf_constants # noqa: E402
|
|
33
|
+
|
|
34
|
+
ENDPOINT = "https://hub.atomgit.com"
|
|
35
|
+
# 用户输入的三层形态(会被归一化成 dash 形态再请求)
|
|
36
|
+
REPO_THREE_LEVEL = "hf_mirrors/Qwen/Qwen2.5-7B-Instruct-GGUF"
|
|
37
|
+
# 服务端实际使用的 dash 形态
|
|
38
|
+
REPO_DASH = "hf_mirrors-Qwen/Qwen2.5-7B-Instruct-GGUF"
|
|
39
|
+
SMALL_FILES = ["README.md", "LICENSE", ".gitattributes"]
|
|
40
|
+
MISSING_REPO = "hf_mirrors/Qwen/Qwen2.5-7B-Instruct-GGUF8"
|
|
41
|
+
MISSING_FILE = "not_exist_xxxxxxxx.bin"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PreEndpointGuardTests(unittest.TestCase):
|
|
45
|
+
"""确保用例真的打在 pre 上,而不是被外部环境变量带到别的端点。"""
|
|
46
|
+
|
|
47
|
+
def test_requests_really_go_to_pre_hub(self):
|
|
48
|
+
self.assertEqual(hf_constants.ENDPOINT, ENDPOINT)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DownloadFileAgainstPreTests(unittest.TestCase):
|
|
52
|
+
def setUp(self):
|
|
53
|
+
self.client = api.HuggingFaceAPI()
|
|
54
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
55
|
+
self.addCleanup(self._tmp.cleanup)
|
|
56
|
+
self.local_dir = Path(self._tmp.name) / "one-file"
|
|
57
|
+
|
|
58
|
+
def _download(self, credentials=None, **kwargs):
|
|
59
|
+
with mock.patch.object(api.config, "get_credentials", return_value=credentials):
|
|
60
|
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
61
|
+
result = self.client.download_file(**kwargs)
|
|
62
|
+
return result, out.getvalue()
|
|
63
|
+
|
|
64
|
+
# ---------- 正例 ----------
|
|
65
|
+
def test_three_level_repo_downloads_real_file(self):
|
|
66
|
+
"""三层 ID 归一化后真实下载成功,文件非空。"""
|
|
67
|
+
result, out = self._download(
|
|
68
|
+
repo_id=REPO_THREE_LEVEL,
|
|
69
|
+
filename="README.md",
|
|
70
|
+
local_path=self.local_dir,
|
|
71
|
+
)
|
|
72
|
+
self.assertTrue(result, out)
|
|
73
|
+
target = self.local_dir / "README.md"
|
|
74
|
+
self.assertTrue(target.is_file(), out)
|
|
75
|
+
self.assertGreater(target.stat().st_size, 0, "下载到的文件不应是 0 字节")
|
|
76
|
+
|
|
77
|
+
def test_public_repo_is_downloadable_without_token(self):
|
|
78
|
+
result, out = self._download(
|
|
79
|
+
credentials=None,
|
|
80
|
+
repo_id=REPO_DASH,
|
|
81
|
+
filename="LICENSE",
|
|
82
|
+
local_path=self.local_dir,
|
|
83
|
+
)
|
|
84
|
+
self.assertTrue(result, out)
|
|
85
|
+
self.assertGreater((self.local_dir / "LICENSE").stat().st_size, 0)
|
|
86
|
+
|
|
87
|
+
# ---------- 反例 ----------
|
|
88
|
+
def test_missing_file_returns_false(self):
|
|
89
|
+
result, out = self._download(
|
|
90
|
+
credentials=None,
|
|
91
|
+
repo_id=REPO_DASH,
|
|
92
|
+
filename=MISSING_FILE,
|
|
93
|
+
local_path=self.local_dir,
|
|
94
|
+
)
|
|
95
|
+
self.assertFalse(result)
|
|
96
|
+
self.assertNotIn("✅", out)
|
|
97
|
+
|
|
98
|
+
def test_public_repo_tolerates_invalid_token(self):
|
|
99
|
+
"""公开仓库不因调用者令牌无效而失败(实测 pre 会忽略无效令牌照常返回内容)。"""
|
|
100
|
+
result, out = self._download(
|
|
101
|
+
credentials={"token": "1231321321"},
|
|
102
|
+
repo_id=REPO_DASH,
|
|
103
|
+
filename="README.md",
|
|
104
|
+
local_path=self.local_dir,
|
|
105
|
+
)
|
|
106
|
+
self.assertTrue(result, out)
|
|
107
|
+
self.assertGreater((self.local_dir / "README.md").stat().st_size, 0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DownloadRepoAgainstPreTests(unittest.TestCase):
|
|
111
|
+
def setUp(self):
|
|
112
|
+
self.client = api.HuggingFaceAPI()
|
|
113
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
114
|
+
self.addCleanup(self._tmp.cleanup)
|
|
115
|
+
self.local_dir = Path(self._tmp.name) / "snapshot"
|
|
116
|
+
|
|
117
|
+
# ---------- 正例:整仓下载路径(限制为小文件,避免拉取数 GB 权重) ----------
|
|
118
|
+
def test_snapshot_download_with_patterns_succeeds(self):
|
|
119
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
120
|
+
path = atomgit_hub.snapshot_download(
|
|
121
|
+
REPO_THREE_LEVEL,
|
|
122
|
+
local_dir=str(self.local_dir),
|
|
123
|
+
allow_patterns=SMALL_FILES,
|
|
124
|
+
)
|
|
125
|
+
downloaded = [p for p in Path(path).iterdir() if p.is_file()]
|
|
126
|
+
self.assertTrue(downloaded, "整仓下载应至少落盘一个小文件")
|
|
127
|
+
for item in downloaded:
|
|
128
|
+
self.assertGreater(item.stat().st_size, 0, f"{item.name} 不应是 0 字节")
|
|
129
|
+
|
|
130
|
+
# ---------- 反例:不存在的仓库 + 已有残留目录,绝不能报成功 ----------
|
|
131
|
+
def test_missing_repo_with_stale_local_dir_does_not_report_success(self):
|
|
132
|
+
self.local_dir.mkdir(parents=True)
|
|
133
|
+
stale = self.local_dir / "keep.txt"
|
|
134
|
+
stale.write_text("stale-content", encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
with mock.patch.object(api.config, "get_credentials", return_value=None):
|
|
137
|
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
138
|
+
result = self.client.download_repo(MISSING_REPO, self.local_dir)
|
|
139
|
+
|
|
140
|
+
self.assertFalse(result)
|
|
141
|
+
self.assertNotIn("✅", out.getvalue())
|
|
142
|
+
self.assertEqual(stale.read_text(encoding="utf-8"), "stale-content")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
unittest.main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""端点配置测试。
|
|
5
|
+
|
|
6
|
+
优化点:端点必须可以被 HF_ENDPOINT 环境变量覆盖,否则无法联调 pre/测试环境。
|
|
7
|
+
由于两个模块都在 import 时读取环境变量,这里用子进程隔离验证。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
DEFAULT_API_ENDPOINT = "https://hub.atomgit.com"
|
|
18
|
+
DEFAULT_HUB_ENDPOINT = "https://hub.atomgit.com"
|
|
19
|
+
CUSTOM_ENDPOINT = "https://example.test"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_in_project(code, endpoint=None, timeout=120):
|
|
23
|
+
"""在项目根目录、去除 HF_ENDPOINT 的干净环境里执行代码。"""
|
|
24
|
+
env = {k: v for k, v in os.environ.items() if k != "HF_ENDPOINT"}
|
|
25
|
+
if endpoint is not None:
|
|
26
|
+
env["HF_ENDPOINT"] = endpoint
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
[sys.executable, "-c", code],
|
|
29
|
+
cwd=str(ROOT),
|
|
30
|
+
env=env,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=timeout,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def endpoint_of(module, endpoint=None):
|
|
38
|
+
code = (
|
|
39
|
+
f"import {module};"
|
|
40
|
+
"from huggingface_hub import constants;"
|
|
41
|
+
"print(constants.ENDPOINT)"
|
|
42
|
+
)
|
|
43
|
+
result = run_in_project(code, endpoint=endpoint)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EndpointConfigTests(unittest.TestCase):
|
|
48
|
+
# ---------- 正例 ----------
|
|
49
|
+
def test_api_module_honours_env_override(self):
|
|
50
|
+
result = endpoint_of("api", endpoint=CUSTOM_ENDPOINT)
|
|
51
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
52
|
+
self.assertEqual(result.stdout.strip(), CUSTOM_ENDPOINT)
|
|
53
|
+
|
|
54
|
+
def test_sdk_module_honours_env_override(self):
|
|
55
|
+
result = endpoint_of("atomgit_hub", endpoint=CUSTOM_ENDPOINT)
|
|
56
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
57
|
+
self.assertEqual(result.stdout.strip(), CUSTOM_ENDPOINT)
|
|
58
|
+
|
|
59
|
+
# ---------- 反例(未设置环境变量时的默认行为) ----------
|
|
60
|
+
def test_api_module_falls_back_to_pre_hub(self):
|
|
61
|
+
result = endpoint_of("api", endpoint=None)
|
|
62
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
63
|
+
self.assertEqual(result.stdout.strip(), DEFAULT_API_ENDPOINT)
|
|
64
|
+
|
|
65
|
+
def test_sdk_module_falls_back_to_hub(self):
|
|
66
|
+
"""记录现状:atomgit_hub 与 api 的默认端点目前不一致。"""
|
|
67
|
+
result = endpoint_of("atomgit_hub", endpoint=None)
|
|
68
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
69
|
+
self.assertEqual(result.stdout.strip(), DEFAULT_HUB_ENDPOINT)
|
|
70
|
+
|
|
71
|
+
def test_empty_env_value_does_not_break_import(self):
|
|
72
|
+
"""空值属于脏配置,只要 import 不崩即可(不做端点断言)。"""
|
|
73
|
+
result = endpoint_of("api", endpoint="")
|
|
74
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
unittest.main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""三层仓库 ID 归一化测试。
|
|
5
|
+
|
|
6
|
+
优化点:上游 /api/models/... 只认 dash 前缀(hf_mirrors-Qwen/repo),
|
|
7
|
+
而用户输入是三层形态(hf_mirrors/Qwen/repo),必须在发起请求前归一化。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
import atomgit_hub
|
|
15
|
+
from api import HuggingFaceAPI
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NormalizeRepoIdTests(unittest.TestCase):
|
|
19
|
+
def setUp(self):
|
|
20
|
+
self.client = HuggingFaceAPI()
|
|
21
|
+
|
|
22
|
+
def normalize(self, repo_id):
|
|
23
|
+
"""归一化会打印转换日志,测试里屏蔽掉避免噪声。"""
|
|
24
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
25
|
+
return self.client._normalize_repo_id(repo_id)
|
|
26
|
+
|
|
27
|
+
# ---------- 正例 ----------
|
|
28
|
+
def test_three_level_becomes_dash_prefix(self):
|
|
29
|
+
self.assertEqual(
|
|
30
|
+
self.normalize("hf_mirrors/Qwen/Qwen2.5-7B-Instruct-GGUF"),
|
|
31
|
+
"hf_mirrors-Qwen/Qwen2.5-7B-Instruct-GGUF",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def test_four_level_keeps_remaining_segments(self):
|
|
35
|
+
self.assertEqual(self.normalize("a/b/c/d"), "a-b/c/d")
|
|
36
|
+
|
|
37
|
+
def test_normalization_prints_conversion_log(self):
|
|
38
|
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
|
39
|
+
self.client._normalize_repo_id("hf_mirrors/Qwen/repo")
|
|
40
|
+
self.assertIn("三层仓库名称转换", out.getvalue())
|
|
41
|
+
self.assertIn("hf_mirrors-Qwen/repo", out.getvalue())
|
|
42
|
+
|
|
43
|
+
def test_module_level_function_is_consistent_with_client(self):
|
|
44
|
+
repo_id = "hf_mirrors/Qwen/Qwen2.5-7B-Instruct-GGUF"
|
|
45
|
+
self.assertEqual(atomgit_hub._normalize_repo_id(repo_id), self.normalize(repo_id))
|
|
46
|
+
|
|
47
|
+
# ---------- 反例 ----------
|
|
48
|
+
def test_two_level_is_unchanged(self):
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
self.normalize("gitcode-dev/atomgit-api-doc"),
|
|
51
|
+
"gitcode-dev/atomgit-api-doc",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def test_single_level_is_unchanged(self):
|
|
55
|
+
self.assertEqual(self.normalize("atomgit-api-doc"), "atomgit-api-doc")
|
|
56
|
+
|
|
57
|
+
def test_empty_string_is_unchanged(self):
|
|
58
|
+
self.assertEqual(self.normalize(""), "")
|
|
59
|
+
|
|
60
|
+
def test_leading_slash_produces_empty_first_segment(self):
|
|
61
|
+
# 记录现状:脏输入不会被归一化修正,需要靠 validate_repo_name 拦截
|
|
62
|
+
self.assertEqual(self.normalize("/a/b"), "-a/b")
|
|
63
|
+
|
|
64
|
+
def test_trailing_slash_keeps_empty_tail(self):
|
|
65
|
+
self.assertEqual(self.normalize("a/b/"), "a-b/")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
unittest.main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""仓库 ID 输入校验测试。
|
|
5
|
+
|
|
6
|
+
优化点:CLI 命令入口必须容忍合法的三层仓库 ID,同时拦截脏输入,
|
|
7
|
+
避免脏输入一路传到上游变成 404/401 的误导性报错。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
from utils import validate_repo_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ValidateRepoNameTests(unittest.TestCase):
|
|
16
|
+
# ---------- 正例 ----------
|
|
17
|
+
def test_two_level_is_valid(self):
|
|
18
|
+
self.assertTrue(validate_repo_name("gitcode-dev/atomgit-api-doc"))
|
|
19
|
+
|
|
20
|
+
def test_three_level_is_valid(self):
|
|
21
|
+
self.assertTrue(validate_repo_name("hf_mirrors/Qwen/Qwen2.5-7B-Instruct-GGUF"))
|
|
22
|
+
|
|
23
|
+
def test_more_than_three_level_is_valid(self):
|
|
24
|
+
self.assertTrue(validate_repo_name("a/b/c/d"))
|
|
25
|
+
|
|
26
|
+
def test_percent_encoded_is_valid(self):
|
|
27
|
+
self.assertTrue(validate_repo_name("hf_mirrors%2FQwen/Qwen2.5-7B-Instruct-GGUF"))
|
|
28
|
+
|
|
29
|
+
def test_dash_dot_underscore_are_valid(self):
|
|
30
|
+
self.assertTrue(validate_repo_name("hf_mirrors-Qwen/Qwen2.5_7B-GGUF.v1"))
|
|
31
|
+
|
|
32
|
+
# ---------- 反例 ----------
|
|
33
|
+
def test_empty_string_is_invalid(self):
|
|
34
|
+
self.assertFalse(validate_repo_name(""))
|
|
35
|
+
|
|
36
|
+
def test_no_slash_is_invalid(self):
|
|
37
|
+
self.assertFalse(validate_repo_name("atomgit-api-doc"))
|
|
38
|
+
|
|
39
|
+
def test_empty_segment_is_invalid(self):
|
|
40
|
+
self.assertFalse(validate_repo_name("a//b"))
|
|
41
|
+
|
|
42
|
+
def test_trailing_slash_is_invalid(self):
|
|
43
|
+
self.assertFalse(validate_repo_name("a/b/"))
|
|
44
|
+
|
|
45
|
+
def test_whitespace_is_invalid(self):
|
|
46
|
+
self.assertFalse(validate_repo_name("a/b c"))
|
|
47
|
+
|
|
48
|
+
def test_illegal_character_is_invalid(self):
|
|
49
|
+
self.assertFalse(validate_repo_name("a/b?x"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
unittest.main()
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import subprocess
|
|
3
|
-
import sys
|
|
4
|
-
import tempfile
|
|
5
|
-
import threading
|
|
6
|
-
import time
|
|
7
|
-
import unittest
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
from rate_limiter import SharedRateLimiter, install_hf_rate_limiter
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class RateLimiterTests(unittest.TestCase):
|
|
14
|
-
def test_concurrent_access_keeps_valid_shared_state(self):
|
|
15
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
16
|
-
state_file = Path(directory) / "rate.json"
|
|
17
|
-
limiter = SharedRateLimiter(60000, 4, state_file)
|
|
18
|
-
errors = []
|
|
19
|
-
|
|
20
|
-
def worker():
|
|
21
|
-
try:
|
|
22
|
-
for _ in range(10):
|
|
23
|
-
limiter.acquire()
|
|
24
|
-
except Exception as error: # pragma: no cover - assertion below
|
|
25
|
-
errors.append(error)
|
|
26
|
-
|
|
27
|
-
threads = [threading.Thread(target=worker) for _ in range(4)]
|
|
28
|
-
for thread in threads:
|
|
29
|
-
thread.start()
|
|
30
|
-
for thread in threads:
|
|
31
|
-
thread.join()
|
|
32
|
-
|
|
33
|
-
self.assertFalse(errors)
|
|
34
|
-
self.assertIsInstance(json.loads(state_file.read_text()), dict)
|
|
35
|
-
|
|
36
|
-
def test_multiple_processes_share_state_file(self):
|
|
37
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
38
|
-
state_file = Path(directory) / "rate.json"
|
|
39
|
-
code = (
|
|
40
|
-
"from pathlib import Path; "
|
|
41
|
-
"from rate_limiter import SharedRateLimiter; "
|
|
42
|
-
f"SharedRateLimiter(60000, 4, Path({str(state_file)!r})).acquire()"
|
|
43
|
-
)
|
|
44
|
-
processes = [subprocess.run([sys.executable, "-c", code]) for _ in range(3)]
|
|
45
|
-
self.assertTrue(all(process.returncode == 0 for process in processes))
|
|
46
|
-
self.assertTrue(state_file.exists())
|
|
47
|
-
|
|
48
|
-
def test_huggingface_client_factory_is_limited(self):
|
|
49
|
-
import huggingface_hub
|
|
50
|
-
|
|
51
|
-
self.assertTrue(install_hf_rate_limiter())
|
|
52
|
-
self.assertEqual(type(huggingface_hub.get_session()).__name__, "LimitedClient")
|
|
53
|
-
huggingface_hub.close_session()
|
|
54
|
-
|
|
55
|
-
def test_file_api_limit_is_conservative(self):
|
|
56
|
-
limiter = SharedRateLimiter()
|
|
57
|
-
self.assertAlmostEqual(limiter.rate * 60, 45.0)
|
|
58
|
-
self.assertEqual(limiter.burst, 2)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class InstalledCliTests(unittest.TestCase):
|
|
62
|
-
def test_installed_cli_help(self):
|
|
63
|
-
cli = Path(__file__).parents[1] / ".venv" / "bin" / "atomgit"
|
|
64
|
-
if not cli.exists():
|
|
65
|
-
self.skipTest(".venv/bin/atomgit is not installed")
|
|
66
|
-
result = subprocess.run([str(cli), "--help"], capture_output=True, text=True)
|
|
67
|
-
self.assertEqual(result.returncode, 0, result.stderr)
|
|
68
|
-
self.assertIn("download", result.stdout)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if __name__ == "__main__":
|
|
72
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|