Add Autodesk cloud sync connector

Initial implementation of the ifcviewer-autodesk connector — a separate process that bridges the IfcViewer to Autodesk APS (BIM 360 / ACC). Speaks JSON-RPC 2.0 over stdio per CLOUD_SYNC_PROTOCOL.md (also added). PKCE OAuth with keyring-backed token storage, customtkinter browse/picker UI, and PyInstaller packaging.

Implements both interactive and non-interactive variants of each push/pull (pull_ifcfed[_interactive], pull_models[_interactive], push_ifcfed[_interactive], push_model[_interactive]) so the viewer can offer both "Save"/"Open from Cloud" and "Save As"/"Add Model from Cloud" entry points. File transfers report progress through a dialog with per-byte updates; pull_models shows "(i/N)" for batches.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Dion Moult
2026-05-19 23:33:03 +10:00
parent 6ca38f8bf6
commit 95c62cc70e
16 changed files with 3045 additions and 0 deletions
@@ -0,0 +1,18 @@
from __future__ import annotations
import sys
from ifcviewer_autodesk.connector import AutodeskConnector
from ifcviewer_autodesk.rpc import JsonRpcHost
from ifcviewer_autodesk.ui import ensure_tk_app
def main() -> int:
ensure_tk_app()
connector = AutodeskConnector()
host = JsonRpcHost(connector.handlers(), stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
return host.run()
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,717 @@
from __future__ import annotations
import base64
import datetime as dt
import hashlib
import json
import math
import secrets
import urllib.parse
import webbrowser
from dataclasses import asdict, dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Callable
import httpx
import keyring
import keyring.errors
from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
Progress = Callable[[str, str, "int | None"], None]
def _no_keyring_error() -> RpcError:
return RpcError(
JSONRPC_INTERNAL_ERROR,
"No secure keyring backend is available. On macOS, use Keychain; "
"on Windows, use Credential Manager; on Linux, install a Secret Service "
"backend such as gnome-keyring or KWallet.",
)
class KeyringTokenStore:
def __init__(self, *, service_name: str, username: str) -> None:
self.service_name = service_name
self.username = username
def load(self) -> dict[str, Any] | None:
try:
raw = keyring.get_password(self.service_name, self.username)
except keyring.errors.NoKeyringError as exc:
raise _no_keyring_error() from exc
return json.loads(raw) if raw else None
def save(self, value: dict[str, Any]) -> None:
try:
keyring.set_password(self.service_name, self.username, json.dumps(value))
except keyring.errors.NoKeyringError as exc:
raise _no_keyring_error() from exc
def delete(self) -> None:
try:
keyring.delete_password(self.service_name, self.username)
except keyring.errors.PasswordDeleteError:
pass
except keyring.errors.NoKeyringError as exc:
raise _no_keyring_error() from exc
def _base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def generate_code_verifier() -> str:
return _base64url(secrets.token_bytes(48))
def generate_code_challenge(verifier: str) -> str:
return _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
@dataclass
class StoredToken:
client_id: str
access_token: str
refresh_token: str
access_token_expires_at_utc: str
refresh_token_expires_at_utc: str
scope: str
@property
def access_token_expires_at(self) -> dt.datetime:
return dt.datetime.fromisoformat(self.access_token_expires_at_utc)
@property
def refresh_token_expires_at(self) -> dt.datetime:
return dt.datetime.fromisoformat(self.refresh_token_expires_at_utc)
def _noop_progress(_phase: str, _message: str, _percent: int | None = None) -> None:
return
class AuthSessionService:
authorize_endpoint = "https://developer.api.autodesk.com/authentication/v2/authorize"
token_endpoint = "https://developer.api.autodesk.com/authentication/v2/token"
def __init__(
self,
*,
client_id: str,
callback_url: str,
scope: str,
token_store: KeyringTokenStore,
) -> 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)
def get_token(self) -> StoredToken | None:
raw = self.token_store.load()
return StoredToken(**raw) if raw else None
def ensure_access_token(self, progress: Progress = _noop_progress) -> str:
token = self.get_token()
now = dt.datetime.now(dt.timezone.utc)
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):
return self._refresh(token, progress).access_token
return self.login_interactive(progress).access_token
def login_interactive(self, progress: Progress = _noop_progress) -> StoredToken:
progress("auth", "Preparing Autodesk sign-in", None)
verifier = generate_code_verifier()
challenge = generate_code_challenge(verifier)
state = secrets.token_hex(16)
callback = urllib.parse.urlparse(self.callback_url)
if callback.scheme != "http" or callback.hostname not in {"127.0.0.1", "localhost"}:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Callback URL must be http://localhost or http://127.0.0.1.")
query = urllib.parse.urlencode(
{
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.callback_url,
"scope": self.scope,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
}
)
authorize_url = f"{self.authorize_endpoint}?{query}"
progress("auth", "Opening browser for Autodesk sign-in", None)
webbrowser.open(authorize_url)
code = self._wait_for_callback(
callback.hostname or "127.0.0.1",
callback.port or 80,
callback.path or "/",
state,
)
progress("auth", "Exchanging authorization code for token", None)
response = self.http.post(
self.token_endpoint,
data={
"client_id": self.client_id,
"grant_type": "authorization_code",
"code": code,
"code_verifier": verifier,
"redirect_uri": self.callback_url,
},
)
if response.is_error:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token exchange failed: {response.text}")
token = self._token_from_payload(response.json())
self.token_store.save(asdict(token))
progress("auth", "Signed in to Autodesk", 100)
return token
def _refresh(self, token: StoredToken, progress: Progress) -> StoredToken:
progress("auth", "Refreshing Autodesk session", None)
response = self.http.post(
self.token_endpoint,
data={
"client_id": self.client_id,
"grant_type": "refresh_token",
"refresh_token": token.refresh_token,
"scope": self.scope,
},
)
if response.is_error:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token refresh failed: {response.text}")
refreshed = self._token_from_payload(response.json())
self.token_store.save(asdict(refreshed))
progress("auth", "Session refreshed", 100)
return refreshed
def _token_from_payload(self, payload: dict[str, Any]) -> StoredToken:
now = dt.datetime.now(dt.timezone.utc)
refresh_ttl = int(payload.get("refresh_token_expires_in", 15 * 24 * 60 * 60))
return StoredToken(
client_id=self.client_id,
access_token=payload["access_token"],
refresh_token=payload["refresh_token"],
access_token_expires_at_utc=(now + dt.timedelta(seconds=int(payload["expires_in"]) - 30)).isoformat(),
refresh_token_expires_at_utc=(now + dt.timedelta(seconds=refresh_ttl - 30)).isoformat(),
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:
self.auth = auth
self.http = httpx.Client(timeout=120)
# Browsing -----------------------------------------------------------------
def list_hubs(self) -> list[dict[str, Any]]:
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
return [
{
"id": item["id"],
"name": item["attributes"]["name"],
"extension_type": item["attributes"]["extension"]["type"],
}
for item in payload.get("data", [])
]
def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
projects: list[dict[str, Any]] = []
while url:
payload = self._get_json(url)
for item in payload.get("data", []):
projects.append(
{
"id": item["id"],
"name": item["attributes"]["name"],
"extension_type": item["attributes"]["extension"]["type"],
"root_folder_id": item["relationships"]["rootFolder"]["data"]["id"],
}
)
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
return projects
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
payload = self._get_json(
f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
)
return [self._entry(item) for item in payload.get("data", [])]
def list_folder_contents(
self,
project_id: str,
folder_id: str,
*,
object_types: list[str] | None = None,
extension_filter: Callable[[dict[str, Any]], bool] | None = None,
) -> list[dict[str, Any]]:
url = f"https://developer.api.autodesk.com/data/v1/projects/{project_id}/folders/{folder_id}/contents"
if object_types:
query = [("filter[type]", value) for value in object_types]
url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
entries: list[dict[str, Any]] = []
while url:
payload = self._get_json(url)
for item in payload.get("data", []):
entry = self._entry(item)
if extension_filter and entry["type"] == "items" and not extension_filter(entry):
continue
entries.append(entry)
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
return entries
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
"""Return the item plus its current tip in a single request.
``hidden`` reflects the item's soft-delete state (BIM 360 / ACC mark
deleted items as ``hidden: true``; the storage URL may still resolve
to a stale copy, so callers must check this before downloading).
"""
payload = self._get_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}"
f"/items/{urllib.parse.quote(item_id, safe='')}?include=tip"
)
item = payload["data"]
item_attributes = item.get("attributes", {})
parent_folder_id = self._relationship_id(item, "parent")
tip_id = self._relationship_id(item, "tip")
tip: dict[str, Any] | None = None
for included in payload.get("included", []):
if included.get("type") == "versions" and included.get("id") == tip_id:
tip = included
break
if tip is None:
return {
"id": item["id"],
"display_name": item_attributes.get("displayName")
or item_attributes.get("name")
or item["id"],
"hidden": True,
"version_id": None,
"storage_id": None,
"version_number": None,
"last_modified_time_utc": None,
"last_modified_user_name": None,
"parent_folder_id": parent_folder_id,
}
tip_attributes = tip.get("attributes", {})
return {
"id": item["id"],
"display_name": tip_attributes.get("displayName")
or tip_attributes.get("name")
or item_attributes.get("displayName")
or item["id"],
"hidden": bool(item_attributes.get("hidden", False)),
"version_id": tip["id"],
"storage_id": self._relationship_id(tip, "storage"),
"version_number": tip_attributes.get("versionNumber"),
"last_modified_time_utc": tip_attributes.get("lastModifiedTime"),
"last_modified_user_name": tip_attributes.get("lastModifiedUserName"),
"parent_folder_id": parent_folder_id,
}
# Download / upload --------------------------------------------------------
def download_storage_to_file(
self,
storage_id: str,
destination_path: Path,
*,
progress: Callable[[str, int | None], None] | None = None,
) -> None:
bucket_key, object_key = self._parse_storage_id(storage_id)
signed_url = self._get_signed_download_url(bucket_key, object_key)
self._download_to_file(signed_url, destination_path, progress)
def upload_file_to_folder(
self,
project_id: str,
folder_id: str,
local_path: Path,
*,
display_name: str | None = None,
progress: Callable[[str, int | None], None] | None = None,
) -> dict[str, Any]:
if not local_path.exists():
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
file_name = display_name or local_path.name
storage_id = self._create_storage(project_id, folder_id, file_name)
bucket_key, object_key = self._parse_storage_id(storage_id)
self._upload_local_file_to_oss(bucket_key, object_key, local_path, progress)
existing_item = self._find_item_in_folder(project_id, folder_id, file_name)
if existing_item is not None:
return self._create_version(project_id, existing_item["id"], file_name, storage_id)
return self._create_item(project_id, folder_id, file_name, storage_id)
# HTTP helpers -------------------------------------------------------------
def _get_json(self, url: str) -> dict[str, Any]:
token = self.auth.ensure_access_token()
try:
response = self.http.get(url, headers={"Authorization": f"Bearer {token}"})
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
token = self.auth.ensure_access_token()
try:
response = self.http.post(
url,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
json=payload,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _get_signed_download_url(self, bucket_key: str, object_key: str) -> str:
payload = self._get_json(
"https://developer.api.autodesk.com/oss/v2/buckets/"
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
f"{urllib.parse.quote(object_key, safe='')}/signeds3download"
)
url = payload.get("url")
if not isinstance(url, str) or not url:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Signed download URL response did not contain a URL.")
return url
def _download_to_file(
self,
url: str,
destination_path: Path,
progress: Callable[[str, int | None], None] | None,
) -> None:
try:
with self.http.stream("GET", url) as response:
response.raise_for_status()
total_bytes = None
header_value = response.headers.get("Content-Length")
if header_value and header_value.isdigit():
total_bytes = int(header_value)
downloaded_bytes = 0
with open(destination_path, "wb") as handle:
for chunk in response.iter_bytes():
handle.write(chunk)
downloaded_bytes += len(chunk)
if progress and total_bytes:
percent = min(100, int((downloaded_bytes / total_bytes) * 100))
progress(destination_path.name, percent)
elif progress:
progress(destination_path.name, None)
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _create_storage(self, project_id: str, folder_id: str, file_name: str) -> str:
payload = self._post_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/storage",
{
"jsonapi": {"version": "1.0"},
"data": {
"type": "objects",
"attributes": {"name": file_name},
"relationships": {"target": {"data": {"type": "folders", "id": folder_id}}},
},
},
)
storage_id = payload.get("data", {}).get("id")
if not isinstance(storage_id, str) or not storage_id:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Storage creation did not return an object id.")
return storage_id
def _upload_local_file_to_oss(
self,
bucket_key: str,
object_key: str,
local_path: Path,
progress: Callable[[str, int | None], None] | None,
) -> None:
file_size = local_path.stat().st_size
chunk_size = 5 * 1024 * 1024
total_parts = max(1, math.ceil(file_size / chunk_size))
upload_key: str | None = None
parts_uploaded = 0
bytes_uploaded = 0
with open(local_path, "rb") as handle:
while parts_uploaded < total_parts:
parts_to_request = min(total_parts - parts_uploaded, 5)
first_part = parts_uploaded + 1
signed = self._get_signed_upload_urls(
bucket_key,
object_key,
upload_key=upload_key,
first_part=first_part,
parts=parts_to_request,
)
if upload_key is None:
upload_key = signed.get("uploadKey")
urls = signed.get("urls", [])
if not isinstance(urls, list) or not urls:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload URL response did not contain upload URLs.")
for url in urls:
if parts_uploaded >= total_parts:
break
chunk = handle.read(chunk_size)
if not chunk:
break
self._put_bytes(str(url), chunk)
parts_uploaded += 1
bytes_uploaded += len(chunk)
if progress:
percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100))
progress(local_path.name, percent)
if not upload_key:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
self._complete_signed_upload(bucket_key, object_key, upload_key)
def _get_signed_upload_urls(
self,
bucket_key: str,
object_key: str,
*,
upload_key: str | None,
first_part: int,
parts: int,
) -> dict[str, Any]:
token = self.auth.ensure_access_token()
params: dict[str, Any] = {"minutesExpiration": 10, "firstPart": first_part, "parts": parts}
if upload_key:
params["uploadKey"] = upload_key
try:
response = self.http.get(
"https://developer.api.autodesk.com/oss/v2/buckets/"
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
headers={"Authorization": f"Bearer {token}"},
params=params,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _complete_signed_upload(self, bucket_key: str, object_key: str, upload_key: str) -> None:
token = self.auth.ensure_access_token()
try:
response = self.http.post(
"https://developer.api.autodesk.com/oss/v2/buckets/"
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"uploadKey": upload_key},
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _put_bytes(self, url: str, content: bytes) -> None:
try:
response = self.http.put(url, content=content, headers={"Content-Type": "application/octet-stream"})
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _find_item_in_folder(self, project_id: str, folder_id: str, file_name: str) -> dict[str, Any] | None:
children = self.list_folder_contents(project_id, folder_id, object_types=["items"])
return next(
(
child for child in children
if self._entry_name_matches(child, file_name)
),
None,
)
def _create_version(self, project_id: str, item_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
payload = self._post_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/versions",
{
"jsonapi": {"version": "1.0"},
"data": {
"type": "versions",
"attributes": {
"name": file_name,
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
},
"relationships": {
"item": {"data": {"type": "items", "id": item_id}},
"storage": {"data": {"type": "objects", "id": storage_id}},
},
},
},
)
version = payload["data"]
attributes = version.get("attributes", {})
return {
"item_id": item_id,
"version_id": version["id"],
"display_name": file_name,
"version_number": attributes.get("versionNumber"),
"last_modified_time_utc": attributes.get("lastModifiedTime"),
"last_modified_user_name": attributes.get("lastModifiedUserName"),
}
def _create_item(self, project_id: str, folder_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
payload = self._post_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/items",
{
"jsonapi": {"version": "1.0"},
"data": {
"type": "items",
"attributes": {
"displayName": file_name,
"extension": {"type": "items:autodesk.bim360:File", "version": "1.0"},
},
"relationships": {
"tip": {"data": {"type": "versions", "id": "1"}},
"parent": {"data": {"type": "folders", "id": folder_id}},
},
},
"included": [
{
"type": "versions",
"id": "1",
"attributes": {
"name": file_name,
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
},
"relationships": {"storage": {"data": {"type": "objects", "id": storage_id}}},
}
],
},
)
item = payload["data"]
version_id = "1"
version_number: Any = 1
last_modified_time: Any = None
last_modified_user: Any = None
for included in payload.get("included", []):
if included.get("type") == "versions":
version_id = included.get("id") or version_id
attributes = included.get("attributes", {})
version_number = attributes.get("versionNumber", version_number)
last_modified_time = attributes.get("lastModifiedTime")
last_modified_user = attributes.get("lastModifiedUserName")
break
return {
"item_id": item["id"],
"version_id": version_id,
"display_name": file_name,
"version_number": version_number,
"last_modified_time_utc": last_modified_time,
"last_modified_user_name": last_modified_user,
}
# Static helpers -----------------------------------------------------------
@staticmethod
def _parse_storage_id(storage_id: str) -> tuple[str, str]:
marker = "urn:adsk.objects:os.object:"
if not storage_id.startswith(marker):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Unsupported storage identifier '{storage_id}'.")
path = storage_id[len(marker):]
slash = path.find("/")
if slash <= 0 or slash == len(path) - 1:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Malformed storage identifier '{storage_id}'.")
return path[:slash], path[slash + 1 :]
@staticmethod
def _entry(item: dict[str, Any]) -> dict[str, Any]:
attributes = item["attributes"]
return {
"id": item["id"],
"type": item["type"],
"display_name": attributes.get("displayName") or attributes.get("name") or "",
"name": attributes.get("name"),
"extension_type": attributes.get("extension", {}).get("type", ""),
}
@staticmethod
def _relationship_id(data: dict[str, Any], name: str) -> str | None:
rel_data = data.get("relationships", {}).get(name, {}).get("data")
if isinstance(rel_data, dict):
rel_id = rel_data.get("id")
return rel_id if isinstance(rel_id, str) and rel_id else None
if isinstance(rel_data, list) and rel_data:
rel_id = rel_data[0].get("id")
return rel_id if isinstance(rel_id, str) and rel_id else None
return None
@staticmethod
def _entry_name_matches(entry: dict[str, Any], expected_name: str) -> bool:
display_name = str(entry.get("display_name") or "").lower()
raw_name = str(entry.get("name") or "").lower()
expected = expected_name.lower()
return display_name == expected or raw_name == expected
@@ -0,0 +1,62 @@
from __future__ import annotations
import hashlib
import json
import os
import platform
import shutil
from pathlib import Path
from typing import Any
def cache_root() -> Path:
system = platform.system()
if system == "Windows":
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
root = Path(base) / "ifcviewer-autodesk" / "Cache"
elif system == "Darwin":
root = Path.home() / "Library" / "Caches" / "ifcviewer-autodesk"
else:
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
root = Path(base) / "ifcviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
return root
def _short_hash(*parts: str) -> str:
joined = "\x1f".join(parts)
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
def ifcfed_dir(project_id: str, item_id: str) -> Path:
"""Stable directory for an .ifcfed. Re-downloads overwrite in place so the
viewer's open path remains valid across sync operations."""
return cache_root() / "ifcfeds" / _short_hash(project_id, item_id)
def model_dir(project_id: str, item_id: str, version_id: str) -> Path:
"""Per-version directory for a model. A new resolved version → a new
directory, satisfying the spec's invariant that sidecars regenerate when
the model file changes."""
return cache_root() / "models" / _short_hash(project_id, item_id, version_id)
def prepare_sole_child_dir(directory: Path) -> Path:
"""Clear the directory so the file we write is the only child."""
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True, exist_ok=True)
return directory
def write_manifest(ifcfed_path: Path, manifest: dict[str, Any]) -> Path:
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return manifest_path
def read_manifest(ifcfed_path: Path) -> dict[str, Any] | None:
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
if not manifest_path.exists():
return None
return json.loads(manifest_path.read_text(encoding="utf-8"))
@@ -0,0 +1,498 @@
from __future__ import annotations
import sys
import traceback
from pathlib import Path
from typing import Any, Callable
from ifcviewer_autodesk import cache, settings
from ifcviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, RpcError
from ifcviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename
ApsProgress = Callable[[str, "int | None"], None]
Report = Callable[[str, str, "int | None"], None]
def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress:
"""Adapt ProgressDialog.report (3-arg) to the APS download callback (2-arg).
index/total render "(i/N)" suffix when batching; pass 0 (the default) for
single-file downloads to omit the suffix.
"""
def cb(name: str, percent: int | None) -> None:
suffix = f" ({index}/{total})" if total else ""
report("download", f"Downloading {name}{suffix}", percent)
return cb
def _upload_callback(report: Report) -> ApsProgress:
def cb(name: str, percent: int | None) -> None:
report("upload", f"Uploading {name}", percent)
return cb
CONNECTOR_ID = "autodesk"
KEYRING_SERVICE = "ifcviewer-autodesk"
DEFAULT_CALLBACK_URL = "http://localhost:8080/"
DEFAULT_SCOPE = "data:read data:write data:create"
class AutodeskConnector:
def __init__(self) -> None:
self.auth: AuthSessionService | None = None
self.aps: ApsClient | None = None
self.reload_credentials()
def reload_credentials(self) -> None:
"""Rebuild auth + APS from current settings. Safe to call any time."""
client_id = settings.load_client_id()
if not client_id:
self.auth = None
self.aps = None
return
token_store = KeyringTokenStore(service_name=KEYRING_SERVICE, username=client_id)
self.auth = AuthSessionService(
client_id=client_id,
callback_url=DEFAULT_CALLBACK_URL,
scope=DEFAULT_SCOPE,
token_store=token_store,
)
self.aps = ApsClient(self.auth)
def _require_aps(self) -> tuple[AuthSessionService, ApsClient]:
if self.auth is None or self.aps is None:
raise RpcError(
JSONRPC_INTERNAL_ERROR,
"Autodesk client id is not configured. Open the connector settings to set it.",
)
return self.auth, self.aps
def handlers(self) -> dict[str, Any]:
return {
"pull_ifcfed_interactive": self.pull_ifcfed_interactive,
"pull_ifcfed": self.pull_ifcfed,
"pull_models": self.pull_models,
"pull_models_interactive": self.pull_models_interactive,
"push_ifcfed_interactive": self.push_ifcfed_interactive,
"push_ifcfed": self.push_ifcfed,
"push_model_interactive": self.push_model_interactive,
"push_model": self.push_model,
"open_settings": self.open_settings,
}
# ---- open_settings ------------------------------------------------------
def open_settings(self, _params: Any) -> dict[str, Any]:
SettingsDialog(connector=self).run()
return {}
# ---- pull_ifcfed_interactive --------------------------------------------
def pull_ifcfed_interactive(self, _params: Any) -> dict[str, Any]:
auth, aps = self._require_aps()
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
hub = chosen["hub"]
project = chosen["project"]
entry = chosen["entry"]
with progress_dialog("Downloading project") as report:
path = self._download_ifcfed(
aps=aps,
hub_id=hub["id"],
project_id=project["id"],
item_id=entry["id"],
display_name=entry["display_name"],
progress=_download_callback(report),
)
return {"path": str(path)}
# ---- pull_ifcfed --------------------------------------------------------
def pull_ifcfed(self, params: Any) -> dict[str, Any]:
_, aps = self._require_aps()
manifest = _require_object(params, "params")
hub_id = _require_string(manifest, "hub_id")
project_id = _require_string(manifest, "project_id")
item_id = _require_string(manifest, "item_id")
display_name = manifest.get("display_name") or item_id
with progress_dialog("Downloading project") as report:
path = self._download_ifcfed(
aps=aps,
hub_id=hub_id,
project_id=project_id,
item_id=item_id,
display_name=display_name,
progress=_download_callback(report),
)
return {"path": str(path)}
def _download_ifcfed(
self,
*,
aps: ApsClient,
hub_id: str,
project_id: str,
item_id: str,
display_name: str,
progress: ApsProgress | None = None,
) -> Path:
item = aps.get_item(project_id, item_id)
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
storage_id = item["storage_id"]
if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
file_name = item["display_name"] or display_name or item_id
if not file_name.lower().endswith(".ifcfed"):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Item '{file_name}' is not an .ifcfed file.")
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, item_id))
ifcfed_path = directory / file_name
aps.download_storage_to_file(storage_id, ifcfed_path, progress=progress)
cache.write_manifest(
ifcfed_path,
{
"connector": CONNECTOR_ID,
"hub_id": hub_id,
"project_id": project_id,
"item_id": item_id,
"display_name": file_name,
},
)
return ifcfed_path
# ---- pull_models --------------------------------------------------------
def pull_models(self, params: Any) -> list[dict[str, Any] | None]:
_, aps = self._require_aps()
models = _require_array(params, "params")
results: list[dict[str, Any] | None] = []
total = len(models)
with progress_dialog("Downloading models") as report:
for index, model in enumerate(models):
callback = _download_callback(report, index=index + 1, total=total)
try:
results.append(self._resolve_model(aps, model, progress=callback))
except RpcError as exc:
print(f"pull_models[{index}] skipped: {exc.message}", file=sys.stderr)
results.append(None)
except Exception as exc:
print(f"pull_models[{index}] skipped: {exc}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
results.append(None)
return results
def _resolve_model(
self,
aps: ApsClient,
model: Any,
*,
progress: ApsProgress | None = None,
) -> dict[str, Any] | None:
if not isinstance(model, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must be an object.")
source = model.get("source")
if not isinstance(source, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must have a 'source' object.")
if source.get("connector") != CONNECTOR_ID:
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
project_id = _require_string(source, "project_id")
item_id = _require_string(source, "item_id")
display_name_hint = model.get("display_name") or item_id
item = aps.get_item(project_id, item_id)
if item["hidden"]:
print(f"Autodesk item '{item_id}' is hidden/deleted; returning null.", file=sys.stderr)
return None
storage_id = item["storage_id"]
if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
file_name = item["display_name"] or display_name_hint
version_id = item["version_id"]
directory = cache.model_dir(project_id, item_id, version_id)
model_path = directory / file_name
if not model_path.exists():
cache.prepare_sole_child_dir(directory)
aps.download_storage_to_file(storage_id, model_path, progress=progress)
return {
"path": str(model_path),
"metadata": _build_metadata(item),
}
# ---- pull_models_interactive --------------------------------------------
def pull_models_interactive(self, _params: Any) -> list[dict[str, Any]]:
auth, aps = self._require_aps()
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
hub = chosen["hub"]
project = chosen["project"]
entry = chosen["entry"]
item = aps.get_item(project["id"], entry["id"])
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, "The selected Autodesk item has been deleted.")
storage_id = item["storage_id"]
if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.")
file_name = item["display_name"] or entry["display_name"] or entry["id"]
version_id = item["version_id"]
directory = cache.model_dir(project["id"], entry["id"], version_id)
model_path = directory / file_name
if not model_path.exists():
cache.prepare_sole_child_dir(directory)
with progress_dialog("Downloading model") as report:
aps.download_storage_to_file(
storage_id, model_path, progress=_download_callback(report)
)
return [
{
"display_name": file_name,
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": entry["id"],
},
"path": str(model_path),
"metadata": _build_metadata(item),
}
]
# ---- push_ifcfed_interactive --------------------------------------------
def push_ifcfed_interactive(self, params: Any) -> dict[str, Any]:
auth, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
if not local_path.name.lower().endswith(".ifcfed"):
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed_interactive expects an .ifcfed file.")
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"]
project = chosen["project"]
folder = chosen["entry"]
file_name = prompt_for_filename(
title="Save Project",
label="Save .ifcfed as:",
default=local_path.name,
)
if not file_name:
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
if not file_name.lower().endswith(".ifcfed"):
file_name = file_name + ".ifcfed"
with progress_dialog("Uploading project") as report:
uploaded = aps.upload_file_to_folder(
project["id"],
folder["id"],
local_path,
display_name=file_name,
progress=_upload_callback(report),
)
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project["id"], uploaded["item_id"]))
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
cache.write_manifest(
cached_path,
{
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": uploaded["item_id"],
"display_name": file_name,
},
)
return {"path": str(cached_path)}
# ---- push_ifcfed --------------------------------------------------------
def push_ifcfed(self, params: Any) -> dict[str, Any]:
_, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
if not local_path.name.lower().endswith(".ifcfed"):
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed expects an .ifcfed file.")
manifest = params_obj.get("manifest")
if not isinstance(manifest, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "'manifest' must be a JSON object.")
if manifest.get("connector") != CONNECTOR_ID:
raise RpcError(JSONRPC_INVALID_PARAMS, f"Manifest connector is not '{CONNECTOR_ID}'.")
hub_id = _require_string(manifest, "hub_id")
project_id = _require_string(manifest, "project_id")
item_id = _require_string(manifest, "item_id")
item = aps.get_item(project_id, item_id)
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
folder_id = item.get("parent_folder_id")
if not isinstance(folder_id, str) or not folder_id:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
file_name = manifest.get("display_name") or item.get("display_name") or local_path.name
with progress_dialog("Uploading project") as report:
uploaded = aps.upload_file_to_folder(
project_id,
folder_id,
local_path,
display_name=file_name,
progress=_upload_callback(report),
)
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, uploaded["item_id"]))
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
cache.write_manifest(
cached_path,
{
"connector": CONNECTOR_ID,
"hub_id": hub_id,
"project_id": project_id,
"item_id": uploaded["item_id"],
"display_name": file_name,
},
)
return {"path": str(cached_path)}
# ---- push_model_interactive ---------------------------------------------
def push_model_interactive(self, params: Any) -> dict[str, Any]:
auth, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"]
project = chosen["project"]
folder = chosen["entry"]
file_name = prompt_for_filename(
title="Save Model",
label="Save model as:",
default=local_path.name,
)
if not file_name:
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
with progress_dialog("Uploading model") as report:
uploaded = aps.upload_file_to_folder(
project["id"],
folder["id"],
local_path,
display_name=file_name,
progress=_upload_callback(report),
)
directory = cache.model_dir(project["id"], uploaded["item_id"], uploaded["version_id"])
cache.prepare_sole_child_dir(directory)
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
return {
"display_name": file_name,
"path": str(cached_path),
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": uploaded["item_id"],
},
"metadata": _build_metadata(uploaded),
}
# ---- push_model ---------------------------------------------------------
def push_model(self, params: Any) -> dict[str, Any]:
_, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
source = params_obj.get("source")
if not isinstance(source, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "'source' must be a JSON object.")
if source.get("connector") != CONNECTOR_ID:
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
hub_id = _require_string(source, "hub_id")
project_id = _require_string(source, "project_id")
item_id = _require_string(source, "item_id")
item = aps.get_item(project_id, item_id)
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
folder_id = item.get("parent_folder_id")
if not isinstance(folder_id, str) or not folder_id:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
file_name = item.get("display_name") or local_path.name
with progress_dialog("Uploading model") as report:
uploaded = aps.upload_file_to_folder(
project_id,
folder_id,
local_path,
display_name=file_name,
progress=_upload_callback(report),
)
directory = cache.model_dir(project_id, uploaded["item_id"], uploaded["version_id"])
cache.prepare_sole_child_dir(directory)
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
return {
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub_id,
"project_id": project_id,
"item_id": uploaded["item_id"],
},
"metadata": _build_metadata(uploaded),
}
def _require_object(params: Any, name: str) -> dict[str, Any]:
if not isinstance(params, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON object.")
return params
def _require_array(params: Any, name: str) -> list[Any]:
if not isinstance(params, list):
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON array.")
return params
def _require_string(obj: dict[str, Any], key: str) -> str:
value = obj.get(key)
if not isinstance(value, str) or not value.strip():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Missing required string field '{key}'.")
return value
def _build_metadata(version_info: dict[str, Any]) -> dict[str, Any]:
metadata: dict[str, Any] = {}
version_number = version_info.get("version_number")
if version_number is not None:
metadata["revision"] = f"v{version_number}"
last_modified = version_info.get("last_modified_time_utc")
if isinstance(last_modified, str) and last_modified:
metadata["date"] = last_modified
author = version_info.get("last_modified_user_name")
if isinstance(author, str) and author:
metadata["author"] = author
return metadata
@@ -0,0 +1,111 @@
from __future__ import annotations
import json
import sys
import traceback
from typing import Any, Callable, TextIO
JSONRPC_PARSE_ERROR = -32700
JSONRPC_INVALID_REQUEST = -32600
JSONRPC_METHOD_NOT_FOUND = -32601
JSONRPC_INVALID_PARAMS = -32602
JSONRPC_INTERNAL_ERROR = -32603
class RpcError(Exception):
def __init__(self, code: int, message: str, data: Any | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.data = data
Handler = Callable[[Any], Any]
class JsonRpcHost:
def __init__(
self,
handlers: dict[str, Handler],
*,
stdin: TextIO = sys.stdin,
stdout: TextIO = sys.stdout,
stderr: TextIO = sys.stderr,
) -> None:
self.handlers = handlers
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
def run(self) -> int:
for line in self.stdin:
line = line.strip()
if not line:
continue
self._handle_line(line)
return 0
def _handle_line(self, line: str) -> None:
message_id: Any = None
try:
try:
message = json.loads(line)
except json.JSONDecodeError as exc:
self._respond_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {exc}")
return
if not isinstance(message, dict):
self._respond_error(None, JSONRPC_INVALID_REQUEST, "Request must be a JSON object")
return
if message.get("jsonrpc") != "2.0":
self._respond_error(message.get("id"), JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version")
return
message_id = message.get("id")
method = message.get("method")
if not isinstance(method, str):
self._respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string")
return
params = message.get("params", None)
if params is not None and not isinstance(params, (dict, list)):
self._respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array")
return
handler = self.handlers.get(method)
if handler is None:
self._respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, f"Unknown method '{method}'")
return
try:
result = handler(params)
except RpcError as exc:
self._respond_error(message_id, exc.code, exc.message, exc.data)
return
except Exception as exc:
print(f"Handler '{method}' raised: {exc}", file=self.stderr)
traceback.print_exc(file=self.stderr)
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
return
if message_id is not None:
self._respond_result(message_id, result)
except Exception as exc:
print(f"Unhandled host error: {exc}", file=self.stderr)
traceback.print_exc(file=self.stderr)
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
def _respond_result(self, message_id: Any, result: Any) -> None:
self._write({"jsonrpc": "2.0", "id": message_id, "result": result})
def _respond_error(self, message_id: Any, code: int, message: str, data: Any | None = None) -> None:
error: dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
self._write({"jsonrpc": "2.0", "id": message_id, "error": error})
def _write(self, payload: dict[str, Any]) -> None:
line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
self.stdout.write(line + "\n")
self.stdout.flush()
@@ -0,0 +1,61 @@
from __future__ import annotations
import json
import os
import platform
from pathlib import Path
from typing import Any
def config_root() -> Path:
system = platform.system()
if system == "Windows":
base = os.environ.get("APPDATA") or os.path.expanduser("~")
root = Path(base) / "ifcviewer-autodesk"
elif system == "Darwin":
root = Path.home() / "Library" / "Application Support" / "ifcviewer-autodesk"
else:
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
root = Path(base) / "ifcviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
return root
def _settings_path() -> Path:
return config_root() / "settings.json"
def _read() -> dict[str, Any]:
path = _settings_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return data if isinstance(data, dict) else {}
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()
def save_client_id(client_id: str) -> None:
data = _read()
data["client_id"] = client_id.strip()
_write(data)
@@ -0,0 +1,637 @@
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from typing import TYPE_CHECKING, Any, Callable, Literal
import customtkinter as ctk
from ifcviewer_autodesk import settings
from ifcviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
if TYPE_CHECKING:
from ifcviewer_autodesk.connector import AutodeskConnector
MODEL_EXTENSIONS = (".ifc", ".ifcview", ".rdb", ".rdbview")
Mode = Literal["ifcfed", "model", "destination"]
# --- root + Treeview style ---------------------------------------------------
# Tk's default ttk.Treeview looks like Windows 95 in any theme; force-style it
# to match the surrounding CTk dark theme. Every other widget uses CTk defaults.
_root: ctk.CTk | None = None
def ensure_tk_app() -> ctk.CTk:
global _root
if _root is None:
ctk.set_appearance_mode("Dark")
ctk.set_default_color_theme("blue")
_root = ctk.CTk()
_root.withdraw()
_apply_treeview_style()
return _root
def _apply_treeview_style() -> None:
style = ttk.Style()
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure(
"Treeview",
background="#2b2b2b",
foreground="#dce4ee",
fieldbackground="#2b2b2b",
borderwidth=0,
rowheight=26,
)
style.map(
"Treeview",
background=[("selected", "#1f6aa5")],
foreground=[("selected", "#ffffff")],
)
style.layout("Treeview", [("Treeview.treearea", {"sticky": "nswe"})])
# --- base modal --------------------------------------------------------------
class _BaseDialog(ctk.CTkToplevel):
def __init__(self, title: str, *, size: tuple[int, int], resizable: bool = True) -> None:
super().__init__(ensure_tk_app())
self.title(title)
self.geometry(f"{size[0]}x{size[1]}")
if not resizable:
self.resizable(False, False)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.result: Any = None
self.withdraw()
def _on_close(self) -> None:
try:
self.grab_release()
except tk.TclError:
pass
self.destroy()
def _center_on_screen(self) -> None:
self.update_idletasks()
w = self.winfo_width()
h = self.winfo_height()
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"+{x}+{y}")
def run(self) -> Any:
root = ensure_tk_app()
self._center_on_screen()
self.deiconify()
self.lift()
self.focus_force()
try:
self.grab_set()
except tk.TclError:
pass
self.wait_window()
try:
root.update()
root.update_idletasks()
except tk.TclError:
pass
return self.result
# --- progress ----------------------------------------------------------------
class ProgressDialog(_BaseDialog):
def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None:
super().__init__(title, size=(440, 130), resizable=False)
body = ctk.CTkFrame(self)
body.pack(fill="both", expand=True, padx=20, pady=20)
self.message = ctk.CTkLabel(body, text="Working…", anchor="w")
self.message.pack(fill="x", anchor="w")
self.bar = ctk.CTkProgressBar(body, mode="indeterminate")
self.bar.pack(fill="x", pady=(12, 0))
self.bar.start()
self._determinate = False
self._center_on_screen()
self.deiconify()
self.lift()
self.update()
def report(self, _phase: str, message: str, percent: int | None = None) -> None:
try:
self.message.configure(text=message)
if percent is None:
if self._determinate:
self.bar.configure(mode="indeterminate")
self.bar.start()
self._determinate = False
else:
if not self._determinate:
self.bar.stop()
self.bar.configure(mode="determinate")
self._determinate = True
self.bar.set(max(0.0, min(1.0, percent / 100.0)))
self.update()
except tk.TclError:
pass
class _ProgressContext:
def __init__(self, parent: tk.Misc | None, message: str) -> None:
self.parent = parent
self.message = message
self.dialog: ProgressDialog | None = None
def __enter__(self) -> Callable[[str, str, int | None], None]:
self.dialog = ProgressDialog(self.message, self.parent)
return self.dialog.report
def __exit__(self, *_exc: object) -> None:
if self.dialog is not None:
try:
self.dialog.destroy()
except tk.TclError:
pass
self.dialog = None
def progress_dialog(message: str) -> _ProgressContext:
"""Standalone progress dialog usable outside the browse picker."""
return _ProgressContext(None, message)
# --- browse ------------------------------------------------------------------
class BrowseDialog(_BaseDialog):
"""Hub → project → folder tree → file/folder picker."""
def __init__(self, *, auth: AuthSessionService, aps: ApsClient, mode: Mode) -> None:
titles = {
"ifcfed": ("Open Project From Autodesk", "Open"),
"model": ("Add Model From Autodesk", "Add"),
"destination": ("Choose Autodesk Destination", "Select"),
}
title, action_label = titles[mode]
super().__init__(title, size=(920, 620))
self.auth = auth
self.aps = aps
self.mode: Mode = mode
self.selected_hub: dict[str, Any] | None = None
self.selected_project: dict[str, Any] | None = None
self.selected_entry: dict[str, Any] | None = None
self._tree_entries: dict[str, dict[str, Any]] = {}
self._project_entries: dict[str, dict[str, Any]] = {}
self._build_ui(action_label)
def _build_ui(self, action_label: str) -> None:
root = ctk.CTkFrame(self, fg_color="transparent")
root.pack(fill="both", expand=True, padx=16, pady=16)
root.grid_rowconfigure(1, weight=1)
root.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(root, fg_color="transparent")
top.grid(row=0, column=0, sticky="ew", pady=(0, 12))
top.grid_columnconfigure(1, weight=1)
self.sign_in_button = ctk.CTkButton(top, text="Sign In", command=self._sign_in)
self.sign_in_button.grid(row=0, column=0, padx=(0, 8), sticky="w")
self.hub_combo = ctk.CTkOptionMenu(
top,
values=["Select hub"],
command=self._hub_changed,
anchor="w",
)
self.hub_combo.grid(row=0, column=1, sticky="ew")
self.hub_combo.configure(state="disabled")
split = ctk.CTkFrame(root, fg_color="transparent")
split.grid(row=1, column=0, sticky="nsew")
split.grid_rowconfigure(0, weight=1)
split.grid_columnconfigure(0, weight=3, uniform="col")
split.grid_columnconfigure(1, weight=7, uniform="col")
self.projects_frame = ctk.CTkFrame(split)
self.projects_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
self.tree_frame = ctk.CTkFrame(split)
self.tree_frame.grid(row=0, column=1, sticky="nsew")
self.projects = self._make_treeview(self.projects_frame, "PROJECTS")
self.projects.bind("<<TreeviewSelect>>", lambda _e: self._project_changed())
self.tree = self._make_treeview(self.tree_frame, "FOLDERS")
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
self.status = ctk.CTkLabel(root, text="Sign in to browse Autodesk projects.", anchor="w")
self.status.grid(row=2, column=0, sticky="ew", pady=(12, 12))
actions = ctk.CTkFrame(root, fg_color="transparent")
actions.grid(row=3, column=0, sticky="ew")
actions.grid_columnconfigure(0, weight=1)
self.cancel_button = ctk.CTkButton(actions, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1)
self.cancel_button.grid(row=0, column=1, padx=(0, 8))
self.action_button = ctk.CTkButton(actions, text=action_label, command=self._confirm)
self.action_button.grid(row=0, column=2)
self.action_button.configure(state="disabled")
def _make_treeview(self, parent: ctk.CTkFrame, header: str) -> ttk.Treeview:
ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
body = ctk.CTkFrame(parent, fg_color="transparent")
body.pack(fill="both", expand=True, padx=8, pady=8)
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=1)
tree = ttk.Treeview(body, show="tree", selectmode="browse")
tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
tree.configure(yscrollcommand=scrollbar.set)
return tree
# --- sign-in & population ------------------------------------------------
def run(self) -> dict[str, Any]:
if self.auth.get_token() is not None:
self._populate_hubs_silently()
outcome = super().run()
if outcome is None:
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled the Autodesk picker.")
return outcome
def _populate_hubs_silently(self) -> None:
try:
hubs = self.aps.list_hubs()
self._fill_hubs(hubs)
self.status.configure(text="Signed in. Select a hub.")
except Exception:
pass
def _sign_in(self) -> None:
with self._with_progress("Signing in to Autodesk") as report:
try:
self.auth.login_interactive(report)
hubs = self.aps.list_hubs()
self._fill_hubs(hubs)
self.status.configure(text="Signed in. Select a hub.")
except Exception as exc:
show_error(title="Sign In Failed", message=str(exc))
def _fill_hubs(self, hubs: list[dict[str, Any]]) -> None:
self._hubs_by_name = {hub["name"]: hub for hub in hubs}
values = ["Select hub"] + list(self._hubs_by_name.keys())
self.hub_combo.configure(values=values, state="normal")
self.hub_combo.set("Select hub")
def _hub_changed(self, label: str) -> None:
if label == "Select hub":
return
hub = getattr(self, "_hubs_by_name", {}).get(label)
if not isinstance(hub, dict):
return
self.selected_hub = hub
self.selected_project = None
self.selected_entry = None
self._clear_projects()
self._clear_tree()
self._refresh_action_button()
with self._with_progress("Loading Autodesk projects"):
try:
projects = self.aps.list_projects(hub["id"])
self._project_entries = {}
for project in projects:
iid = self.projects.insert("", "end", text=project["name"])
self._project_entries[iid] = project
self.status.configure(text=f"Hub: {hub['name']}. Select a project.")
except Exception as exc:
show_error(title="Load Projects Failed", message=str(exc))
def _project_changed(self) -> None:
selection = self.projects.selection()
if not selection or self.selected_hub is None:
return
project = self._project_entries.get(selection[0])
if not isinstance(project, dict):
return
self.selected_project = project
self.selected_entry = None
self._refresh_action_button()
self._clear_tree()
with self._with_progress("Loading top folders"):
try:
top_folders = self.aps.list_top_folders(self.selected_hub["id"], project["id"])
for entry in top_folders:
self._insert_tree_entry("", entry)
if self.mode == "destination":
self.status.configure(text=f"Project: {project['name']}. Browse folders and choose a destination.")
else:
self.status.configure(text=f"Project: {project['name']}. Browse folders and pick a file.")
except Exception as exc:
show_error(title="Load Project Failed", message=str(exc))
def _insert_tree_entry(self, parent_iid: str, entry: dict[str, Any]) -> str:
label = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
iid = self.tree.insert(parent_iid, "end", text=label)
self._tree_entries[iid] = entry
if entry.get("type") == "folders":
placeholder = self.tree.insert(iid, "end", text="Loading…")
self._tree_entries[placeholder] = {"__placeholder__": True}
return iid
def _on_tree_open(self, _event: tk.Event) -> None:
selection = self.tree.focus()
if not selection:
return
entry = self._tree_entries.get(selection)
if not isinstance(entry, dict) or entry.get("type") != "folders" or self.selected_project is None:
return
children = self.tree.get_children(selection)
if len(children) != 1:
return
only = self._tree_entries.get(children[0])
if not (isinstance(only, dict) and only.get("__placeholder__")):
return
self.tree.delete(children[0])
self._tree_entries.pop(children[0], None)
with self._with_progress("Loading folder contents"):
try:
object_types = ["folders"] if self.mode == "destination" else ["folders", "items"]
contents = self.aps.list_folder_contents(
self.selected_project["id"],
entry["id"],
object_types=object_types,
extension_filter=self._extension_filter(),
)
for child in contents:
self._insert_tree_entry(selection, child)
except Exception as exc:
show_error(title="Load Folder Failed", message=str(exc))
def _extension_filter(self) -> Callable[[dict[str, Any]], bool] | None:
if self.mode == "ifcfed":
return lambda entry: (entry.get("display_name") or "").lower().endswith(".ifcfed")
if self.mode == "model":
return lambda entry: (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
return None
def _tree_selection_changed(self) -> None:
selection = self.tree.selection()
entry = self._tree_entries.get(selection[0]) if selection else None
if isinstance(entry, dict) and not entry.get("__placeholder__"):
self.selected_entry = entry
name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
kind = entry.get("type", "entry")
self.status.configure(text=f"Selected {kind}: {name}")
else:
self.selected_entry = None
self._refresh_action_button()
def _refresh_action_button(self) -> None:
enabled = self.selected_project is not None and self.selected_entry is not None
if enabled:
assert self.selected_entry is not None
if self.mode == "destination":
enabled = self.selected_entry.get("type") == "folders"
elif self.mode == "ifcfed":
enabled = (
self.selected_entry.get("type") == "items"
and (self.selected_entry.get("display_name") or "").lower().endswith(".ifcfed")
)
else:
enabled = (
self.selected_entry.get("type") == "items"
and (self.selected_entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
)
self.action_button.configure(state="normal" if enabled else "disabled")
def _confirm(self) -> None:
if not self.selected_hub or not self.selected_project or not self.selected_entry:
return
self.result = {
"hub": self.selected_hub,
"project": self.selected_project,
"entry": self.selected_entry,
}
self._on_close()
def _clear_projects(self) -> None:
for iid in self.projects.get_children():
self.projects.delete(iid)
self._project_entries.clear()
def _clear_tree(self) -> None:
for iid in self.tree.get_children():
self.tree.delete(iid)
self._tree_entries.clear()
def _with_progress(self, message: str) -> _ProgressContext:
return _ProgressContext(self, message)
# --- filename prompt ---------------------------------------------------------
class _FilenamePrompt(_BaseDialog):
def __init__(self, *, title: str, label: str, default: str) -> None:
super().__init__(title, size=(440, 170), resizable=False)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=20, pady=20)
ctk.CTkLabel(body, text=label, anchor="w").pack(fill="x")
self.entry = ctk.CTkEntry(body)
self.entry.pack(fill="x", pady=(8, 16))
self.entry.insert(0, default)
self.entry.select_range(0, "end")
self.entry.focus_set()
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(0, weight=1)
ctk.CTkButton(buttons, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
ctk.CTkButton(buttons, text="OK", command=self._confirm).grid(row=0, column=2)
self.bind("<Return>", lambda _e: self._confirm())
self.bind("<Escape>", lambda _e: self._on_close())
def _confirm(self) -> None:
value = self.entry.get().strip()
self.result = value or None
self._on_close()
def prompt_for_filename(*, title: str, label: str, default: str) -> str | None:
return _FilenamePrompt(title=title, label=label, default=default).run()
# --- message dialogs (CTk-styled replacements for tkinter.messagebox) --------
class _ConfirmDialog(_BaseDialog):
def __init__(self, *, title: str, message: str) -> None:
super().__init__(title, size=(440, 180), resizable=False)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=20, pady=20)
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(0, weight=1)
ctk.CTkButton(buttons, text="No", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
ctk.CTkButton(buttons, text="Yes", command=self._confirm).grid(row=0, column=2)
self.bind("<Return>", lambda _e: self._confirm())
self.bind("<Escape>", lambda _e: self._on_close())
def _confirm(self) -> None:
self.result = True
self._on_close()
class _AlertDialog(_BaseDialog):
def __init__(self, *, title: str, message: str) -> None:
super().__init__(title, size=(440, 180), resizable=False)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=20, pady=20)
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(0, weight=1)
ctk.CTkButton(buttons, text="OK", command=self._on_close).grid(row=0, column=1)
self.bind("<Return>", lambda _e: self._on_close())
self.bind("<Escape>", lambda _e: self._on_close())
def confirm(*, title: str, message: str) -> bool:
return bool(_ConfirmDialog(title=title, message=message).run())
def show_error(*, title: str, message: str) -> None:
_AlertDialog(title=title, message=message).run()
# --- settings ----------------------------------------------------------------
class SettingsDialog(_BaseDialog):
"""Edit the APS client id and sign out."""
def __init__(self, *, connector: "AutodeskConnector") -> None:
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
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=24, pady=24)
ctk.CTkLabel(
body,
text="Autodesk Platform Services",
anchor="w",
font=ctk.CTkFont(size=14, weight="bold"),
).pack(fill="x")
ctk.CTkLabel(
body,
text="The connector signs in to Autodesk using a PKCE flow. The client id below comes from your APS application.",
anchor="w",
wraplength=460,
justify="left",
).pack(fill="x", pady=(2, 16))
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.status_label = ctk.CTkLabel(
body,
text=f"Signed in as {effective}" if effective else "No client id configured.",
anchor="w",
wraplength=460,
justify="left",
)
self.status_label.pack(fill="x", pady=(0, 16))
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(1, weight=1)
self.signout_button = ctk.CTkButton(
buttons,
text="Sign Out",
command=self._sign_out,
fg_color="transparent",
border_width=1,
)
self.signout_button.grid(row=0, column=0, sticky="w")
if not effective:
self.signout_button.configure(state="disabled")
ctk.CTkButton(
buttons,
text="Close",
command=self._on_close,
fg_color="transparent",
border_width=1,
).grid(row=0, column=2, padx=(0, 8))
ctk.CTkButton(buttons, text="Save", command=self._save).grid(row=0, column=3)
def _save(self) -> None:
new_id = self.client_id_entry.get().strip()
settings.save_client_id(new_id)
try:
self.connector.reload_credentials()
except Exception as exc:
show_error(title="Reload Failed", message=str(exc))
return
self._on_close()
def _sign_out(self) -> None:
client_id = settings.env_client_id() or settings.stored_client_id()
if not client_id:
return
if not confirm(
title="Sign Out",
message=f"Forget the stored Autodesk session for {client_id}?",
):
return
try:
KeyringTokenStore(service_name="ifcviewer-autodesk", username=client_id).delete()
except RpcError as exc:
show_error(title="Sign Out Failed", message=exc.message)
return
self.status_label.configure(text="Signed out. Next operation will prompt for sign-in.")
self.signout_button.configure(state="disabled")