Skip to content

Commit e8651f0

Browse files
committed
Model Hub support
1 parent c4cb309 commit e8651f0

17 files changed

Lines changed: 954 additions & 227 deletions

examples/gguf_example.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515
client = wildedge.WildEdge(
1616
app_version="1.0.0", # set WILDEDGE_DSN env var
1717
)
18-
client.instrument("huggingface")
19-
client.instrument("gguf")
18+
client.instrument("gguf", hubs=["huggingface"])
2019

2120
model_path = hf_hub_download(
2221
"bartowski/Llama-3.2-1B-Instruct-GGUF",

examples/onnx_example.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
client = wildedge.WildEdge(
1717
app_version="1.0.0", # set WILDEDGE_DSN env var
1818
)
19-
client.instrument("huggingface")
20-
client.instrument("onnx")
19+
client.instrument("onnx", hubs=["huggingface"])
2120

2221
model_path = hf_hub_download("Xenova/resnet-50", "onnx/model.onnx")
2322
session = ort.InferenceSession(model_path)

examples/timm_example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
client = wildedge.WildEdge(
2323
app_version="1.0.0", # set WILDEDGE_DSN env var
2424
)
25-
client.instrument("timm")
25+
client.instrument("timm", hubs=["huggingface", "torchhub"])
2626

2727
model = timm.create_model("resnet18", pretrained=True)
2828
model.eval()

tests/test_client_flows.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ def test_register_model_fallback_requires_id_when_no_extractor(
1818
client.register_model(object())
1919

2020

21-
def test_on_model_auto_loaded_uses_hf_records_when_downloads_missing(
21+
def test_on_model_auto_loaded_uses_hub_records_when_downloads_missing(
2222
client_with_stubbed_runtime, dummy_handle
2323
):
2424
client = client_with_stubbed_runtime
25-
client._hf_instrumented = True
2625

2726
records = [
2827
{
@@ -37,7 +36,7 @@ def test_on_model_auto_loaded_uses_hf_records_when_downloads_missing(
3736
]
3837

3938
with (
40-
patch("wildedge.client.drain_downloads", return_value=records),
39+
patch.object(client, "_drain_hub_trackers", return_value=records),
4140
patch.object(client, "register_model", return_value=dummy_handle),
4241
):
4342
client._on_model_auto_loaded(DummyModel(), load_ms=5)
@@ -101,7 +100,7 @@ def test_load_skips_duplicate_track_load_for_auto_loaded_model(
101100
):
102101
client = client_with_stubbed_runtime
103102
dummy_handle.model_id = "dup-model"
104-
client._auto_loaded.add("dup-model")
103+
client.auto_loaded.add("dup-model")
105104

106105
with patch.object(client, "register_model", return_value=dummy_handle):
107106
client.load(DummyModel)

tests/test_hubs.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Tests for wildedge.hubs hub trackers."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import types
7+
from unittest.mock import patch
8+
9+
from wildedge.hubs.huggingface import HuggingFaceHubTracker
10+
from wildedge.hubs.torchhub import TorchHubTracker
11+
12+
# ---------------------------------------------------------------------------
13+
# BaseHubTracker.scan_cache
14+
# ---------------------------------------------------------------------------
15+
16+
17+
def test_scan_cache_returns_real_files_skips_symlinks(tmp_path):
18+
real_file = tmp_path / "blob"
19+
real_file.write_bytes(b"x" * 100)
20+
link = tmp_path / "link"
21+
link.symlink_to(real_file)
22+
23+
tracker = HuggingFaceHubTracker()
24+
with patch.object(tracker, "cache_dir", return_value=str(tmp_path)):
25+
result = tracker.scan_cache()
26+
27+
assert str(real_file) in result
28+
assert str(link) not in result
29+
assert result[str(real_file)] == 100
30+
31+
32+
def test_scan_cache_returns_empty_when_no_cache_dir():
33+
tracker = HuggingFaceHubTracker()
34+
with patch.object(tracker, "cache_dir", return_value=None):
35+
assert tracker.scan_cache() == {}
36+
37+
38+
def test_scan_cache_returns_empty_when_dir_missing():
39+
tracker = HuggingFaceHubTracker()
40+
with patch.object(tracker, "cache_dir", return_value="/nonexistent/path/xyz"):
41+
assert tracker.scan_cache() == {}
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# HuggingFaceHubTracker.diff_to_records
46+
# ---------------------------------------------------------------------------
47+
48+
49+
def test_hf_diff_to_records_groups_by_repo():
50+
tracker = HuggingFaceHubTracker()
51+
sep = os.sep
52+
before = {}
53+
after = {
54+
f"{sep}cache{sep}hub{sep}models--facebook--opt-125m{sep}blobs{sep}sha1": 200_000_000,
55+
f"{sep}cache{sep}hub{sep}models--facebook--opt-125m{sep}snapshots{sep}abc{sep}config.json": 1_000,
56+
f"{sep}cache{sep}hub{sep}models--bert-base-uncased{sep}blobs{sep}sha2": 400_000_000,
57+
}
58+
records = tracker.diff_to_records(before, after, elapsed_ms=5000)
59+
60+
assert len(records) == 2
61+
repo_ids = {r["repo_id"] for r in records}
62+
assert repo_ids == {"facebook/opt-125m", "bert-base-uncased"}
63+
for r in records:
64+
assert r["source_type"] == "huggingface"
65+
assert r["source_url"] == f"hf://{r['repo_id']}"
66+
assert r["cache_hit"] is False
67+
assert r["duration_ms"] == 5000
68+
69+
opt = next(r for r in records if r["repo_id"] == "facebook/opt-125m")
70+
assert opt["size"] == 200_000_000 + 1_000
71+
72+
73+
def test_hf_diff_to_records_returns_empty_when_no_new_files():
74+
tracker = HuggingFaceHubTracker()
75+
snapshot = {"/cache/blobs/sha1": 100}
76+
assert tracker.diff_to_records(snapshot, snapshot, elapsed_ms=1000) == []
77+
78+
79+
def test_hf_diff_to_records_ignores_files_outside_models_dirs():
80+
tracker = HuggingFaceHubTracker()
81+
before = {}
82+
after = {"/cache/hub/some_other_file.txt": 500}
83+
# Files not under a models-- directory are silently dropped (no repo_id)
84+
records = tracker.diff_to_records(before, after, elapsed_ms=1000)
85+
assert records == []
86+
87+
88+
# ---------------------------------------------------------------------------
89+
# TorchHubTracker.diff_to_records
90+
# ---------------------------------------------------------------------------
91+
92+
93+
def test_torch_hub_diff_to_records_checkpoints():
94+
tracker = TorchHubTracker()
95+
hub_dir = "/home/user/.cache/torch/hub"
96+
before = {}
97+
after = {f"{hub_dir}/checkpoints/resnet50-0676ba61.pth": 97_781_926}
98+
99+
with patch.object(tracker, "cache_dir", return_value=hub_dir):
100+
records = tracker.diff_to_records(before, after, elapsed_ms=3000)
101+
102+
assert len(records) == 1
103+
r = records[0]
104+
assert r["source_type"] == "torchhub"
105+
assert r["source_url"] == "torchhub://checkpoints/resnet50-0676ba61.pth"
106+
assert r["repo_id"] == "resnet50.pth" # hash suffix stripped
107+
assert r["cache_hit"] is False
108+
assert r["size"] == 97_781_926
109+
110+
111+
def test_torch_hub_diff_to_records_repo_clone_dir():
112+
tracker = TorchHubTracker()
113+
hub_dir = "/home/user/.cache/torch/hub"
114+
before = {}
115+
after = {
116+
f"{hub_dir}/pytorch_vision_v0.10.0/hubconf.py": 2_000,
117+
f"{hub_dir}/pytorch_vision_v0.10.0/torchvision/models/resnet.py": 30_000,
118+
}
119+
120+
with patch.object(tracker, "cache_dir", return_value=hub_dir):
121+
records = tracker.diff_to_records(before, after, elapsed_ms=2000)
122+
123+
assert len(records) == 2
124+
for r in records:
125+
assert r["source_type"] == "torchhub"
126+
assert r["source_url"] == "torchhub://pytorch/vision"
127+
assert r["repo_id"] == "pytorch/vision"
128+
129+
130+
def test_torch_hub_diff_to_records_empty_when_no_new_files():
131+
tracker = TorchHubTracker()
132+
snapshot = {"/cache/torch/hub/checkpoints/model.pth": 1000}
133+
with patch.object(tracker, "cache_dir", return_value="/cache/torch/hub"):
134+
assert tracker.diff_to_records(snapshot, snapshot, elapsed_ms=500) == []
135+
136+
137+
# ---------------------------------------------------------------------------
138+
# TorchHubTracker.install_patch idempotency
139+
# ---------------------------------------------------------------------------
140+
141+
142+
def test_torch_hub_install_patch_is_idempotent(monkeypatch):
143+
import wildedge.hubs.torchhub as torchhub_mod
144+
145+
original_load_calls = []
146+
147+
class FakeHub:
148+
@staticmethod
149+
def load(repo_or_dir, model, *args, **kwargs):
150+
original_load_calls.append((repo_or_dir, model))
151+
return object()
152+
153+
@staticmethod
154+
def get_dir():
155+
return "/tmp/hub"
156+
157+
fake_torch = types.SimpleNamespace(hub=FakeHub)
158+
monkeypatch.setattr(torchhub_mod, "_torch", fake_torch)
159+
monkeypatch.setattr(torchhub_mod, "_torch_hub_load_patched", False)
160+
161+
tracker = TorchHubTracker()
162+
tracker.install_patch(lambda: None)
163+
first_patched = fake_torch.hub.load
164+
tracker.install_patch(lambda: None)
165+
second_patched = fake_torch.hub.load
166+
167+
assert first_patched is second_patched
168+
assert getattr(first_patched, "__wildedge_patch_name__", None) == "torchhub_load"
169+
170+
171+
# ---------------------------------------------------------------------------
172+
# HuggingFaceHubTracker.drain (thread-local buffer)
173+
# ---------------------------------------------------------------------------
174+
175+
176+
def test_hf_tracker_drain_returns_and_clears_buffer(monkeypatch):
177+
import wildedge.hubs.huggingface as hf_mod
178+
179+
tracker = HuggingFaceHubTracker()
180+
# Directly inject a record into the thread-local buffer
181+
hf_mod._buffer().append({"repo_id": "test/model", "source_type": "huggingface"})
182+
183+
result = tracker.drain()
184+
assert len(result) == 1
185+
assert result[0]["repo_id"] == "test/model"
186+
# Buffer should be cleared
187+
assert tracker.drain() == []

tests/test_integration_patching.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
from __future__ import annotations
22

33
import types
4+
from unittest.mock import patch
45

6+
import pytest
7+
8+
from wildedge.client import WildEdge
9+
from wildedge.device import DeviceInfo
10+
from wildedge.hubs.huggingface import HuggingFaceHubTracker
511
from wildedge.integrations.gguf import GgufExtractor
6-
from wildedge.integrations.hf import install_patch as install_hf_patch
712
from wildedge.integrations.onnx import OnnxExtractor
813
from wildedge.integrations.pytorch import PytorchExtractor
914

1015

1116
def test_hf_install_patch_is_idempotent(monkeypatch):
12-
import wildedge.integrations.hf as hf_mod
17+
import wildedge.hubs.huggingface as hf_mod
1318

1419
def orig_hf_hub_download(repo_id, filename, **kwargs):
1520
return f"/tmp/{repo_id}/{filename}"
@@ -28,15 +33,16 @@ def orig_hf_hub_download(repo_id, filename, **kwargs):
2833
hf_mod.sys.modules, "test_hf_consumer_mod", fake_consumer_module
2934
)
3035

31-
install_hf_patch()
36+
tracker = HuggingFaceHubTracker()
37+
tracker.install_patch(None)
3238
first = fake_consumer_module.hf_hub_download
33-
install_hf_patch()
39+
tracker.install_patch(None)
3440
second = fake_consumer_module.hf_hub_download
3541
assert first is second
3642

3743

3844
def test_hf_install_patch_retries_unpatched_part(monkeypatch):
39-
import wildedge.integrations.hf as hf_mod
45+
import wildedge.hubs.huggingface as hf_mod
4046

4147
monkeypatch.setattr(hf_mod, "_hf", object())
4248
monkeypatch.setattr(hf_mod, "_fd", object())
@@ -50,9 +56,10 @@ def fake_install_hf():
5056
return calls["hf"] > 1
5157

5258
monkeypatch.setattr(hf_mod, "_install_hf_hub_download_patch", fake_install_hf)
53-
install_hf_patch()
59+
tracker = HuggingFaceHubTracker()
60+
tracker.install_patch(None)
5461
assert hf_mod._hf_hub_download_patched is False
55-
install_hf_patch()
62+
tracker.install_patch(None)
5663
assert hf_mod._hf_hub_download_patched is True
5764
assert calls["hf"] == 2
5865

@@ -117,3 +124,51 @@ def client_ref():
117124
GgufExtractor.install_auto_load_patch(client_ref)
118125
second = fake_llama_cpp.Llama.__init__
119126
assert first is second
127+
128+
129+
# ---------------------------------------------------------------------------
130+
# instrument() hubs= parameter
131+
# ---------------------------------------------------------------------------
132+
133+
134+
@pytest.fixture
135+
def stub_client():
136+
with (
137+
patch("wildedge.client.detect_device", return_value=DeviceInfo("id", "linux")),
138+
patch("wildedge.client.Transmitter"),
139+
patch("wildedge.client.Consumer"),
140+
):
141+
yield WildEdge(dsn="https://secret@ingest.wildedge.dev/key")
142+
143+
144+
def test_instrument_hubs_activates_requested_trackers(stub_client):
145+
activated = []
146+
with (
147+
patch.object(stub_client, "_activate_hub", side_effect=activated.append),
148+
patch.dict(stub_client.PATCH_INSTALLERS, {"gguf": lambda ref: None}),
149+
):
150+
stub_client.instrument("gguf", hubs=["huggingface"])
151+
152+
assert activated == ["huggingface"]
153+
154+
155+
def test_instrument_hubs_unknown_hub_raises(stub_client):
156+
with pytest.raises(ValueError, match="Unknown hub"):
157+
stub_client.instrument("gguf", hubs=["nonexistent"])
158+
159+
160+
def test_instrument_hub_name_directly_raises(stub_client):
161+
with pytest.raises(ValueError, match="is a hub"):
162+
stub_client.instrument("huggingface")
163+
164+
165+
def test_instrument_none_without_hubs_raises(stub_client):
166+
with pytest.raises(ValueError, match="requires hubs="):
167+
stub_client.instrument(None)
168+
169+
170+
def test_instrument_none_activates_hub(stub_client):
171+
activated = []
172+
with patch.object(stub_client, "_activate_hub", side_effect=activated.append):
173+
stub_client.instrument(None, hubs=["huggingface"])
174+
assert activated == ["huggingface"]

wildedge/cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from wildedge import constants
1818
from wildedge.client import parse_dsn
1919
from wildedge.device import get_device_id_path
20+
from wildedge.hubs.registry import HUBS_BY_NAME, supported_hubs
2021
from wildedge.integrations.registry import INTEGRATIONS_BY_NAME, supported_integrations
2122
from wildedge.paths import default_dead_letter_dir, default_pending_queue_dir
2223
from wildedge.runtime.bootstrap import (
@@ -260,7 +261,7 @@ def run_command(parsed: argparse.Namespace) -> int:
260261

261262
def integration_list(value: str | None) -> list[str]:
262263
if not value or value == "all":
263-
return sorted(supported_integrations())
264+
return sorted(supported_integrations() | supported_hubs())
264265
return [item.strip() for item in value.split(",") if item.strip()]
265266

266267

@@ -449,7 +450,7 @@ def doctor_report(parsed: argparse.Namespace) -> dict:
449450
)
450451

451452
for integration in integration_list(parsed.integrations):
452-
spec = INTEGRATIONS_BY_NAME.get(integration)
453+
spec = INTEGRATIONS_BY_NAME.get(integration) or HUBS_BY_NAME.get(integration)
453454
if spec is None:
454455
ok = False
455456
integrations.append(

0 commit comments

Comments
 (0)