agentproc 0.4.0__tar.gz → 0.4.1__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.
- {agentproc-0.4.0 → agentproc-0.4.1}/PKG-INFO +1 -1
- {agentproc-0.4.0 → agentproc-0.4.1}/pyproject.toml +1 -1
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc/hub.py +155 -78
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc.egg-info/PKG-INFO +1 -1
- {agentproc-0.4.0 → agentproc-0.4.1}/tests/test_hub.py +72 -12
- {agentproc-0.4.0 → agentproc-0.4.1}/setup.cfg +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc/__init__.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc/cli.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc/runner.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc.egg-info/SOURCES.txt +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc.egg-info/dependency_links.txt +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc.egg-info/entry_points.txt +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/src/agentproc.egg-info/top_level.txt +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/tests/test_agentproc.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/tests/test_conformance.py +0 -0
- {agentproc-0.4.0 → agentproc-0.4.1}/tests/test_runner.py +0 -0
|
@@ -158,6 +158,22 @@ def _http_get_text(url: str, timeout: int = 30) -> str:
|
|
|
158
158
|
) from e
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def _http_get_text_optional(url: str, timeout: int = 30) -> Optional[str]:
|
|
162
|
+
"""Like _http_get_text, but returns None on 404 instead of raising.
|
|
163
|
+
|
|
164
|
+
Used for probing optional profile files (e.g. bridge.sh only exists for
|
|
165
|
+
echo-agent) and for detecting "profile does not exist" without burning
|
|
166
|
+
a rate-limited Trees API call. Delegates to _http_get_text so callers
|
|
167
|
+
that patch _http_get_text in tests automatically cover this path.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
return _http_get_text(url, timeout=timeout)
|
|
171
|
+
except HubError as e:
|
|
172
|
+
if e.status == 404:
|
|
173
|
+
return None
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
|
|
161
177
|
def _http_error_to_hub(status: int, body: str, url: str, *, is_json: bool) -> HubError:
|
|
162
178
|
"""Translate a GitHub HTTP error into a HubError with a remediation hint."""
|
|
163
179
|
authed = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
|
|
@@ -196,91 +212,123 @@ def _http_error_to_hub(status: int, body: str, url: str, *, is_json: bool) -> Hu
|
|
|
196
212
|
)
|
|
197
213
|
|
|
198
214
|
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Repo tree — cached in-memory and on disk (24h TTL)
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
#
|
|
219
|
+
# The GitHub Trees API is the one call that rate-limits anonymous users to
|
|
220
|
+
# ~60/hr. Every CLI invocation is a fresh process, so an in-memory cache
|
|
221
|
+
# alone doesn't help across calls. The disk cache (~/.agentproc/cache/hub/
|
|
222
|
+
# tree.json) is what gives rate-limit relief: a normal user makes at most
|
|
223
|
+
# ~1 Trees API call per day, regardless of how many `hub list` / `hub run`
|
|
224
|
+
# invocations they run.
|
|
225
|
+
|
|
226
|
+
_tree_cache: Optional[List[Dict[str, str]]] = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _tree_cache_path() -> Path:
|
|
230
|
+
return _cache_root() / "tree.json"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _clear_tree_cache() -> None:
|
|
234
|
+
"""Drop the in-memory tree cache and delete the disk cache file."""
|
|
235
|
+
global _tree_cache
|
|
236
|
+
_tree_cache = None
|
|
237
|
+
p = _tree_cache_path()
|
|
238
|
+
if p.exists():
|
|
239
|
+
try:
|
|
240
|
+
p.unlink()
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
201
243
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
244
|
+
|
|
245
|
+
def _get_tree() -> List[Dict[str, str]]:
|
|
246
|
+
"""Return the full repo tree as a list of {path, type('blob'|'tree')}.
|
|
247
|
+
|
|
248
|
+
Serves from in-memory cache → disk cache (24h TTL) → GitHub API, writing
|
|
249
|
+
each layer as it misses. The API is the only rate-limited step.
|
|
205
250
|
"""
|
|
206
|
-
|
|
207
|
-
|
|
251
|
+
global _tree_cache
|
|
252
|
+
if _tree_cache is not None:
|
|
253
|
+
return _tree_cache
|
|
254
|
+
|
|
255
|
+
tp = _tree_cache_path()
|
|
256
|
+
if tp.exists():
|
|
257
|
+
try:
|
|
258
|
+
meta = json.loads(tp.read_text(encoding="utf-8"))
|
|
259
|
+
age = max(0.0, time.time() - float(meta.get("fetched_at", 0)))
|
|
260
|
+
if age < HUB_CACHE_TTL_SECS and isinstance(meta.get("tree"), list):
|
|
261
|
+
_tree_cache = [
|
|
262
|
+
{"path": str(e.get("path", "")), "type": str(e.get("type", ""))}
|
|
263
|
+
for e in meta["tree"] if isinstance(e, dict)
|
|
264
|
+
]
|
|
265
|
+
return _tree_cache
|
|
266
|
+
except (ValueError, OSError):
|
|
267
|
+
pass # corrupt cache file — refetch
|
|
268
|
+
|
|
208
269
|
url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
|
|
209
270
|
data = _http_get_json(url)
|
|
210
|
-
if not isinstance(data, dict) or "tree"
|
|
271
|
+
if not isinstance(data, dict) or not isinstance(data.get("tree"), list):
|
|
211
272
|
raise RuntimeError(f"unexpected tree API response: {type(data).__name__}")
|
|
273
|
+
_tree_cache = [
|
|
274
|
+
{"path": str(e.get("path", "")), "type": str(e.get("type", ""))}
|
|
275
|
+
for e in data["tree"] if isinstance(e, dict)
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
_cache_root().mkdir(parents=True, exist_ok=True)
|
|
280
|
+
tp.write_text(
|
|
281
|
+
json.dumps(
|
|
282
|
+
{"fetched_at": time.time(), "ref": HUB_REF, "tree": _tree_cache},
|
|
283
|
+
),
|
|
284
|
+
encoding="utf-8",
|
|
285
|
+
)
|
|
286
|
+
except OSError:
|
|
287
|
+
pass # disk cache is best-effort
|
|
288
|
+
|
|
289
|
+
return _tree_cache
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _list_remote_files(subpath: str) -> List[Dict[str, str]]:
|
|
293
|
+
"""List top-level entries in a hub subpath (e.g. 'hub' → all profile dirs).
|
|
294
|
+
|
|
295
|
+
Uses _get_tree(), which is cached in-memory and on disk (24h TTL) so
|
|
296
|
+
repeated calls don't re-hit the rate-limited Trees API.
|
|
297
|
+
"""
|
|
298
|
+
if not subpath.endswith("/"):
|
|
299
|
+
subpath = subpath + "/"
|
|
300
|
+
tree = _get_tree()
|
|
212
301
|
out: List[Dict[str, str]] = []
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
p = str(entry.get("path", ""))
|
|
302
|
+
seen = set()
|
|
303
|
+
for entry in tree:
|
|
304
|
+
p = entry["path"]
|
|
217
305
|
if not p.startswith(subpath):
|
|
218
306
|
continue
|
|
219
|
-
# type is "blob" (file) or "tree" (directory)
|
|
220
|
-
etype = "file" if entry.get("type") == "blob" else (
|
|
221
|
-
"dir" if entry.get("type") == "tree" else ""
|
|
222
|
-
)
|
|
223
|
-
# Name is the part after subpath.
|
|
224
307
|
name = p[len(subpath):].split("/")[0]
|
|
308
|
+
if not name or name in seen:
|
|
309
|
+
continue
|
|
310
|
+
seen.add(name)
|
|
311
|
+
is_dir = any(t["path"] == subpath + name and t["type"] == "tree" for t in tree)
|
|
225
312
|
out.append({
|
|
226
313
|
"name": name,
|
|
227
314
|
"path": p,
|
|
228
|
-
"type":
|
|
229
|
-
# We don't use download_url from the API; we use raw URLs.
|
|
315
|
+
"type": "dir" if is_dir else "file",
|
|
230
316
|
"download_url": "",
|
|
231
317
|
})
|
|
232
|
-
# Deduplicate: for directory listing we only want the top-level entries.
|
|
233
|
-
seen = set()
|
|
234
|
-
unique: List[Dict[str, str]] = []
|
|
235
|
-
for e in out:
|
|
236
|
-
if e["name"] in seen:
|
|
237
|
-
continue
|
|
238
|
-
seen.add(e["name"])
|
|
239
|
-
unique.append(e)
|
|
240
|
-
return unique
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _list_remote_profile_files(name: str) -> List[Dict[str, str]]:
|
|
244
|
-
"""List the actual files inside hub/<name>/ (not just top-level entries).
|
|
245
|
-
|
|
246
|
-
Returns only file entries (type=file), with their full remote paths.
|
|
247
|
-
"""
|
|
248
|
-
prefix = f"hub/{name}/"
|
|
249
|
-
url = GITHUB_TREES.format(repo=HUB_REPO, ref=HUB_REF)
|
|
250
|
-
data = _http_get_json(url)
|
|
251
|
-
if not isinstance(data, dict) or "tree" not in data:
|
|
252
|
-
raise RuntimeError(f"unexpected tree API response: {type(data).__name__}")
|
|
253
|
-
out: List[Dict[str, str]] = []
|
|
254
|
-
for entry in data["tree"]:
|
|
255
|
-
if not isinstance(entry, dict):
|
|
256
|
-
continue
|
|
257
|
-
p = str(entry.get("path", ""))
|
|
258
|
-
if not p.startswith(prefix):
|
|
259
|
-
continue
|
|
260
|
-
if entry.get("type") != "blob":
|
|
261
|
-
continue
|
|
262
|
-
# Filename is the last segment.
|
|
263
|
-
fname = p[len(prefix):].split("/")[-1]
|
|
264
|
-
out.append({"name": fname, "path": p})
|
|
265
318
|
return out
|
|
266
319
|
|
|
267
320
|
|
|
268
321
|
def _list_profile_names() -> List[str]:
|
|
269
322
|
"""List top-level profile names (directories directly under hub/).
|
|
270
323
|
|
|
271
|
-
Cheap: uses the
|
|
272
|
-
|
|
324
|
+
Cheap: uses the disk-cached tree, so this is at most ~1 Trees API call
|
|
325
|
+
per day regardless of how many times it's called.
|
|
273
326
|
"""
|
|
274
|
-
|
|
275
|
-
data = _http_get_json(url)
|
|
276
|
-
if not isinstance(data, dict) or "tree" not in data:
|
|
277
|
-
return []
|
|
327
|
+
tree = _get_tree()
|
|
278
328
|
names: List[str] = []
|
|
279
329
|
seen = set()
|
|
280
|
-
for entry in
|
|
281
|
-
|
|
282
|
-
continue
|
|
283
|
-
p = str(entry.get("path", ""))
|
|
330
|
+
for entry in tree:
|
|
331
|
+
p = entry["path"]
|
|
284
332
|
if not p.startswith("hub/"):
|
|
285
333
|
continue
|
|
286
334
|
seg = p[len("hub/"):].split("/")[0]
|
|
@@ -344,12 +392,19 @@ def _edit_distance(a: str, b: str) -> int:
|
|
|
344
392
|
return prev[n]
|
|
345
393
|
|
|
346
394
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
395
|
+
# Every hub profile is this fixed set of files (see hub/README.md):
|
|
396
|
+
# profile.yaml (required) + bridge.py + bridge.js + README.md,
|
|
397
|
+
# with echo-agent additionally shipping bridge.sh. We fetch them directly
|
|
398
|
+
# via raw.githubusercontent.com (CDN, not rate-limited) so `hub run` never
|
|
399
|
+
# calls the GitHub Trees API in the happy path. If a future profile adds a
|
|
400
|
+
# new file type, extend this list.
|
|
401
|
+
_PROFILE_FILE_CANDIDATES = (
|
|
402
|
+
"profile.yaml",
|
|
403
|
+
"bridge.py",
|
|
404
|
+
"bridge.js",
|
|
405
|
+
"bridge.sh",
|
|
406
|
+
"README.md",
|
|
407
|
+
)
|
|
353
408
|
|
|
354
409
|
|
|
355
410
|
# ---------------------------------------------------------------------------
|
|
@@ -363,9 +418,17 @@ def fetch_profile(
|
|
|
363
418
|
) -> Path:
|
|
364
419
|
"""Fetch a profile directory to local cache. Returns the cache path.
|
|
365
420
|
|
|
421
|
+
Fetches files directly via raw.githubusercontent.com (CDN, not
|
|
422
|
+
rate-limited) — no GitHub Trees API call in the happy path. Only an
|
|
423
|
+
unknown profile name (profile.yaml 404) falls back to the disk-cached
|
|
424
|
+
tree to produce a "did you mean" suggestion.
|
|
425
|
+
|
|
366
426
|
If a fresh cache exists (younger than HUB_CACHE_TTL_SECS) and refresh
|
|
367
427
|
is False, returns immediately without network access.
|
|
368
428
|
"""
|
|
429
|
+
if refresh:
|
|
430
|
+
_clear_tree_cache()
|
|
431
|
+
|
|
369
432
|
age = _cache_age_secs(name)
|
|
370
433
|
cached = cache_dir(name)
|
|
371
434
|
profile_yaml = cached / "profile.yaml"
|
|
@@ -381,11 +444,15 @@ def fetch_profile(
|
|
|
381
444
|
else:
|
|
382
445
|
on_log(f"fetching profile '{name}' from {HUB_REPO}:{HUB_REF}...")
|
|
383
446
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
447
|
+
# Probe profile.yaml via raw URL. raw.githubusercontent.com is CDN-backed
|
|
448
|
+
# and not subject to the 60/hr anonymous API limit, so this does not burn
|
|
449
|
+
# rate-limit budget.
|
|
450
|
+
probe_url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/profile.yaml")
|
|
451
|
+
probe = _http_get_text_optional(probe_url)
|
|
452
|
+
if probe is None:
|
|
453
|
+
# profile.yaml 404 → the name is wrong. Fall back to the (disk-cached)
|
|
454
|
+
# tree to produce a "did you mean" suggestion. This is the only path
|
|
455
|
+
# that may call the Trees API for `hub run`, and it's cached for 24h.
|
|
389
456
|
known: List[str] = []
|
|
390
457
|
try:
|
|
391
458
|
known = _list_profile_names()
|
|
@@ -405,16 +472,26 @@ def fetch_profile(
|
|
|
405
472
|
hint="\n".join(lines),
|
|
406
473
|
)
|
|
407
474
|
|
|
408
|
-
# Clear cache, then
|
|
475
|
+
# Clear cache, then download the candidate file set via raw URLs.
|
|
409
476
|
if cached.exists():
|
|
410
477
|
shutil.rmtree(cached)
|
|
411
478
|
cached.mkdir(parents=True, exist_ok=True)
|
|
412
479
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
480
|
+
# profile.yaml already fetched via the probe.
|
|
481
|
+
(cached / "profile.yaml").write_text(probe, encoding="utf-8")
|
|
482
|
+
if on_log:
|
|
483
|
+
on_log(" - profile.yaml")
|
|
484
|
+
|
|
485
|
+
for fname in _PROFILE_FILE_CANDIDATES:
|
|
486
|
+
if fname == "profile.yaml":
|
|
487
|
+
continue
|
|
488
|
+
url = GITHUB_RAW.format(repo=HUB_REPO, ref=HUB_REF, path=f"hub/{name}/{fname}")
|
|
489
|
+
text = _http_get_text_optional(url)
|
|
490
|
+
if text is None:
|
|
491
|
+
continue # optional file not present for this profile
|
|
492
|
+
(cached / fname).write_text(text, encoding="utf-8")
|
|
416
493
|
if on_log:
|
|
417
|
-
on_log(f" - {
|
|
494
|
+
on_log(f" - {fname}")
|
|
418
495
|
|
|
419
496
|
_write_cache_meta(name)
|
|
420
497
|
return cached
|
|
@@ -19,6 +19,7 @@ import pytest
|
|
|
19
19
|
from agentproc import hub as hub_mod
|
|
20
20
|
from agentproc.hub import (
|
|
21
21
|
HUB_CACHE_TTL_SECS,
|
|
22
|
+
HubError,
|
|
22
23
|
cache_dir,
|
|
23
24
|
_cache_root,
|
|
24
25
|
_cache_age_secs,
|
|
@@ -92,23 +93,33 @@ def _make_fake_http_get_json(tree=None):
|
|
|
92
93
|
|
|
93
94
|
|
|
94
95
|
def _make_fake_http_get_text(contents=None):
|
|
95
|
-
"""Return a callable that emulates _http_get_text for raw file fetches.
|
|
96
|
+
"""Return a callable that emulates _http_get_text for raw file fetches.
|
|
97
|
+
|
|
98
|
+
Unmatched URLs raise a HubError(404) so that _http_get_text_optional
|
|
99
|
+
(which wraps _http_get_text and swallows 404) returns None — modelling
|
|
100
|
+
an optional profile file that doesn't exist (e.g. bridge.sh on a
|
|
101
|
+
non-echo profile) or a wrong profile name.
|
|
102
|
+
"""
|
|
96
103
|
contents = contents or FAKE_FILE_CONTENTS
|
|
97
104
|
def fake(url, timeout=30):
|
|
98
105
|
# Extract path from raw URL: .../agentproc/main/hub/echo-agent/profile.yaml
|
|
99
106
|
for path, content in contents.items():
|
|
100
107
|
if url.endswith(path):
|
|
101
108
|
return content
|
|
102
|
-
raise
|
|
109
|
+
raise HubError(f"fetch failed (HTTP 404) for {url}", status=404)
|
|
103
110
|
return fake
|
|
104
111
|
|
|
105
112
|
|
|
106
113
|
@pytest.fixture
|
|
107
114
|
def isolated_cache(monkeypatch, tmp_path):
|
|
108
|
-
"""Redirect cache to a tmp dir."""
|
|
115
|
+
"""Redirect cache to a tmp dir and reset the module-level tree cache."""
|
|
109
116
|
cache_root = tmp_path / "cache" / "hub"
|
|
110
117
|
monkeypatch.setattr(hub_mod, "_cache_root", lambda: cache_root)
|
|
111
118
|
monkeypatch.setattr(hub_mod, "cache_dir", lambda name: cache_root / name)
|
|
119
|
+
# _tree_cache is module-global and would otherwise leak across tests
|
|
120
|
+
# (each test uses a different fake tree). Reset it so every test starts
|
|
121
|
+
# cold and _get_tree re-reads from the (fresh) fake _http_get_json.
|
|
122
|
+
hub_mod._clear_tree_cache()
|
|
112
123
|
return tmp_path
|
|
113
124
|
|
|
114
125
|
|
|
@@ -159,9 +170,38 @@ class TestFetchProfile:
|
|
|
159
170
|
assert "README.md" in names
|
|
160
171
|
assert ".cache-meta.json" in names
|
|
161
172
|
|
|
173
|
+
def test_fetch_happy_path_does_not_call_trees_api(self, isolated_cache):
|
|
174
|
+
# `hub run` fetches profile files via raw.githubusercontent.com (CDN,
|
|
175
|
+
# not rate-limited). A known profile must not trigger any
|
|
176
|
+
# api.github.com call — that's the whole point of the rate-limit fix.
|
|
177
|
+
json_calls = {"n": 0}
|
|
178
|
+
|
|
179
|
+
def assert_no_json(url, timeout=30):
|
|
180
|
+
json_calls["n"] += 1
|
|
181
|
+
raise AssertionError(f"unexpected Trees API call: {url}")
|
|
182
|
+
|
|
183
|
+
with patch("agentproc.hub._http_get_json", side_effect=assert_no_json), \
|
|
184
|
+
patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
|
|
185
|
+
hub_mod.fetch_profile("echo-agent")
|
|
186
|
+
assert json_calls["n"] == 0
|
|
187
|
+
|
|
188
|
+
def test_fetch_skips_optional_files_that_404(self, isolated_cache):
|
|
189
|
+
# claude-code has no bridge.sh in the fixtures → the 404 is swallowed
|
|
190
|
+
# and bridge.sh is not stored, while the four standard files are.
|
|
191
|
+
with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
|
|
192
|
+
patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
|
|
193
|
+
p = hub_mod.fetch_profile("claude-code")
|
|
194
|
+
names = [x.name for x in p.iterdir()]
|
|
195
|
+
assert "profile.yaml" in names
|
|
196
|
+
assert "bridge.py" in names
|
|
197
|
+
assert "bridge.js" in names
|
|
198
|
+
assert "README.md" in names
|
|
199
|
+
assert "bridge.sh" not in names
|
|
200
|
+
|
|
162
201
|
def test_fetch_unknown_profile_raises(self, isolated_cache):
|
|
163
202
|
empty_tree = [{"path": "hub", "type": "tree"}]
|
|
164
|
-
with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json(empty_tree))
|
|
203
|
+
with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json(empty_tree)), \
|
|
204
|
+
patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
|
|
165
205
|
with pytest.raises(RuntimeError, match="not found in hub"):
|
|
166
206
|
hub_mod.fetch_profile("nope")
|
|
167
207
|
|
|
@@ -186,18 +226,20 @@ class TestFetchProfile:
|
|
|
186
226
|
assert call_count["text"] == first_text # no new file downloads
|
|
187
227
|
|
|
188
228
|
def test_refresh_forces_refetch(self, isolated_cache):
|
|
189
|
-
call_count = {"
|
|
229
|
+
call_count = {"text": 0}
|
|
190
230
|
|
|
191
|
-
def
|
|
192
|
-
call_count["
|
|
193
|
-
return
|
|
231
|
+
def counting_text(url, timeout=30):
|
|
232
|
+
call_count["text"] += 1
|
|
233
|
+
return _make_fake_http_get_text()(url, timeout)
|
|
194
234
|
|
|
195
|
-
with patch("agentproc.hub._http_get_json", side_effect=
|
|
196
|
-
patch("agentproc.hub._http_get_text", side_effect=
|
|
235
|
+
with patch("agentproc.hub._http_get_json", side_effect=_make_fake_http_get_json()), \
|
|
236
|
+
patch("agentproc.hub._http_get_text", side_effect=counting_text):
|
|
197
237
|
hub_mod.fetch_profile("echo-agent")
|
|
198
|
-
|
|
238
|
+
first_text = call_count["text"]
|
|
199
239
|
hub_mod.fetch_profile("echo-agent", refresh=True)
|
|
200
|
-
|
|
240
|
+
# hub run fetches files via raw URLs (CDN), not the rate-limited
|
|
241
|
+
# Trees API — so a refresh re-fetches the file set, not the tree.
|
|
242
|
+
assert call_count["text"] > first_text
|
|
201
243
|
|
|
202
244
|
def test_fetch_overwrites_old_files(self, isolated_cache):
|
|
203
245
|
# First fetch.
|
|
@@ -250,6 +292,24 @@ class TestListProfiles:
|
|
|
250
292
|
assert not any(n.startswith("_") for n in names), names
|
|
251
293
|
assert "_shared" not in names
|
|
252
294
|
|
|
295
|
+
def test_tree_disk_cached_across_calls(self, isolated_cache):
|
|
296
|
+
# First list_profiles hits the Trees API (json 0→1) and writes
|
|
297
|
+
# ~/.agentproc/cache/hub/tree.json. A second call reuses the cached
|
|
298
|
+
# tree and must not make another API call.
|
|
299
|
+
json_calls = {"n": 0}
|
|
300
|
+
|
|
301
|
+
def counting_json(url, timeout=30):
|
|
302
|
+
json_calls["n"] += 1
|
|
303
|
+
return _make_fake_http_get_json()(url, timeout)
|
|
304
|
+
|
|
305
|
+
with patch("agentproc.hub._http_get_json", side_effect=counting_json), \
|
|
306
|
+
patch("agentproc.hub._http_get_text", side_effect=_make_fake_http_get_text()):
|
|
307
|
+
hub_mod.list_profiles()
|
|
308
|
+
assert json_calls["n"] == 1
|
|
309
|
+
assert (hub_mod._cache_root() / "tree.json").exists()
|
|
310
|
+
hub_mod.list_profiles()
|
|
311
|
+
assert json_calls["n"] == 1, "second call hit the Trees API again"
|
|
312
|
+
|
|
253
313
|
|
|
254
314
|
# ---------------------------------------------------------------------------
|
|
255
315
|
# show_readme
|
|
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
|