Skip to content

Commit 3be1668

Browse files
committed
Add HTTP transport, auth, cache, client, and tests
Introduce core networking, auth, caching, and client logic plus comprehensive tests. - Add musher._auth: credential resolution chain (env, OS keyring, XDG config file). - Add musher._http: HTTPTransport wrapper around httpx with auth headers and error mapping to SDK exceptions. - Expand musher._cache: content-addressable blob storage and JSON manifest storage with atomic writes and clear() implementation. - Implement AsyncClient: HTTP-backed resolve, fetch_asset, and pull (concurrent fetching, checksum verification, blob caching). - Implement synchronous Client: background event loop/thread runner for sync APIs and proper shutdown. - Update musher._config: use XDG cache dir, auto-discover env vars and credential chain, increase default timeout. - Update pyproject.toml: optional keyring dependency declared. - Minor example tweak to show token discovery comment. - Add/modify tests: reset global config fixture; new tests for auth, cache, client (async + sync), config, and http transport. These changes wire up the SDK transport and caching behavior, implement integrity checking, and add extensive unit coverage.
1 parent b7d22b6 commit 3be1668

13 files changed

Lines changed: 852 additions & 105 deletions

File tree

examples/inspect_manifest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77

88
async def main():
9+
# Token auto-discovered from MUSHER_API_KEY env var, keyring, or config file.
10+
# Optionally set explicitly:
911
musher.configure(token="your-token-here")
1012

11-
# Resolve without pulling asset content (will raise NotImplementedError until implemented)
1213
async with musher.AsyncClient() as client:
1314
result = await client.resolve("myorg/my-bundle:1.0.0")
1415

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ dependencies = [
1010
"pydantic>=2.12",
1111
]
1212

