atomgit 1.0.5__tar.gz → 1.0.7__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.5 → atomgit-1.0.7}/PKG-INFO +1 -1
- {atomgit-1.0.5 → atomgit-1.0.7}/__init__.py +1 -1
- {atomgit-1.0.5 → atomgit-1.0.7}/api.py +13 -4
- {atomgit-1.0.5 → atomgit-1.0.7}/atomgit.egg-info/PKG-INFO +1 -1
- {atomgit-1.0.5 → atomgit-1.0.7}/atomgit.egg-info/SOURCES.txt +4 -1
- {atomgit-1.0.5 → atomgit-1.0.7}/atomgit_hub.py +22 -3
- {atomgit-1.0.5 → atomgit-1.0.7}/cli.py +1 -1
- atomgit-1.0.7/rate_limiter.py +107 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/setup.py +1 -1
- {atomgit-1.0.5 → atomgit-1.0.7}/test.py +1 -1
- atomgit-1.0.7/tests/test_rate_limiter.py +72 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/MANIFEST.in +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/README.md +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/__main__.py +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/atomgit.egg-info/dependency_links.txt +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/atomgit.egg-info/entry_points.txt +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/atomgit.egg-info/requires.txt +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/atomgit.egg-info/top_level.txt +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/config.py +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/requirements.txt +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/setup.cfg +0 -0
- {atomgit-1.0.5 → atomgit-1.0.7}/utils.py +0 -0
|
@@ -16,6 +16,12 @@ os.environ["HF_HOME"] = cache_dir
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
from huggingface_hub import hf_hub_download, upload_folder, create_repo, snapshot_download, constants as hf_constants
|
|
19
|
+
try:
|
|
20
|
+
from .rate_limiter import install_hf_rate_limiter
|
|
21
|
+
except ImportError:
|
|
22
|
+
from rate_limiter import install_hf_rate_limiter
|
|
23
|
+
|
|
24
|
+
install_hf_rate_limiter()
|
|
19
25
|
|
|
20
26
|
try:
|
|
21
27
|
from .config import config
|
|
@@ -221,7 +227,8 @@ class HuggingFaceAPI:
|
|
|
221
227
|
repo_id=normalized_repo_id,
|
|
222
228
|
local_dir=str(local_path),
|
|
223
229
|
force_download=force_download, # 根据用户选择决定是否强制下载
|
|
224
|
-
token=credentials['token'] if credentials and 'token' in credentials else None
|
|
230
|
+
token=credentials['token'] if credentials and 'token' in credentials else None,
|
|
231
|
+
max_workers=8
|
|
225
232
|
)
|
|
226
233
|
print(f"✅ 仓库下载成功")
|
|
227
234
|
return True
|
|
@@ -247,11 +254,14 @@ class HuggingFaceAPI:
|
|
|
247
254
|
local_path.mkdir(parents=True, exist_ok=True)
|
|
248
255
|
|
|
249
256
|
# 首先尝试不使用token下载(适用于公开仓库)
|
|
257
|
+
credentials = config.get_credentials()
|
|
250
258
|
try:
|
|
251
259
|
hf_hub_download(
|
|
252
260
|
repo_id=normalized_repo_id,
|
|
253
261
|
filename=filename,
|
|
254
|
-
local_dir=str(local_path)
|
|
262
|
+
local_dir=str(local_path),
|
|
263
|
+
token=credentials['token'] if credentials else None,
|
|
264
|
+
force_download=force_download
|
|
255
265
|
)
|
|
256
266
|
print(f"✅ 文件下载成功")
|
|
257
267
|
return True
|
|
@@ -264,7 +274,6 @@ class HuggingFaceAPI:
|
|
|
264
274
|
print("检测到认证问题,尝试使用token下载...")
|
|
265
275
|
|
|
266
276
|
# 如果公开下载失败,尝试使用token下载
|
|
267
|
-
credentials = config.get_credentials()
|
|
268
277
|
if credentials:
|
|
269
278
|
try:
|
|
270
279
|
hf_hub_download(
|
|
@@ -307,4 +316,4 @@ class HuggingFaceAPI:
|
|
|
307
316
|
|
|
308
317
|
|
|
309
318
|
# 全局API实例
|
|
310
|
-
api = HuggingFaceAPI()
|
|
319
|
+
api = HuggingFaceAPI()
|
|
@@ -6,6 +6,7 @@ api.py
|
|
|
6
6
|
atomgit_hub.py
|
|
7
7
|
cli.py
|
|
8
8
|
config.py
|
|
9
|
+
rate_limiter.py
|
|
9
10
|
requirements.txt
|
|
10
11
|
setup.py
|
|
11
12
|
test.py
|
|
@@ -16,6 +17,7 @@ utils.py
|
|
|
16
17
|
./atomgit_hub.py
|
|
17
18
|
./cli.py
|
|
18
19
|
./config.py
|
|
20
|
+
./rate_limiter.py
|
|
19
21
|
./test.py
|
|
20
22
|
./utils.py
|
|
21
23
|
atomgit.egg-info/PKG-INFO
|
|
@@ -23,4 +25,5 @@ atomgit.egg-info/SOURCES.txt
|
|
|
23
25
|
atomgit.egg-info/dependency_links.txt
|
|
24
26
|
atomgit.egg-info/entry_points.txt
|
|
25
27
|
atomgit.egg-info/requires.txt
|
|
26
|
-
atomgit.egg-info/top_level.txt
|
|
28
|
+
atomgit.egg-info/top_level.txt
|
|
29
|
+
tests/test_rate_limiter.py
|
|
@@ -23,6 +23,15 @@ os.environ["HF_HOME"] = cache_dir
|
|
|
23
23
|
|
|
24
24
|
from huggingface_hub import snapshot_download as hf_snapshot_download
|
|
25
25
|
from huggingface_hub import hf_hub_download, upload_folder as hf_upload_folder, create_repo
|
|
26
|
+
try:
|
|
27
|
+
from .rate_limiter import install_hf_rate_limiter
|
|
28
|
+
except ImportError:
|
|
29
|
+
try:
|
|
30
|
+
from rate_limiter import install_hf_rate_limiter
|
|
31
|
+
except ImportError:
|
|
32
|
+
from atomgit.rate_limiter import install_hf_rate_limiter
|
|
33
|
+
|
|
34
|
+
install_hf_rate_limiter()
|
|
26
35
|
|
|
27
36
|
|
|
28
37
|
try:
|
|
@@ -331,8 +340,10 @@ def upload_folder(
|
|
|
331
340
|
# 如果要上传到根目录,直接使用源文件夹
|
|
332
341
|
upload_path = str(folder_path)
|
|
333
342
|
else:
|
|
334
|
-
#
|
|
335
|
-
with
|
|
343
|
+
# 如果要上传到特定路径,需要重新组织目录结构。
|
|
344
|
+
# 注意:不能提前退出 with TemporaryDirectory(),否则上传时临时目录已被删除。
|
|
345
|
+
temp_dir = tempfile.mkdtemp(prefix="atomgit_upload_")
|
|
346
|
+
try:
|
|
336
347
|
temp_path = Path(temp_dir)
|
|
337
348
|
|
|
338
349
|
# 创建目标路径
|
|
@@ -342,7 +353,11 @@ def upload_folder(
|
|
|
342
353
|
# 复制整个目录树
|
|
343
354
|
shutil.copytree(folder_path, target_path, dirs_exist_ok=True)
|
|
344
355
|
|
|
345
|
-
|
|
356
|
+
except Exception:
|
|
357
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
358
|
+
raise
|
|
359
|
+
|
|
360
|
+
upload_path = str(temp_path)
|
|
346
361
|
# 使用 Monkey Patch 方式临时修改 huggingface_hub 的默认超时配置
|
|
347
362
|
from huggingface_hub import constants as hf_constants
|
|
348
363
|
|
|
@@ -372,6 +387,10 @@ def upload_folder(
|
|
|
372
387
|
raise Exception(f"仓库不存在:{repo_id}")
|
|
373
388
|
else:
|
|
374
389
|
raise Exception(f"上传失败:{error_msg}")
|
|
390
|
+
finally:
|
|
391
|
+
# 清理临时目录(仅重新组织目录结构时创建)
|
|
392
|
+
if path_in_repo not in ("./", ".", ""):
|
|
393
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
375
394
|
|
|
376
395
|
|
|
377
396
|
def create_repository(
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Shared client-side rate limiting for AtomGit Hub requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SharedRateLimiter:
|
|
15
|
+
"""A cross-process leaky bucket using a small lock-protected state file."""
|
|
16
|
+
|
|
17
|
+
_thread_lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def __init__(self, rate_per_minute: float = 45.0, burst: int = 2,
|
|
20
|
+
state_file: Optional[Path] = None) -> None:
|
|
21
|
+
self.rate = max(1.0, rate_per_minute) / 60.0
|
|
22
|
+
self.burst = max(1, burst)
|
|
23
|
+
self.state_file = state_file or (
|
|
24
|
+
Path(os.path.expanduser("~/.cache/atomgit")) / "rate_limit.json"
|
|
25
|
+
)
|
|
26
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
def acquire(self) -> None:
|
|
29
|
+
"""Reserve one request slot, sharing reservations across processes."""
|
|
30
|
+
while True:
|
|
31
|
+
wait_for = 0.0
|
|
32
|
+
# flock is process-scoped on some Unix systems; the thread lock is
|
|
33
|
+
# therefore also required when a single CLI uses a worker pool.
|
|
34
|
+
with self._thread_lock, self.state_file.open("a+", encoding="utf-8") as handle:
|
|
35
|
+
try:
|
|
36
|
+
import fcntl
|
|
37
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
38
|
+
except ImportError:
|
|
39
|
+
pass
|
|
40
|
+
handle.seek(0)
|
|
41
|
+
try:
|
|
42
|
+
state = json.load(handle)
|
|
43
|
+
except (json.JSONDecodeError, EOFError):
|
|
44
|
+
state = {"next_slot": 0.0}
|
|
45
|
+
|
|
46
|
+
now = time.monotonic()
|
|
47
|
+
next_slot = float(state.get("next_slot", 0.0))
|
|
48
|
+
# A reservation may be at most ``burst`` intervals ahead.
|
|
49
|
+
reservation = max(next_slot, now - self.burst / self.rate)
|
|
50
|
+
reservation += 1.0 / self.rate
|
|
51
|
+
state["next_slot"] = reservation
|
|
52
|
+
handle.seek(0)
|
|
53
|
+
handle.truncate()
|
|
54
|
+
json.dump(state, handle)
|
|
55
|
+
handle.flush()
|
|
56
|
+
wait_for = max(0.0, reservation - now)
|
|
57
|
+
try:
|
|
58
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
59
|
+
except (NameError, UnboundLocalError):
|
|
60
|
+
pass
|
|
61
|
+
if wait_for <= 0:
|
|
62
|
+
return
|
|
63
|
+
time.sleep(min(wait_for, 1.0))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def install_hf_rate_limiter() -> bool:
|
|
67
|
+
"""Install the limiter into huggingface_hub v1.x when available.
|
|
68
|
+
|
|
69
|
+
The function is intentionally best-effort so the CLI remains compatible
|
|
70
|
+
with older huggingface_hub releases.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
import httpx
|
|
74
|
+
from huggingface_hub import set_client_factory
|
|
75
|
+
except (ImportError, AttributeError):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
# AtomGit's file metadata endpoint is limited to 50 requests/minute.
|
|
79
|
+
# Keep a safety margin and leave ordinary file transfer requests untouched.
|
|
80
|
+
limiter = SharedRateLimiter(rate_per_minute=45.0, burst=2)
|
|
81
|
+
|
|
82
|
+
def is_file_api_request(request) -> bool:
|
|
83
|
+
path = urlparse(str(request.url)).path.rstrip("/")
|
|
84
|
+
return "/api/v1/file/" in f"{path}/"
|
|
85
|
+
|
|
86
|
+
class LimitedClient(httpx.Client):
|
|
87
|
+
def send(self, request, *args, **kwargs):
|
|
88
|
+
attempts = 0
|
|
89
|
+
while True:
|
|
90
|
+
limited = is_file_api_request(request)
|
|
91
|
+
if limited:
|
|
92
|
+
limiter.acquire()
|
|
93
|
+
response = super().send(request, *args, **kwargs)
|
|
94
|
+
if not limited or response.status_code != 429 or attempts >= 5:
|
|
95
|
+
return response
|
|
96
|
+
retry_after = response.headers.get("Retry-After")
|
|
97
|
+
try:
|
|
98
|
+
delay = max(1.0, float(retry_after)) if retry_after else 2 ** attempts
|
|
99
|
+
except ValueError:
|
|
100
|
+
delay = 2 ** attempts
|
|
101
|
+
response.close()
|
|
102
|
+
time.sleep(min(delay, 60.0))
|
|
103
|
+
attempts += 1
|
|
104
|
+
|
|
105
|
+
set_client_factory(lambda: LimitedClient())
|
|
106
|
+
return True
|
|
107
|
+
_thread_lock = threading.Lock()
|
|
@@ -130,7 +130,7 @@ def run_all_tests():
|
|
|
130
130
|
# 2. 测试upload
|
|
131
131
|
# test_upload_folder(repo_id=model, folder_path=model_folder_path)
|
|
132
132
|
# test_upload_folder(repo_id=dataset, folder_path=dataset_folder_path)
|
|
133
|
-
test_upload_folder(repo_id="yanlp/dataset-
|
|
133
|
+
test_upload_folder(repo_id="yanlp/dataset-t2", folder_path="/Users/yanlp/csdn/IdeaProjects/gitcode/gitcode-hf-registry/dataset-t11")
|
|
134
134
|
|
|
135
135
|
# 3. 测试下载
|
|
136
136
|
# test_snapshot_download(repo_id=model, local_dir="./test_downloads/model/snapshot_full")
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|