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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomgit
3
- Version: 1.0.9
3
+ Version: 1.0.11
4
4
  Summary: AtomGit模型文件上传下载CLI工具
5
5
  Home-page: https://atomgit.com/gitcode-ai/atomgit_cli
6
6
  Author: AtomGit CLI Team
@@ -8,7 +8,7 @@ AtomGit CLI - 基于Transformers和Hugging Face Hub的模型文件上传下载
8
8
  支持模型和数据集的上传、下载等操作。
9
9
  """
10
10
 
11
- __version__ = '1.0.9'
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["HF_ENDPOINT"] = "https://hub.atomgit.com"
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, snapshot_download, constants as hf_constants
18
+ from huggingface_hub import hf_hub_download, upload_folder, create_repo, constants as hf_constants
19
+
19
20
  try:
20
- from .atomgit_hub import snapshot_download as atomgit_snapshot_download
21
+ from . import atomgit_hub
21
22
  except ImportError:
22
- from atomgit_hub import snapshot_download as atomgit_snapshot_download
23
+ import atomgit_hub
24
+
23
25
  try:
24
26
  from .rate_limiter import install_hf_rate_limiter
25
27
  except ImportError:
26
- from rate_limiter import install_hf_rate_limiter
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
- # 标准化仓库ID(处理三层格式)
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
- atomgit_snapshot_download(
231
- repo_id=normalized_repo_id,
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomgit
3
- Version: 1.0.9
3
+ Version: 1.0.11
4
4
  Summary: AtomGit模型文件上传下载CLI工具
5
5
  Home-page: https://atomgit.com/gitcode-ai/atomgit_cli
6
6
  Author: AtomGit CLI Team
@@ -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/test_rate_limiter.py
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["HF_ENDPOINT"] = "https://hub.atomgit.com"
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, upload_file as hf_upload_file, upload_folder as hf_upload_folder, create_repo
26
- from huggingface_hub import HfApi
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 _snapshot_download_from_tree(
92
- repo_id: str,
93
- local_dir: Union[str, Path],
94
- revision: Optional[str],
95
- token: Optional[Union[str, bool]],
96
- force_download: bool,
97
- allow_patterns: Optional[Union[List[str], str]],
98
- ignore_patterns: Optional[Union[List[str], str]],
99
- max_workers: int,
100
- ) -> str:
101
- """Download through list_repo_tree when repo_info.siblings is unavailable."""
102
- from huggingface_hub.utils import filter_repo_objects
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
- if isinstance(item, RepoFile)
112
- ]
113
- files = list(filter_repo_objects(
114
- items=files,
115
- allow_patterns=allow_patterns,
116
- ignore_patterns=ignore_patterns,
117
- ))
118
- if not files:
119
- raise FileNotFoundError(f"仓库没有可下载的文件: {repo_id}")
120
-
121
- destination = Path(local_dir)
122
- destination.mkdir(parents=True, exist_ok=True)
123
-
124
- def download_one(filename: str) -> None:
125
- hf_hub_download(
126
- repo_id=repo_id,
127
- filename=filename,
128
- revision=revision,
129
- token=token,
130
- local_dir=str(destination),
131
- force_download=force_download,
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
- 上传文件或文件夹到AtomGit Hub
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"路径不存在: {folder_path}")
397
-
398
- if not folder_path.is_file() and not folder_path.is_dir():
399
- raise ValueError(f"路径既不是文件也不是目录: {folder_path}")
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
- # 使用 Monkey Patch 方式临时修改 huggingface_hub 的默认超时配置
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.9')
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
- set_client_factory(lambda: LimitedClient())
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.9',
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