13+
[project.optional-dependencies]
14+
keyring = ["keyring>=25.0"]
15+
1316
[dependency-groups]
1417
dev = [
1518
"ruff>=0.15.2",

src/musher/_auth.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Credential resolution chain (CLI-compatible)."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
import stat
8+
from pathlib import Path
9+
10+
_log = logging.getLogger(__name__)
11+
12+
13+
def _xdg_config_home() -> Path:
14+
"""Return XDG_CONFIG_HOME, defaulting to ~/.config."""
15+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
16+
17+
18+
def resolve_token() -> str | None:
19+
"""Resolve an API token using the same 3-tier priority as the CLI.
20+
21+
1. ``MUSHER_API_KEY`` environment variable
22+
2. OS keyring — service ``dev.musher.musher``, username ``api-key``
23+
3. File fallback — ``$XDG_CONFIG_HOME/musher/api-key`` (must be 0600)
24+
"""
25+
# 1. Environment variable
26+
env_token = os.environ.get("MUSHER_API_KEY")
27+
if env_token:
28+
return env_token
29+
30+
# 2. OS keyring (optional dependency)
31+
keyring_token = _try_keyring()
32+
if keyring_token:
33+
return keyring_token
34+
35+
# 3. File fallback
36+
return _try_file()
37+
38+
39+
def _try_keyring() -> str | None:
40+
"""Attempt to read token from OS keyring. Returns None if unavailable."""
41+
try:
42+
import keyring # type: ignore[import-untyped] # noqa: PLC0415
43+
44+
token = keyring.get_password("dev.musher.musher", "api-key")
45+
if token:
46+
return token
47+
except Exception: # noqa: BLE001
48+
_log.debug("keyring lookup failed", exc_info=True)
49+
return None
50+
51+
52+
def _try_file() -> str | None:
53+
"""Read token from $XDG_CONFIG_HOME/musher/api-key if permissions are safe."""
54+
key_file = _xdg_config_home() / "musher" / "api-key"
55+
if not key_file.is_file():
56+
return None
57+
58+
# Check permissions — must be owner-only (0600)
59+
mode = key_file.stat().st_mode
60+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
61+
return None
62+
63+
return key_file.read_text().strip() or None

src/musher/_cache.py

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1-
"""XDG-compliant disk cache for downloaded bundles."""
1+
"""Content-addressable disk cache for bundle blobs and manifests."""
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING
5+
import json
6+
import shutil
7+
import tempfile
8+
from pathlib import Path
9+
from typing import Any
610

711
from musher._config import get_config
812

9-
if TYPE_CHECKING:
10-
from pathlib import Path
11-
1213

1314
class BundleCache:
14-
"""Local disk cache for bundle assets.
15+
"""Local disk cache with content-addressable blob storage.
16+
17+
Layout::
1518
16-
Defaults to ``~/.cache/musher/bundles/`` (XDG_CACHE_HOME compliant).
19+
$XDG_CACHE_HOME/musher/
20+
blobs/sha256/<first-2-chars>/<full-hash>
21+
manifests/<namespace>/<slug>/<version>.json
1722
"""
1823

1924
def __init__(self, cache_dir: Path | None = None) -> None:
@@ -23,18 +28,63 @@ def __init__(self, cache_dir: Path | None = None) -> None:
2328
def cache_dir(self) -> Path:
2429
return self._cache_dir
2530

26-
def get(self, ref: str, version: str) -> bytes | None:
27-
"""Retrieve a cached bundle. Returns None on cache miss."""
28-
raise NotImplementedError
31+
# ── Blob operations ────────────────────────────────────────────
32+
33+
def get_blob(self, content_sha256: str) -> bytes | None:
34+
"""Retrieve a cached blob by SHA-256 hash. Returns ``None`` on miss."""
35+
path = self._blob_path(content_sha256)
36+
if path.is_file():
37+
return path.read_bytes()
38+
return None
39+
40+
def put_blob(self, content_sha256: str, data: bytes) -> None:
41+
"""Store a blob using atomic write (tmp + rename)."""
42+
path = self._blob_path(content_sha256)
43+
path.parent.mkdir(parents=True, exist_ok=True)
44+
fd, tmp_name = tempfile.mkstemp(dir=path.parent)
45+
tmp = Path(tmp_name)
46+
try:
47+
with open(fd, "wb") as f: # noqa: PTH123
48+
f.write(data)
49+
tmp.rename(path)
50+
except BaseException:
51+
tmp.unlink(missing_ok=True)
52+
raise
53+
54+
# ── Manifest operations ────────────────────────────────────────
2955

30-
def put(self, ref: str, version: str, data: bytes) -> None:
31-
"""Store a bundle in the cache."""
32-
raise NotImplementedError
56+
def get_manifest(self, namespace: str, slug: str, version: str) -> dict[str, Any] | None:
57+
"""Retrieve a cached manifest. Returns ``None`` on miss."""
58+
path = self._manifest_path(namespace, slug, version)
59+
if path.is_file():
60+
return json.loads(path.read_text())
61+
return None
3362

34-
def evict(self, ref: str, version: str) -> None:
35-
"""Remove a specific bundle from the cache."""
36-
raise NotImplementedError
63+
def put_manifest(self, namespace: str, slug: str, version: str, data: dict[str, Any]) -> None:
64+
"""Store a manifest as JSON using atomic write."""
65+
path = self._manifest_path(namespace, slug, version)
66+
path.parent.mkdir(parents=True, exist_ok=True)
67+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".json")
68+
tmp = Path(tmp_name)
69+
try:
70+
with open(fd, "w") as f: # noqa: PTH123
71+
json.dump(data, f)
72+
tmp.rename(path)
73+
except BaseException:
74+
tmp.unlink(missing_ok=True)
75+
raise
76+
77+
# ── Maintenance ────────────────────────────────────────────────
3778

3879
def clear(self) -> None:
39-
"""Remove all cached bundles."""
40-
raise NotImplementedError
80+
"""Remove all cached data."""
81+
if self._cache_dir.is_dir():
82+
shutil.rmtree(self._cache_dir)
83+
84+
# ── Internal ───────────────────────────────────────────────────
85+
86+
def _blob_path(self, content_sha256: str) -> Path:
87+
return self._cache_dir / "blobs" / "sha256" / content_sha256[:2] / content_sha256
88+
89+
def _manifest_path(self, namespace: str, slug: str, version: str) -> Path:
90+
return self._cache_dir / "manifests" / namespace / slug / f"{version}.json"

0 commit comments

Comments
 (0)