Add test suite for the Bonsai Viewer Autodesk connector

Introduce pytest coverage for the previously untested connector — rpc,
cache, settings, autodesk (auth + APS client) and connector handlers —
94 tests, runnable via the new `test` optional-dependency extra.

To make HTTP, time and the OAuth redirect testable without a network or
real sockets, add dependency-injection seams to autodesk.py:
AuthSessionService and ApsClient accept an optional httpx transport;
AuthSessionService accepts an injectable clock and callback_waiter; and
_wait_for_callback is extracted to the module-level wait_for_oauth_callback.
All seams default to the previous behaviour.

Remove the APS_CLIENT_ID environment-variable override: the client id now
comes solely from settings.json, collapsing settings.load_client_id and
simplifying the settings dialog.

CI: the build-bonsaiviewer-autodesk workflow gains a `test` job
(Python 3.11 + 3.13) that gates the build matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-21 10:23:39 +10:00
parent de7520418b
commit 137a890256
12 changed files with 1452 additions and 80 deletions
@@ -12,8 +12,41 @@ on:
- '.github/workflows/build-bonsaiviewer-autodesk.yml'
jobs:
test:
name: test-py${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Exercise the floor and a current version of the supported range
# (pyproject requires-python = ">=3.11").
python-version: ['3.11', '3.13']
defaults:
run:
working-directory: src/bonsaiviewer-autodesk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
# The connector imports tkinter/customtkinter (via the test suite's
# connector coverage), so fail loudly here if Tk is missing.
- name: Verify tkinter is available
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
- name: Install package and test deps
run: python -m pip install ".[test]"
- name: Run pytest
run: python -m pytest -q
build:
name: ${{ matrix.os_label }}-${{ matrix.arch }}
needs: test
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
@@ -22,6 +22,13 @@ from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
Progress = Callable[[str, str, "int | None"], None]
# (host, port, path, expected_state) -> authorization code
CallbackWaiter = Callable[[str, int, str, str], str]
def _utcnow() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def _no_keyring_error() -> RpcError:
return RpcError(
@@ -93,6 +100,48 @@ def _noop_progress(_phase: str, _message: str, _percent: int | None = None) -> N
return
def wait_for_oauth_callback(host: str, port: int, path: str, expected_state: str) -> str:
"""Block on a single OAuth redirect to ``http://host:port/path`` and return
the authorization code. Raises ``RpcError`` on an OAuth error, a ``state``
mismatch, or a missing code. This is the default ``callback_waiter`` for
:class:`AuthSessionService`; tests inject a stub instead."""
result: dict[str, str] = {}
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path != path:
self.send_response(404)
self.end_headers()
return
query = urllib.parse.parse_qs(parsed.query)
result["state"] = query.get("state", [""])[0]
result["code"] = query.get("code", [""])[0]
result["error"] = query.get("error", [""])[0]
body = b"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
server = HTTPServer((host, port), Handler)
server.handle_request()
server.server_close()
if result.get("error"):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
if result.get("state") != expected_state:
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
code = result.get("code", "")
if not code:
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
return code
class AuthSessionService:
authorize_endpoint = "https://developer.api.autodesk.com/authentication/v2/authorize"
token_endpoint = "https://developer.api.autodesk.com/authentication/v2/token"
@@ -104,12 +153,17 @@ class AuthSessionService:
callback_url: str,
scope: str,
token_store: KeyringTokenStore,
transport: httpx.BaseTransport | None = None,
now: Callable[[], dt.datetime] | None = None,
callback_waiter: CallbackWaiter | None = None,
) -> None:
self.client_id = client_id
self.callback_url = callback_url
self.scope = scope
self.token_store = token_store
self.http = httpx.Client(timeout=60)
self.http = httpx.Client(timeout=60, transport=transport)
self._now = now or _utcnow
self._callback_waiter = callback_waiter or wait_for_oauth_callback
def get_token(self) -> StoredToken | None:
raw = self.token_store.load()
@@ -117,7 +171,7 @@ class AuthSessionService:
def ensure_access_token(self, progress: Progress = _noop_progress) -> str:
token = self.get_token()
now = dt.datetime.now(dt.timezone.utc)
now = self._now()
if token and token.access_token_expires_at > now + dt.timedelta(minutes=1):
return token.access_token
if token and token.refresh_token_expires_at > now + dt.timedelta(minutes=1):
@@ -148,7 +202,7 @@ class AuthSessionService:
progress("auth", "Opening browser for Autodesk sign-in", None)
webbrowser.open(authorize_url)
code = self._wait_for_callback(
code = self._callback_waiter(
callback.hostname or "127.0.0.1",
callback.port or 80,
callback.path or "/",
@@ -192,7 +246,7 @@ class AuthSessionService:
return refreshed
def _token_from_payload(self, payload: dict[str, Any]) -> StoredToken:
now = dt.datetime.now(dt.timezone.utc)
now = self._now()
refresh_ttl = int(payload.get("refresh_token_expires_in", 15 * 24 * 60 * 60))
return StoredToken(
client_id=self.client_id,
@@ -203,48 +257,10 @@ class AuthSessionService:
scope=self.scope,
)
def _wait_for_callback(self, host: str, port: int, path: str, expected_state: str) -> str:
result: dict[str, str] = {}
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path != path:
self.send_response(404)
self.end_headers()
return
query = urllib.parse.parse_qs(parsed.query)
result["state"] = query.get("state", [""])[0]
result["code"] = query.get("code", [""])[0]
result["error"] = query.get("error", [""])[0]
body = b"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
server = HTTPServer((host, port), Handler)
server.handle_request()
server.server_close()
if result.get("error"):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
if result.get("state") != expected_state:
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
code = result.get("code", "")
if not code:
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
return code
class ApsClient:
def __init__(self, auth: AuthSessionService) -> None:
def __init__(self, auth: AuthSessionService, *, transport: httpx.BaseTransport | None = None) -> None:
self.auth = auth
self.http = httpx.Client(timeout=120)
self.http = httpx.Client(timeout=120, transport=transport)
# Browsing -----------------------------------------------------------------
@@ -43,19 +43,9 @@ def _write(data: dict[str, Any]) -> None:
_settings_path().write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def stored_client_id() -> str:
"""Whatever is persisted in settings.json — ignores the env var."""
return str(_read().get("client_id", "")).strip()
def env_client_id() -> str:
"""Whatever APS_CLIENT_ID currently has — ignores settings.json."""
return os.environ.get("APS_CLIENT_ID", "").strip()
def load_client_id() -> str:
"""The effective value: env var wins, so dev overrides keep working."""
return env_client_id() or stored_client_id()
"""The client id persisted in settings.json, or "" if none is set."""
return str(_read().get("client_id", "")).strip()
def save_client_id(client_id: str) -> None:
@@ -613,9 +613,7 @@ class SettingsDialog(_BaseDialog):
super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False)
self.connector = connector
env_override = settings.env_client_id()
stored = settings.stored_client_id()
effective = env_override or stored
client_id = settings.load_client_id()
callback_port = settings.stored_callback_port()
body = ctk.CTkFrame(self, fg_color="transparent")
@@ -638,17 +636,7 @@ class SettingsDialog(_BaseDialog):
ctk.CTkLabel(body, text="APS client id", anchor="w").pack(fill="x")
self.client_id_entry = ctk.CTkEntry(body, placeholder_text="Paste your APS client id")
self.client_id_entry.pack(fill="x", pady=(6, 8))
self.client_id_entry.insert(0, stored)
if env_override:
ctk.CTkLabel(
body,
text=f"APS_CLIENT_ID environment variable is set ({env_override}) and overrides the saved value.",
anchor="w",
wraplength=460,
justify="left",
text_color=("#b45309", "#f59e0b"),
).pack(fill="x", pady=(0, 8))
self.client_id_entry.insert(0, client_id)
ctk.CTkLabel(body, text="OAuth callback port", anchor="w").pack(fill="x")
self.callback_port_entry = ctk.CTkEntry(body, placeholder_text=str(settings.DEFAULT_CALLBACK_PORT))
@@ -657,7 +645,7 @@ class SettingsDialog(_BaseDialog):
self.status_label = ctk.CTkLabel(
body,
text=f"Signed in as {effective}" if effective else "No client id configured.",
text=f"Signed in as {client_id}" if client_id else "No client id configured.",
anchor="w",
wraplength=460,
justify="left",
@@ -676,7 +664,7 @@ class SettingsDialog(_BaseDialog):
border_width=1,
)
self.signout_button.grid(row=0, column=0, sticky="w")
if not effective:
if not client_id:
self.signout_button.configure(state="disabled")
ctk.CTkButton(
@@ -705,7 +693,7 @@ class SettingsDialog(_BaseDialog):
self._on_close()
def _sign_out(self) -> None:
client_id = settings.env_client_id() or settings.stored_client_id()
client_id = settings.load_client_id()
if not client_id:
return
if not confirm(
+5
View File
@@ -16,12 +16,17 @@ dependencies = [
[project.optional-dependencies]
build = ["pyinstaller>=6.0"]
test = ["pytest>=8"]
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
include = ["bonsaiviewer_autodesk*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[project.scripts]
bonsaiviewer-autodesk = "bonsaiviewer_autodesk.__main__:main"
@@ -0,0 +1,72 @@
"""Shared fixtures for the bonsaiviewer-autodesk test suite."""
from __future__ import annotations
import keyring
import keyring.backend
import keyring.errors
import pytest
from bonsaiviewer_autodesk import cache as cache_module
from bonsaiviewer_autodesk import settings as settings_module
@pytest.fixture
def cache_dir(tmp_path, monkeypatch):
"""Redirect ``bonsaiviewer_autodesk.cache`` at an isolated tmp directory.
Patches ``cache_root`` itself rather than ``XDG_CACHE_HOME`` so the
redirect holds on every platform — on macOS/Windows ``cache_root`` ignores
the XDG variables. Returns the directory ``cache_root()`` now resolves to.
"""
root = tmp_path / "cache" / "bonsaiviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(cache_module, "cache_root", lambda: root)
return root
@pytest.fixture
def config_dir(tmp_path, monkeypatch):
"""Redirect ``bonsaiviewer_autodesk.settings`` at an isolated tmp directory.
Patches ``config_root`` directly (platform-independent). Returns the
directory ``config_root()`` now resolves to.
"""
root = tmp_path / "config" / "bonsaiviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(settings_module, "config_root", lambda: root)
return root
class InMemoryKeyring(keyring.backend.KeyringBackend):
"""A keyring backend that keeps secrets in a dict — never touches the OS."""
priority = 1 # type: ignore[assignment]
def __init__(self) -> None:
super().__init__()
self._store: dict[tuple[str, str], str] = {}
def get_password(self, service: str, username: str) -> str | None:
return self._store.get((service, username))
def set_password(self, service: str, username: str, password: str) -> None:
self._store[(service, username)] = password
def delete_password(self, service: str, username: str) -> None:
try:
del self._store[(service, username)]
except KeyError as exc:
raise keyring.errors.PasswordDeleteError("not found") from exc
@pytest.fixture
def memory_keyring():
"""Swap in the in-memory keyring backend for the duration of a test."""
backend = InMemoryKeyring()
previous = keyring.get_keyring()
keyring.set_keyring(backend)
try:
yield backend
finally:
keyring.set_keyring(previous)
@@ -0,0 +1,676 @@
"""Tests for the Autodesk auth + APS client (``bonsaiviewer_autodesk.autodesk``).
HTTP is mocked through the injectable ``transport`` seam using
``httpx.MockTransport``; time through the injectable ``now`` clock; and the
OAuth redirect through the injectable ``callback_waiter``.
"""
from __future__ import annotations
import datetime as dt
import socket
import threading
import time
from collections import deque
import httpx
import keyring.errors
import pytest
from bonsaiviewer_autodesk import autodesk
from bonsaiviewer_autodesk.rpc import RpcError
# --- HTTP routing ------------------------------------------------------------
def _build_response(spec: dict) -> httpx.Response:
kwargs = {key: spec[key] for key in ("json", "text", "content", "headers") if key in spec}
return httpx.Response(spec.get("status", 200), **kwargs)
class Router:
"""A tiny httpx.MockTransport router.
Routes match on HTTP method plus a substring of the request URL, in
declaration order. Pass multiple specs to a route to return them in turn
(the last one repeats); every request is recorded on ``requests``.
"""
def __init__(self) -> None:
self._routes: list[tuple[str, str, deque]] = []
self.requests: list[httpx.Request] = []
def add(self, method: str, contains: str, *specs: dict) -> "Router":
self._routes.append((method, contains, deque(specs or ({},))))
return self
def _handle(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
for method, contains, specs in self._routes:
if request.method == method and contains in str(request.url):
spec = specs[0] if len(specs) == 1 else specs.popleft()
return _build_response(spec)
return httpx.Response(404, text=f"unrouted {request.method} {request.url}")
@property
def transport(self) -> httpx.MockTransport:
return httpx.MockTransport(self._handle)
def count(self, method: str, contains: str) -> int:
return sum(
1 for r in self.requests if r.method == method and contains in str(r.url)
)
# --- fakes -------------------------------------------------------------------
class FakeTokenStore:
"""In-memory stand-in for KeyringTokenStore."""
def __init__(self, initial: dict | None = None) -> None:
self.value = initial
def load(self) -> dict | None:
return self.value
def save(self, value: dict) -> None:
self.value = value
def delete(self) -> None:
self.value = None
class FakeAuth:
"""Minimal AuthSessionService stand-in for ApsClient tests."""
def ensure_access_token(self) -> str:
return "fake-token"
FIXED_NOW = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.timezone.utc)
def iso(offset_seconds: int) -> str:
return (FIXED_NOW + dt.timedelta(seconds=offset_seconds)).isoformat()
def stored_token_dict(*, access_offset: int, refresh_offset: int) -> dict:
return {
"client_id": "cid",
"access_token": "current-access",
"refresh_token": "current-refresh",
"access_token_expires_at_utc": iso(access_offset),
"refresh_token_expires_at_utc": iso(refresh_offset),
"scope": "data:read",
}
def make_auth(
*,
router: Router | None = None,
token_store: FakeTokenStore | None = None,
callback_waiter=None,
callback_url: str = "http://localhost:8080/",
) -> autodesk.AuthSessionService:
return autodesk.AuthSessionService(
client_id="cid",
callback_url=callback_url,
scope="data:read",
token_store=token_store or FakeTokenStore(),
transport=(router or Router()).transport,
now=lambda: FIXED_NOW,
callback_waiter=callback_waiter,
)
def make_client(router: Router) -> autodesk.ApsClient:
return autodesk.ApsClient(FakeAuth(), transport=router.transport)
# --- pure helpers ------------------------------------------------------------
def test_base64url_strips_padding():
assert autodesk._base64url(b"\x00") == "AA"
assert "=" not in autodesk._base64url(b"\x00\x00")
def test_generate_code_verifier_is_url_safe_and_unique():
verifier = autodesk.generate_code_verifier()
assert not set(verifier) & set("=+/")
assert autodesk.generate_code_verifier() != autodesk.generate_code_verifier()
def test_generate_code_challenge_matches_rfc7636_vector():
# RFC 7636 Appendix B test vector.
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
assert (
autodesk.generate_code_challenge(verifier)
== "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
)
def test_parse_storage_id_splits_bucket_and_object():
bucket, obj = autodesk.ApsClient._parse_storage_id(
"urn:adsk.objects:os.object:wip.dm.prod/abc-123.ifc"
)
assert bucket == "wip.dm.prod"
assert obj == "abc-123.ifc"
@pytest.mark.parametrize(
"bad",
[
"not-a-urn",
"urn:adsk.objects:os.object:bucketonly",
"urn:adsk.objects:os.object:/object",
"urn:adsk.objects:os.object:bucket/",
],
)
def test_parse_storage_id_rejects_malformed(bad):
with pytest.raises(RpcError):
autodesk.ApsClient._parse_storage_id(bad)
def test_entry_extracts_fields():
item = {
"id": "x",
"type": "items",
"attributes": {
"displayName": "Model.ifc",
"extension": {"type": "items:autodesk.bim360:File"},
},
}
assert autodesk.ApsClient._entry(item) == {
"id": "x",
"type": "items",
"display_name": "Model.ifc",
"name": None,
"extension_type": "items:autodesk.bim360:File",
}
def test_relationship_id_handles_dict_list_and_missing():
data = {
"relationships": {
"parent": {"data": {"id": "p1"}},
"files": {"data": [{"id": "f1"}, {"id": "f2"}]},
}
}
assert autodesk.ApsClient._relationship_id(data, "parent") == "p1"
assert autodesk.ApsClient._relationship_id(data, "files") == "f1"
assert autodesk.ApsClient._relationship_id(data, "missing") is None
def test_entry_name_matches_is_case_insensitive():
assert autodesk.ApsClient._entry_name_matches({"display_name": "Model.IFC"}, "model.ifc")
assert autodesk.ApsClient._entry_name_matches({"name": "Model.IFC"}, "model.ifc")
assert not autodesk.ApsClient._entry_name_matches({"display_name": "other.ifc"}, "model.ifc")
# --- KeyringTokenStore -------------------------------------------------------
def test_keyring_token_store_round_trip(memory_keyring):
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
assert store.load() is None
store.save({"access_token": "abc"})
assert store.load() == {"access_token": "abc"}
store.delete()
assert store.load() is None
def test_keyring_token_store_delete_missing_is_noop(memory_keyring):
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
store.delete() # no entry to delete — must not raise
def test_keyring_missing_backend_surfaces_friendly_error(monkeypatch):
def boom(*_args, **_kwargs):
raise keyring.errors.NoKeyringError("no backend")
monkeypatch.setattr(autodesk.keyring, "get_password", boom)
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
with pytest.raises(RpcError) as excinfo:
store.load()
assert "keyring" in excinfo.value.message.lower()
# --- token lifecycle / injected clock ---------------------------------------
def test_ensure_access_token_returns_unexpired_token_without_http():
store = FakeTokenStore(stored_token_dict(access_offset=3600, refresh_offset=100_000))
router = Router()
auth = make_auth(router=router, token_store=store)
assert auth.ensure_access_token() == "current-access"
assert router.requests == []
def test_ensure_access_token_refreshes_when_access_expired():
store = FakeTokenStore(stored_token_dict(access_offset=-100, refresh_offset=100_000))
router = Router().add(
"POST",
"/authentication/v2/token",
{
"json": {
"access_token": "refreshed-access",
"refresh_token": "refreshed-refresh",
"expires_in": 3600,
"refresh_token_expires_in": 200_000,
}
},
)
auth = make_auth(router=router, token_store=store)
assert auth.ensure_access_token() == "refreshed-access"
assert store.value["access_token"] == "refreshed-access"
assert router.count("POST", "/authentication/v2/token") == 1
def test_ensure_access_token_logs_in_when_both_tokens_expired(monkeypatch):
monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
store = FakeTokenStore() # nothing stored at all
router = Router().add(
"POST",
"/authentication/v2/token",
{
"json": {
"access_token": "logged-in-access",
"refresh_token": "logged-in-refresh",
"expires_in": 3600,
}
},
)
auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "auth-code")
assert auth.ensure_access_token() == "logged-in-access"
assert store.value["access_token"] == "logged-in-access"
def test_token_from_payload_uses_injected_clock():
auth = make_auth()
token = auth._token_from_payload(
{"access_token": "a", "refresh_token": "r", "expires_in": 3600}
)
assert token.access_token_expires_at_utc == iso(3600 - 30)
# Default refresh TTL is 15 days, minus the same 30s safety margin.
assert token.refresh_token_expires_at_utc == iso(15 * 24 * 60 * 60 - 30)
def test_login_interactive_rejects_non_local_callback():
auth = make_auth(callback_url="https://example.com/callback")
with pytest.raises(RpcError, match="Callback URL"):
auth.login_interactive()
def test_login_interactive_exchanges_code_for_token(monkeypatch):
opened: list[str] = []
monkeypatch.setattr(autodesk.webbrowser, "open", lambda url: opened.append(url))
store = FakeTokenStore()
router = Router().add(
"POST",
"/authentication/v2/token",
{
"json": {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"expires_in": 3600,
}
},
)
auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "the-code")
token = auth.login_interactive()
assert token.access_token == "fresh-access"
assert store.value["access_token"] == "fresh-access"
assert opened and opened[0].startswith(autodesk.AuthSessionService.authorize_endpoint)
def test_token_exchange_failure_raises_rpc_error(monkeypatch):
monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
router = Router().add(
"POST", "/authentication/v2/token", {"status": 400, "text": "invalid_grant"}
)
auth = make_auth(router=router, callback_waiter=lambda *_a: "the-code")
with pytest.raises(RpcError, match="invalid_grant"):
auth.login_interactive()
# --- wait_for_oauth_callback (real loopback socket) --------------------------
def _free_port() -> int:
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _get_with_retry(url: str, params: dict, timeout: float = 5.0) -> None:
"""Fire one GET, retrying only while the server has not yet bound."""
deadline = time.time() + timeout
while True:
try:
httpx.get(url, params=params)
return
except httpx.ConnectError:
if time.time() > deadline:
raise
time.sleep(0.02)
def drive_callback(expected_state: str, query: dict, path: str = "/cb") -> dict:
"""Run ``wait_for_oauth_callback`` in a thread and fire one redirect at it."""
port = _free_port()
outcome: dict = {}
def server() -> None:
try:
outcome["code"] = autodesk.wait_for_oauth_callback(
"127.0.0.1", port, path, expected_state
)
except BaseException as exc: # noqa: BLE001 - re-raised to the test
outcome["error"] = exc
thread = threading.Thread(target=server, daemon=True)
thread.start()
_get_with_retry(f"http://127.0.0.1:{port}{path}", query)
thread.join(timeout=5)
return outcome
def test_wait_for_oauth_callback_returns_authorization_code():
outcome = drive_callback("state-123", {"state": "state-123", "code": "the-code"})
assert outcome.get("code") == "the-code"
def test_wait_for_oauth_callback_rejects_state_mismatch():
outcome = drive_callback("expected-state", {"state": "tampered", "code": "c"})
assert isinstance(outcome.get("error"), RpcError)
assert "state mismatch" in outcome["error"].message.lower()
def test_wait_for_oauth_callback_reports_oauth_error():
outcome = drive_callback("state-123", {"state": "state-123", "error": "access_denied"})
assert isinstance(outcome.get("error"), RpcError)
assert "access_denied" in outcome["error"].message
def test_wait_for_oauth_callback_requires_a_code():
outcome = drive_callback("state-123", {"state": "state-123"})
assert isinstance(outcome.get("error"), RpcError)
assert "authorization code" in outcome["error"].message.lower()
# --- ApsClient browsing ------------------------------------------------------
def _hub(hub_id: str, name: str) -> dict:
return {
"id": hub_id,
"attributes": {"name": name, "extension": {"type": "hubs:autodesk.core:Hub"}},
}
def _project(project_id: str, name: str) -> dict:
return {
"id": project_id,
"attributes": {
"name": name,
"extension": {"type": "projects:autodesk.bim360:Project"},
},
"relationships": {"rootFolder": {"data": {"id": f"root-{project_id}"}}},
}
def test_list_hubs_sorts_case_insensitively():
router = Router().add(
"GET",
"/project/v1/hubs",
{"json": {"data": [_hub("h2", "Beta"), _hub("h1", "alpha")]}},
)
hubs = make_client(router).list_hubs()
assert [h["name"] for h in hubs] == ["alpha", "Beta"]
assert hubs[0]["id"] == "h1"
def test_list_projects_follows_pagination():
router = Router()
router.add(
"GET",
"page=2",
{"json": {"data": [_project("p2", "Zeta")], "links": {}}},
)
router.add(
"GET",
"/hubs/h/projects",
{
"json": {
"data": [_project("p1", "Alpha")],
"links": {
"next": {
"href": "https://developer.api.autodesk.com/project/v1/hubs/h/projects?page=2"
}
},
}
},
)
projects = make_client(router).list_projects("h")
assert [p["id"] for p in projects] == ["p1", "p2"]
assert projects[0]["root_folder_id"] == "root-p1"
def test_get_item_returns_tip_details():
router = Router().add(
"GET",
"/items/",
{
"json": {
"data": {
"id": "item-1",
"attributes": {"displayName": "Model.ifcfed", "hidden": False},
"relationships": {
"parent": {"data": {"id": "folder-1"}},
"tip": {"data": {"id": "v3"}},
},
},
"included": [
{
"type": "versions",
"id": "v3",
"attributes": {
"versionNumber": 3,
"lastModifiedTime": "2026-01-01T00:00:00Z",
"lastModifiedUserName": "Dion",
},
"relationships": {
"storage": {
"data": {"id": "urn:adsk.objects:os.object:b/o"}
}
},
}
],
}
},
)
item = make_client(router).get_item("proj-1", "item-1")
assert item["hidden"] is False
assert item["version_id"] == "v3"
assert item["storage_id"] == "urn:adsk.objects:os.object:b/o"
assert item["version_number"] == 3
assert item["parent_folder_id"] == "folder-1"
assert item["last_modified_user_name"] == "Dion"
def test_get_item_without_tip_is_treated_as_hidden():
router = Router().add(
"GET",
"/items/",
{"json": {"data": {"id": "item-1", "attributes": {}, "relationships": {}}}},
)
item = make_client(router).get_item("proj-1", "item-1")
assert item["hidden"] is True
assert item["storage_id"] is None
assert item["version_id"] is None
def test_list_folder_contents_applies_extension_filter():
router = Router().add(
"GET",
"/contents",
{
"json": {
"data": [
{
"id": "f1",
"type": "folders",
"attributes": {"name": "Sub", "extension": {"type": "t"}},
},
{
"id": "i1",
"type": "items",
"attributes": {"displayName": "keep.ifcfed", "extension": {}},
},
{
"id": "i2",
"type": "items",
"attributes": {"displayName": "skip.txt", "extension": {}},
},
],
"links": {},
}
},
)
entries = make_client(router).list_folder_contents(
"proj",
"folder",
extension_filter=lambda e: (e["display_name"] or "").endswith(".ifcfed"),
)
ids = {e["id"] for e in entries}
assert ids == {"f1", "i1"} # folders kept, non-.ifcfed item dropped
def test_get_json_maps_http_error_to_rpc_error():
router = Router().add(
"GET", "/project/v1/hubs", {"status": 403, "text": "Forbidden: bad token"}
)
with pytest.raises(RpcError, match="Forbidden"):
make_client(router).list_hubs()
# --- download / upload -------------------------------------------------------
def test_download_storage_to_file_writes_content_and_reports_progress(tmp_path):
router = Router()
router.add(
"GET",
"/signeds3download",
{"json": {"url": "https://signed.example/blob"}},
)
router.add("GET", "signed.example/blob", {"content": b"hello world"})
dest = tmp_path / "out.bin"
seen: list = []
make_client(router).download_storage_to_file(
"urn:adsk.objects:os.object:bucket/object",
dest,
progress=lambda name, pct, done, total: seen.append((name, pct, done, total)),
)
assert dest.read_bytes() == b"hello world"
assert seen[-1][1] == 100 # final progress callback reports 100%
def test_signed_download_url_missing_raises():
router = Router().add("GET", "/signeds3download", {"json": {}})
with pytest.raises(RpcError, match="URL"):
make_client(router).download_storage_to_file(
"urn:adsk.objects:os.object:bucket/object", "/tmp/ignored"
)
def _upload_router() -> Router:
router = Router()
router.add(
"POST", "/storage", {"json": {"data": {"id": "urn:adsk.objects:os.object:bk/obj"}}}
)
router.add(
"GET",
"/signeds3upload",
{"json": {"uploadKey": "ukey", "urls": ["https://up.example/part1"]}},
)
router.add("PUT", "up.example/part1", {"status": 200})
router.add("POST", "/signeds3upload", {"status": 200, "json": {}})
return router
def test_upload_file_creates_new_item_when_folder_is_empty(tmp_path):
local = tmp_path / "model.ifc"
local.write_bytes(b"x" * 1024)
router = _upload_router()
router.add("GET", "/contents", {"json": {"data": [], "links": {}}})
router.add(
"POST",
"/items",
{
"json": {
"data": {"id": "new-item"},
"included": [
{
"type": "versions",
"id": "v1",
"attributes": {
"versionNumber": 1,
"lastModifiedTime": "2026-01-01T00:00:00Z",
"lastModifiedUserName": "Dion",
},
}
],
}
},
)
result = make_client(router).upload_file_to_folder(
"proj", "folder", local, display_name="model.ifc"
)
assert result["item_id"] == "new-item"
assert result["version_id"] == "v1"
assert result["version_number"] == 1
assert router.count("PUT", "up.example/part1") == 1
def test_upload_file_creates_a_version_when_item_exists(tmp_path):
local = tmp_path / "model.ifc"
local.write_bytes(b"x" * 1024)
router = _upload_router()
router.add(
"GET",
"/contents",
{
"json": {
"data": [
{
"id": "existing-item",
"type": "items",
"attributes": {"displayName": "model.ifc", "extension": {}},
}
],
"links": {},
}
},
)
router.add(
"POST",
"/versions",
{"json": {"data": {"id": "v7", "attributes": {"versionNumber": 7}}}},
)
result = make_client(router).upload_file_to_folder(
"proj", "folder", local, display_name="model.ifc"
)
assert result["item_id"] == "existing-item"
assert result["version_id"] == "v7"
assert result["version_number"] == 7
def test_upload_rejects_missing_local_file(tmp_path):
with pytest.raises(RpcError, match="does not exist"):
make_client(Router()).upload_file_to_folder(
"proj", "folder", tmp_path / "missing.ifc"
)
@@ -0,0 +1,83 @@
"""Tests for the on-disk cache layout (``bonsaiviewer_autodesk.cache``)."""
from __future__ import annotations
from bonsaiviewer_autodesk import cache
def test_cache_root_uses_xdg_on_linux(tmp_path, monkeypatch):
monkeypatch.setattr(cache.platform, "system", lambda: "Linux")
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
root = cache.cache_root()
assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_cache_root_uses_localappdata_on_windows(tmp_path, monkeypatch):
monkeypatch.setattr(cache.platform, "system", lambda: "Windows")
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "appdata"))
root = cache.cache_root()
assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk" / "Cache"
assert root.is_dir()
def test_cache_root_uses_library_caches_on_macos(tmp_path, monkeypatch):
monkeypatch.setattr(cache.platform, "system", lambda: "Darwin")
monkeypatch.setattr(cache.Path, "home", lambda: tmp_path / "home")
root = cache.cache_root()
assert root == tmp_path / "home" / "Library" / "Caches" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_ifcfed_dir_is_deterministic(cache_dir):
first = cache.ifcfed_dir("project-1", "item-1")
second = cache.ifcfed_dir("project-1", "item-1")
assert first == second
def test_ifcfed_dir_varies_with_inputs(cache_dir):
assert cache.ifcfed_dir("p", "item-1") != cache.ifcfed_dir("p", "item-2")
assert cache.ifcfed_dir("p1", "item") != cache.ifcfed_dir("p2", "item")
def test_model_dir_is_per_version(cache_dir):
v1 = cache.model_dir("p", "item", "v1")
v2 = cache.model_dir("p", "item", "v2")
assert v1 != v2
assert cache.model_dir("p", "item", "v1") == v1
def test_prepare_sole_child_dir_wipes_existing_contents(cache_dir):
directory = cache.ifcfed_dir("p", "item")
directory.mkdir(parents=True)
(directory / "stale.txt").write_text("old")
result = cache.prepare_sole_child_dir(directory)
assert result == directory
assert directory.is_dir()
assert list(directory.iterdir()) == []
def test_prepare_sole_child_dir_creates_when_absent(cache_dir):
directory = cache.model_dir("p", "item", "v1")
assert not directory.exists()
cache.prepare_sole_child_dir(directory)
assert directory.is_dir()
def test_manifest_round_trips(cache_dir):
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
ifcfed_path = directory / "model.ifcfed"
ifcfed_path.write_text("data")
manifest = {"connector": "autodesk", "item_id": "item", "hub_id": "h"}
manifest_path = cache.write_manifest(ifcfed_path, manifest)
assert manifest_path.name == "model.ifcfed.manifest"
assert cache.read_manifest(ifcfed_path) == manifest
def test_read_manifest_returns_none_when_absent(cache_dir):
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
assert cache.read_manifest(directory / "model.ifcfed") is None
@@ -0,0 +1,305 @@
"""Tests for the RPC handlers (``bonsaiviewer_autodesk.connector``).
The non-interactive handlers are exercised against a fake ``ApsClient`` so no
network or GUI is touched; ``progress_dialog`` is stubbed out.
"""
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
import pytest
from bonsaiviewer_autodesk import cache, connector, settings
from bonsaiviewer_autodesk.rpc import RpcError
# --- fakes / fixtures --------------------------------------------------------
@contextmanager
def _fake_progress(_message):
yield lambda *_args, **_kwargs: None
class FakeAps:
"""Stand-in for ApsClient covering only what the handlers call."""
def __init__(self, *, item: dict | None = None, items: dict | None = None) -> None:
self._item = item
self._items = items or {}
self.downloaded: list[tuple] = []
self.uploaded: list[tuple] = []
def get_item(self, _project_id: str, item_id: str) -> dict:
if item_id in self._items:
return dict(self._items[item_id])
assert self._item is not None, f"no canned item for {item_id!r}"
return dict(self._item)
def download_storage_to_file(self, storage_id, destination_path, *, progress=None) -> None:
Path(destination_path).write_bytes(b"<ifc data>")
self.downloaded.append((storage_id, Path(destination_path)))
def upload_file_to_folder(
self, project_id, folder_id, local_path, *, display_name=None, progress=None
) -> dict:
self.uploaded.append((project_id, folder_id, Path(local_path), display_name))
return {
"item_id": "uploaded-item",
"version_id": "v1",
"version_number": 1,
"last_modified_time_utc": "2026-01-01T00:00:00Z",
"last_modified_user_name": "Dion",
}
@pytest.fixture
def make_connector(config_dir, cache_dir, monkeypatch):
"""Build an AutodeskConnector wired to a fake ApsClient."""
monkeypatch.setattr(connector, "progress_dialog", _fake_progress)
def _make(aps: FakeAps) -> connector.AutodeskConnector:
conn = connector.AutodeskConnector()
conn.aps = aps
conn.auth = object() # only identity matters to _require_aps
return conn
return _make
def _ifcfed_item(**overrides) -> dict:
item = {
"id": "item-1",
"hidden": False,
"storage_id": "urn:adsk.objects:os.object:bucket/object",
"display_name": "Project.ifcfed",
"version_id": "v1",
"version_number": 1,
"last_modified_time_utc": None,
"last_modified_user_name": None,
"parent_folder_id": "folder-1",
}
item.update(overrides)
return item
# --- pure helpers ------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
(0, "0 B"),
(512, "512 B"),
(1024, "1.0 KB"),
(1536, "1.5 KB"),
(5 * 1024 * 1024, "5.0 MB"),
],
)
def test_format_bytes(value, expected):
assert connector._format_bytes(value) == expected
def test_progress_detail_combines_percent_and_bytes():
detail = connector._progress_detail(45, 4_500_000, 10_000_000)
assert detail.startswith("45%, ")
assert " / " in detail
def test_progress_detail_percent_only():
assert connector._progress_detail(50, None, None) == "50%"
def test_progress_detail_bytes_without_total():
assert connector._progress_detail(None, 2048, None) == "2.0 KB"
def test_progress_detail_empty_when_nothing_known():
assert connector._progress_detail(None, None, None) == ""
def test_build_metadata_maps_all_fields():
metadata = connector._build_metadata(
{
"version_number": 3,
"last_modified_time_utc": "2026-01-01T00:00:00Z",
"last_modified_user_name": "Dion",
}
)
assert metadata == {
"revision": "v3",
"date": "2026-01-01T00:00:00Z",
"author": "Dion",
}
def test_build_metadata_omits_missing_fields():
assert connector._build_metadata({}) == {}
assert connector._build_metadata({"version_number": None}) == {}
def test_require_string_rejects_missing_or_blank():
assert connector._require_string({"k": "v"}, "k") == "v"
with pytest.raises(RpcError):
connector._require_string({"k": " "}, "k")
with pytest.raises(RpcError):
connector._require_string({}, "k")
def test_require_object_and_array_type_checks():
assert connector._require_object({"a": 1}, "p") == {"a": 1}
assert connector._require_array([1, 2], "p") == [1, 2]
with pytest.raises(RpcError):
connector._require_object([], "p")
with pytest.raises(RpcError):
connector._require_array({}, "p")
# --- credential wiring -------------------------------------------------------
def test_reload_credentials_without_client_id_leaves_aps_unset(config_dir):
conn = connector.AutodeskConnector()
assert conn.aps is None
assert conn.auth is None
with pytest.raises(RpcError, match="client id"):
conn._require_aps()
def test_reload_credentials_with_client_id_builds_aps(config_dir):
settings.save_client_id("my-client-id")
conn = connector.AutodeskConnector()
assert conn.aps is not None
assert conn.auth is not None
# --- pull_ifcfed -------------------------------------------------------------
def test_pull_ifcfed_downloads_and_writes_manifest(make_connector):
aps = FakeAps(item=_ifcfed_item())
conn = make_connector(aps)
result = conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
path = Path(result["path"])
assert path.exists()
assert path.name == "Project.ifcfed"
assert aps.downloaded # the fake actually got asked to download
manifest = cache.read_manifest(path)
assert manifest["connector"] == "autodesk"
assert manifest["item_id"] == "item-1"
assert manifest["hub_id"] == "h"
def test_pull_ifcfed_rejects_non_ifcfed_file(make_connector):
aps = FakeAps(item=_ifcfed_item(display_name="model.ifc"))
conn = make_connector(aps)
with pytest.raises(RpcError, match="ifcfed"):
conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
def test_pull_ifcfed_rejects_deleted_item(make_connector):
aps = FakeAps(item=_ifcfed_item(hidden=True))
conn = make_connector(aps)
with pytest.raises(RpcError, match="deleted"):
conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
def test_pull_ifcfed_requires_string_fields(make_connector):
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="item_id"):
conn.pull_ifcfed({"hub_id": "h", "project_id": "p"})
# --- pull_models -------------------------------------------------------------
def test_pull_models_skips_failures_with_none(make_connector):
aps = FakeAps(
items={
"good": _ifcfed_item(id="good", display_name="good.ifc"),
"gone": _ifcfed_item(id="gone", hidden=True),
}
)
conn = make_connector(aps)
models = [
{"source": {"connector": "autodesk", "project_id": "p", "item_id": "good"}},
{"source": {"connector": "autodesk", "project_id": "p", "item_id": "gone"}},
{"source": {"connector": "other", "project_id": "p", "item_id": "good"}},
]
results = conn.pull_models(models)
assert len(results) == 3
assert results[0] is not None and Path(results[0]["path"]).exists()
assert results[1] is None # hidden/deleted item
assert results[2] is None # wrong connector -> RpcError, swallowed
def test_pull_models_requires_a_json_array(make_connector):
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="array"):
conn.pull_models({"not": "an array"})
# --- push_ifcfed -------------------------------------------------------------
def test_push_ifcfed_uploads_and_caches_with_manifest(make_connector, tmp_path):
local = tmp_path / "local.ifcfed"
local.write_bytes(b"ifcfed bytes")
aps = FakeAps(item=_ifcfed_item())
conn = make_connector(aps)
result = conn.push_ifcfed(
{
"path": str(local),
"manifest": {
"connector": "autodesk",
"hub_id": "h",
"project_id": "p",
"item_id": "item-1",
},
}
)
assert aps.uploaded
cached = Path(result["path"])
assert cached.exists()
assert cache.read_manifest(cached)["item_id"] == "uploaded-item"
def test_push_ifcfed_rejects_missing_local_file(make_connector):
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="does not exist"):
conn.push_ifcfed(
{
"path": "/no/such/file.ifcfed",
"manifest": {
"connector": "autodesk",
"hub_id": "h",
"project_id": "p",
"item_id": "item-1",
},
}
)
def test_push_ifcfed_rejects_non_ifcfed_extension(make_connector, tmp_path):
local = tmp_path / "local.ifc"
local.write_bytes(b"data")
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="ifcfed"):
conn.push_ifcfed({"path": str(local), "manifest": {"connector": "autodesk"}})
def test_push_ifcfed_rejects_foreign_connector_manifest(make_connector, tmp_path):
local = tmp_path / "local.ifcfed"
local.write_bytes(b"data")
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="connector"):
conn.push_ifcfed({"path": str(local), "manifest": {"connector": "other"}})
+123
View File
@@ -0,0 +1,123 @@
"""Tests for the JSON-RPC host (``bonsaiviewer_autodesk.rpc``)."""
from __future__ import annotations
import io
import json
from bonsaiviewer_autodesk.rpc import (
JSONRPC_INTERNAL_ERROR,
JSONRPC_INVALID_PARAMS,
JSONRPC_INVALID_REQUEST,
JSONRPC_METHOD_NOT_FOUND,
JSONRPC_PARSE_ERROR,
JsonRpcHost,
RpcError,
)
def run(line: str, handlers: dict | None = None) -> tuple[list[dict], str]:
"""Drive a ``JsonRpcHost`` over in-memory streams.
Returns ``(responses, stderr_text)`` where ``responses`` is the parsed
JSON written to stdout, one element per line.
"""
out = io.StringIO()
err = io.StringIO()
JsonRpcHost(
handlers or {},
stdin=io.StringIO(line),
stdout=out,
stderr=err,
).run()
responses = [json.loads(piece) for piece in out.getvalue().splitlines() if piece]
return responses, err.getvalue()
def test_parse_error_for_invalid_json():
responses, _ = run("this is not json\n")
assert responses[0]["error"]["code"] == JSONRPC_PARSE_ERROR
assert responses[0]["id"] is None
def test_request_must_be_a_json_object():
responses, _ = run("[1, 2, 3]\n")
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
def test_wrong_jsonrpc_version_keeps_id():
responses, _ = run('{"jsonrpc": "1.0", "id": 7, "method": "go"}\n')
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
assert responses[0]["id"] == 7
def test_missing_method_string():
responses, _ = run('{"jsonrpc": "2.0", "id": 1}\n')
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
def test_params_must_be_object_or_array():
responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "go", "params": 5}\n')
assert responses[0]["error"]["code"] == JSONRPC_INVALID_PARAMS
def test_unknown_method():
responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "nope"}\n')
assert responses[0]["error"]["code"] == JSONRPC_METHOD_NOT_FOUND
def test_successful_result_round_trip():
responses, _ = run(
'{"jsonrpc": "2.0", "id": 42, "method": "echo", "params": {"x": 1}}\n',
{"echo": lambda params: params},
)
assert responses == [{"jsonrpc": "2.0", "id": 42, "result": {"x": 1}}]
def test_rpc_error_is_forwarded_with_code_and_data():
def handler(_params):
raise RpcError(JSONRPC_INTERNAL_ERROR, "boom", data={"detail": "x"})
responses, _ = run(
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
{"go": handler},
)
assert responses[0]["error"] == {
"code": JSONRPC_INTERNAL_ERROR,
"message": "boom",
"data": {"detail": "x"},
}
def test_unexpected_exception_becomes_internal_error():
def handler(_params):
raise ValueError("kaboom")
responses, stderr = run(
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
{"go": handler},
)
assert responses[0]["error"]["code"] == JSONRPC_INTERNAL_ERROR
assert responses[0]["error"]["message"] == "kaboom"
assert "Traceback" in stderr
def test_notification_runs_handler_but_writes_no_response():
calls: list[int] = []
responses, _ = run(
'{"jsonrpc": "2.0", "method": "go"}\n',
{"go": lambda _params: calls.append(1)},
)
assert calls == [1]
assert responses == []
def test_blank_lines_skipped_and_requests_processed_in_order():
line = (
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n'
"\n"
" \n"
'{"jsonrpc": "2.0", "id": 2, "method": "go"}\n'
)
responses, _ = run(line, {"go": lambda _params: "ok"})
assert [r["id"] for r in responses] == [1, 2]
@@ -0,0 +1,85 @@
"""Tests for settings persistence (``bonsaiviewer_autodesk.settings``)."""
from __future__ import annotations
import json
import pytest
from bonsaiviewer_autodesk import settings
def _write_settings_json(config_dir, data: dict) -> None:
(config_dir / "settings.json").write_text(json.dumps(data), encoding="utf-8")
def test_config_root_uses_xdg_on_linux(tmp_path, monkeypatch):
monkeypatch.setattr(settings.platform, "system", lambda: "Linux")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
root = settings.config_root()
assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_config_root_uses_appdata_on_windows(tmp_path, monkeypatch):
monkeypatch.setattr(settings.platform, "system", lambda: "Windows")
monkeypatch.setenv("APPDATA", str(tmp_path / "appdata"))
root = settings.config_root()
assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_config_root_uses_application_support_on_macos(tmp_path, monkeypatch):
monkeypatch.setattr(settings.platform, "system", lambda: "Darwin")
monkeypatch.setattr(settings.Path, "home", lambda: tmp_path / "home")
root = settings.config_root()
assert root == tmp_path / "home" / "Library" / "Application Support" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_save_and_load_client_id_strips_whitespace(config_dir):
settings.save_client_id(" abc123 ")
assert settings.load_client_id() == "abc123"
def test_load_client_id_empty_when_nothing_configured(config_dir):
assert settings.load_client_id() == ""
def test_callback_port_round_trips(config_dir):
settings.save_callback_port(9001)
assert settings.stored_callback_port() == 9001
def test_callback_port_defaults_when_unset(config_dir):
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_callback_port_defaults_on_non_numeric_value(config_dir):
_write_settings_json(config_dir, {"callback_port": "not-a-number"})
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_callback_port_defaults_on_out_of_range_value(config_dir):
_write_settings_json(config_dir, {"callback_port": 70000})
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_save_callback_port_rejects_out_of_range(config_dir):
with pytest.raises(ValueError):
settings.save_callback_port(0)
with pytest.raises(ValueError):
settings.save_callback_port(70000)
def test_corrupt_settings_file_is_treated_as_empty(config_dir):
(config_dir / "settings.json").write_text("{ not valid json", encoding="utf-8")
assert settings.load_client_id() == ""
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_save_client_id_preserves_other_keys(config_dir):
settings.save_callback_port(9001)
settings.save_client_id("abc")
assert settings.stored_callback_port() == 9001
assert settings.load_client_id() == "abc"
@@ -30,8 +30,7 @@ Run
bonsaiviewer-autodesk
The connector launches without configuration. On first run, open the settings
dialog, or set the ``APS_CLIENT_ID`` environment variable, to configure the
Autodesk client ID and OAuth callback port.
dialog to configure the Autodesk client ID and OAuth callback port.
For direct protocol testing, send newline-delimited JSON-RPC 2.0 requests on
standard input:
@@ -51,11 +50,8 @@ exit.
Configuration
-------------
The connector reads the Autodesk client ID from these places, in order:
1. ``APS_CLIENT_ID`` environment variable.
2. ``settings.json`` in the connector config directory, written by the settings
dialog.
The connector reads the Autodesk client ID from ``settings.json`` in the
connector config directory, written by the settings dialog.
The OAuth callback host is always ``localhost``. The callback port defaults to
``8080`` and can be changed in the settings dialog.