mirror of
https://github.com/Heretek-AI/Asset-Manager.git
synced 2026-11-07 10:45:57 -10:00
library: CLI init/push/pull with JSON contract
This commit is contained in:
1 parent
9c3e27f427
commit
9ab3bb8afd
5 files changed
+259
No files matched your search
@@ -198,6 +198,77 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _library_endpoint(args: argparse.Namespace) -> tuple[str, str]:
|
||||
server = args.server or os.environ.get("ASSETMGR_LIBRARY_URL", "")
|
||||
token = args.token or os.environ.get("ASSETMGR_LIBRARY_TOKEN", "")
|
||||
if not server or not token:
|
||||
raise SystemExit("library server/token required "
|
||||
"(--server/--token or ASSETMGR_LIBRARY_URL/TOKEN)")
|
||||
return server, token
|
||||
|
||||
|
||||
def _cmd_library_init(args: argparse.Namespace) -> int:
|
||||
from library import db as library_db
|
||||
|
||||
root = os.path.abspath(args.directory)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
db_path = os.path.join(root, "library.db")
|
||||
library_db.open_db(db_path).close()
|
||||
_emit(args, f"library initialised: {root}",
|
||||
{"root": root, "db": db_path})
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_library_push(args: argparse.Namespace) -> int:
|
||||
from library.client import LibraryError, push
|
||||
|
||||
try:
|
||||
server, token = _library_endpoint(args)
|
||||
except SystemExit as exc:
|
||||
return _die(args, str(exc), 2)
|
||||
if args.params:
|
||||
try:
|
||||
params = json.loads(args.params)
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return _die(args, "params must be a JSON object", 2)
|
||||
else:
|
||||
params = {}
|
||||
tags = [t for t in (args.tags or "").split(",") if t]
|
||||
try:
|
||||
result = push(server, token, args.file, name=args.name,
|
||||
kind=args.kind, importer=args.importer,
|
||||
params=params, tags=tags)
|
||||
except LibraryError as exc:
|
||||
return _die(args, str(exc), 1)
|
||||
_emit(args, f"uploaded as asset {result['id']}", result)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_library_pull(args: argparse.Namespace) -> int:
|
||||
from library.client import LibraryError, pull
|
||||
|
||||
try:
|
||||
server, token = _library_endpoint(args)
|
||||
except SystemExit as exc:
|
||||
return _die(args, str(exc), 2)
|
||||
try:
|
||||
result = pull(server, token, args.asset_id, args.out)
|
||||
except LibraryError as exc:
|
||||
return _die(args, str(exc), 1)
|
||||
_emit(args, f"downloaded to {result['path']}", result)
|
||||
return 0
|
||||
|
||||
|
||||
def _die(args: argparse.Namespace, message: str, code: int) -> int:
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps({"error": message}))
|
||||
else:
|
||||
print(f"error: {message}", file=sys.stderr)
|
||||
return code
|
||||
|
||||
|
||||
def _add_json(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--json", action="store_true",
|
||||
help="emit machine-readable JSON on stdout")
|
||||
@@ -255,6 +326,33 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="destination PNG (default: <name>_thumb.png beside source)")
|
||||
_add_json(p)
|
||||
p.set_defaults(func=_cmd_thumbnail)
|
||||
|
||||
p = sub.add_parser("library-init", help="initialise a library store directory")
|
||||
p.add_argument("directory")
|
||||
_add_json(p)
|
||||
p.set_defaults(func=_cmd_library_init)
|
||||
|
||||
p = sub.add_parser("library-push", help="upload a file to the library server")
|
||||
p.add_argument("file")
|
||||
p.add_argument("--server", default="")
|
||||
p.add_argument("--token", default="")
|
||||
p.add_argument("--name", default=None)
|
||||
p.add_argument("--kind", default="raw")
|
||||
p.add_argument("--importer", default=None)
|
||||
p.add_argument("--params", default="",
|
||||
help="import params as a JSON object")
|
||||
p.add_argument("--tags", default="",
|
||||
help="comma-separated tags")
|
||||
_add_json(p)
|
||||
p.set_defaults(func=_cmd_library_push)
|
||||
|
||||
p = sub.add_parser("library-pull", help="download an asset from the library server")
|
||||
p.add_argument("asset_id", type=int)
|
||||
p.add_argument("--out", required=True)
|
||||
p.add_argument("--server", default="")
|
||||
p.add_argument("--token", default="")
|
||||
_add_json(p)
|
||||
p.set_defaults(func=_cmd_library_pull)
|
||||
return parser
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""HTTP client for a self-hosted asset library (stdlib only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
__all__ = ["LibraryError", "push", "pull"]
|
||||
|
||||
|
||||
class LibraryError(RuntimeError):
|
||||
"""The server refused the request (auth, missing asset, network)."""
|
||||
|
||||
def __init__(self, message: str, status: int = 0) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def _request(server: str, token: str, method: str, path: str, *,
|
||||
params: dict | None = None,
|
||||
data: bytes | None = None) -> tuple[int, bytes]:
|
||||
url = server.rstrip("/") + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
detail = json.loads(exc.read().decode()).get("error", "")
|
||||
except (ValueError, AttributeError):
|
||||
detail = ""
|
||||
raise LibraryError(f"{method} {path}: HTTP {exc.code}"
|
||||
+ (f" ({detail})" if detail else ""),
|
||||
status=exc.code) from None
|
||||
except OSError as exc:
|
||||
raise LibraryError(f"{method} {path}: {exc}") from None
|
||||
|
||||
|
||||
def push(server: str, token: str, file_path: str, *,
|
||||
name: str | None = None, kind: str = "raw",
|
||||
importer: str | None = None, params: dict | None = None,
|
||||
tags: list[str] | tuple = ()) -> dict:
|
||||
"""Upload *file_path*; returns the server's ``{"id", "blob_sha256"}``."""
|
||||
if not os.path.isfile(file_path):
|
||||
raise LibraryError(f"no such file: {file_path}")
|
||||
with open(file_path, "rb") as handle:
|
||||
data = handle.read()
|
||||
query: dict[str, str] = {"name": name or os.path.basename(file_path),
|
||||
"kind": kind}
|
||||
if importer:
|
||||
query["importer"] = importer
|
||||
if params:
|
||||
query["params"] = json.dumps(params)
|
||||
if tags:
|
||||
query["tags"] = ",".join(tags)
|
||||
status, body = _request(server, token, "POST", "/api/v1/assets",
|
||||
params=query, data=data)
|
||||
if status != 201:
|
||||
raise LibraryError(f"upload rejected: HTTP {status}", status=status)
|
||||
return json.loads(body.decode())
|
||||
|
||||
|
||||
def pull(server: str, token: str, asset_id: int, out_path: str) -> dict:
|
||||
"""Download asset *asset_id* to *out_path*; returns ``{"path","size"}``."""
|
||||
status, body = _request(server, token, "GET",
|
||||
f"/api/v1/assets/{asset_id}/blob")
|
||||
if status != 200:
|
||||
raise LibraryError(f"download rejected: HTTP {status}", status=status)
|
||||
with open(out_path, "wb") as handle:
|
||||
handle.write(body)
|
||||
return {"path": os.path.abspath(out_path), "size": len(body)}
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -130,6 +131,8 @@ class LibraryServer:
|
||||
|
||||
def __init__(self, store_root, db_path, token: str,
|
||||
max_upload_bytes: int = DEFAULT_MAX_UPLOAD_BYTES) -> None:
|
||||
os.makedirs(store_root, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
|
||||
self.store_root = store_root
|
||||
# One shared connection: handler threads are serialized by this lock.
|
||||
self.conn = db.open_db(db_path, check_same_thread=False)
|
||||
|
||||
@@ -141,3 +141,35 @@ def test_thumbnail_json_no_engine(tmp_path, monkeypatch, capsys):
|
||||
code, payload = _run_json(capsys, "thumbnail", source)
|
||||
assert code == 3
|
||||
assert "ffmpeg" in payload["error"]
|
||||
|
||||
|
||||
def test_library_init_json(tmp_path, capsys):
|
||||
code, payload = _run_json(capsys, "library-init",
|
||||
str(tmp_path / "lib"))
|
||||
assert code == 0
|
||||
assert os.path.exists(payload["db"])
|
||||
|
||||
|
||||
def test_library_push_pull_json(tmp_path, capsys):
|
||||
from test_library_server import TOKEN, start_server
|
||||
_, base = start_server(tmp_path / "srv")
|
||||
source = _write_png(tmp_path / "hero.png")
|
||||
code, payload = _run_json(
|
||||
capsys, "library-push", source, "--server", base,
|
||||
"--token", TOKEN, "--kind", "texture", "--tags", "hero,demo")
|
||||
assert code == 0
|
||||
assert payload["id"] == 1
|
||||
dest = str(tmp_path / "fetched.png")
|
||||
code, payload = _run_json(
|
||||
capsys, "library-pull", "1", "--out", dest,
|
||||
"--server", base, "--token", TOKEN)
|
||||
assert code == 0
|
||||
with open(source, "rb") as a, open(dest, "rb") as b:
|
||||
assert a.read() == b.read()
|
||||
|
||||
|
||||
def test_library_push_needs_endpoint(tmp_path, capsys):
|
||||
source = _write_png(tmp_path / "hero.png")
|
||||
code, payload = _run_json(capsys, "library-push", source)
|
||||
assert code == 2
|
||||
assert "server" in payload["error"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Library client tests (push/pull against a live test server)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from library.client import LibraryError, pull, push
|
||||
from test_library_server import TOKEN, call, start_server
|
||||
|
||||
|
||||
def test_push_pull_roundtrip(tmp_path):
|
||||
_, base = start_server(tmp_path)
|
||||
source = tmp_path / "hero.png"
|
||||
source.write_bytes(b"png-bytes")
|
||||
result = push(base, TOKEN, str(source), kind="texture",
|
||||
importer="texture", tags=["hero"])
|
||||
assert result["id"] == 1
|
||||
dest = tmp_path / "fetched.png"
|
||||
pulled = pull(base, TOKEN, result["id"], str(dest))
|
||||
assert dest.read_bytes() == b"png-bytes"
|
||||
assert pulled["size"] == len(b"png-bytes")
|
||||
|
||||
|
||||
def test_push_params_json(tmp_path):
|
||||
_, base = start_server(tmp_path)
|
||||
source = tmp_path / "a.bin"
|
||||
source.write_bytes(b"x")
|
||||
result = push(base, TOKEN, str(source),
|
||||
params={"compress/mode": 1})
|
||||
_, body = call(base, "/api/v1/assets", token=TOKEN)
|
||||
assert json.loads(body)["assets"][0]["params"] == {"compress/mode": 1}
|
||||
|
||||
|
||||
def test_bad_token_raises(tmp_path):
|
||||
_, base = start_server(tmp_path)
|
||||
source = tmp_path / "a.bin"
|
||||
source.write_bytes(b"x")
|
||||
with pytest.raises(LibraryError):
|
||||
push(base, "wrong", str(source))
|
||||
with pytest.raises(LibraryError):
|
||||
pull(base, "wrong", 1, str(tmp_path / "o.bin"))
|
||||
|
||||
|
||||
def test_missing_file_raises(tmp_path):
|
||||
_, base = start_server(tmp_path)
|
||||
with pytest.raises(LibraryError):
|
||||
push(base, TOKEN, str(tmp_path / "nope.bin"))
|
||||
Reference in new issue
Block a user