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
+4
View File
@@ -128,3 +128,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
*.claude
*.py.tmp*
*.json.tmp*
# ifcviewer-autodesk connector build artifacts
/src/ifcviewer-autodesk/build/
/src/ifcviewer-autodesk/dist/
@@ -0,0 +1,504 @@
# IfcViewer cloud connectors
IfcViewer will have the capability to load and save projects and models from a
cloud platform. Later on, there will be other resources stored on cloud
platforms too, such as issues, clash results, and so on, but this behaviour is
not currently designed.
Due to the variety of cloud platforms, the IfcViewer itself will depend on
a "connector" to integrate with each platform. The connector is a separate
application which will communicate to and from the IfcViewer.
The following types of resources may be managed with a connector:
- Projects (.ifcfed)
- Models (.ifc, .rdb, .ifcview, .rdbview)
- Issues (.bcf, not yet supported nor defined)
- Specifications (.ids, not yet supported nor defined)
## Communication protocol
The IfcViewer launches one connector process per session, on first use, and
keeps it alive for the duration of the session. This allows the connector to
maintain authentication tokens, browse state, in-flight downloads, and caches
in memory across calls without re-authenticating on every request.
Communication is over stdio using newline-delimited JSON-RPC 2.0:
- Requests and responses are single-line JSON objects on the connector's
stdin/stdout. Each message is terminated by a single `\n`.
- The connector must not emit literal newlines inside a JSON message.
- The connector may write arbitrary diagnostic output to stderr; the IfcViewer
will not parse it.
The IfcViewer shuts a connector down by closing its stdin. The connector should
exit cleanly. If it does not exit within a few seconds, the IfcViewer will
terminate it.
## Connector scope
The IfcViewer has minimal knowledge about connectors. IfcViewer only knows how
to work with local files. If it detects that a project or model is not local,
it will invoke a connector. The connector's job is to resolve the IfcViewer's
request back into a local file and cloud metadata. The connector must not
modify the file in any way.
A connector will handle anything necessary for the cloud platform (or arbitrary
data source). This includes authentication, browsing files, filters and
searches, selecting or pinning revisions, progress bars, cache,
platform-specific requirements, etc. The connector may or may not display a UI.
This makes connectors very flexible.
A single project may have different resources coming from different connectors.
For example, some models might be on one platform, and some projects hosted on
another platform. The permissions regarding model access can be quite granular
and therefore managed by the platform, and not IfcViewer.
When a connector returns a local file, that file is required to be the sole
child in its directory. This is because there may be adjacent temporary files,
viewer-generated sidecar files, database locks or helpers (e.g. SQLite WAL), or
where filenames are significant (and cannot be renamed to prevent collisions),
or are actually directories containing other files.
The connector is expected to persist this cache until explicitly cleared by a
user, because it will be used directly as a local path by the viewer. If a
connector invalidates a cache, it can simply delete the entire directory. If a
connector resolves to a new version of the file, it can create a fresh
directory (thus all sidecar artefacts will be regenerated if needed). It is not
prescribed how a connector manages cache.
## Resource: Projects
A project is defined using an `.ifcfed` file. The file stores settings (such as
units, home view coordinates, saved searches, etc) and models.
For example:
```json
# project.ifcfed
{
"created": "2026-04-29T21:22:36Z",
"modified": "2026-04-29T21:22:36Z",
"home_view": null,
"models": ... # see Resources: Models,
...
}
```
A project may have a manifest file (with `.manifest` as a suffix), which may
store metadata that a ifcfed was retrieved from a cloud source.
```json
# project.ifcfed.manifest
{
"connector": "mycompany",
# Arbitrary connector-specific data
"version": "2",
"url": "http://example.com/project.ifcfed",
}
```
## Resource: Models
The list of models is defined in the .ifcfed. Each model may either point to a
local file (via the special "local" connector), or to a cloud file.
Cloud connections may store arbitrary source data as keys. For example, they
might store a revision policy that determines whether the model is pinned to a
particular revision or must always be the latest. This is completely up to the
connector.
Here is an example of how models might be stored in an .ifcfed:
```json
[
{
"display_name": "foo.ifc",
"id": "0503642e-e2f6-4700-87fd-16479542e801",
"source": {
"connector": "local",
"path": "path/to/foo.ifc" # Only for local
},
},
{
"display_name": "bar.ifc",
"id": "5fc69e6a-1ff0-4d8a-82c6-2215df53d2ed",
"source": {
"connector": "autodesk",
# Below is arbitrary data depending on the connector
"version": "1",
"hub_id": "b.hub123",
"project_id": "b.project456",
"item_id": "urn:adsk.wipprod:dm.lineage:abc",
"version_id": "urn:adsk.wipprod:fs.file:vf.xyz?version=3"
},
},
]
```
Note that cloud metadata (filename, cloud ID, revision, date modified, etc) is
not specified nor stored in the .ifcfed. This is to be returned by the
connector when requested.
The IfcViewer will display all returned cloud metadata as simple text strings.
However some keys are treated specially and shown in more places in the
IfcViewer UI for convenience:
- author
- revision
- date
## Open from cloud workflow
This opens a .ifcfed from a cloud platform and constitutes a fresh session.
Note that downloading the latest versions of all models immediately upon open
is not required. At a minimum, only the .ifcfed needs to be opened. It is
perfectly acceptable to give the user choice on whether to download all or some
models, or use cache (even if outdated). The user can always reopen the project
later.
1. The user presses a button in the IfcViewer UI that says "Open from Cloud"
2. The user chooses a connector.
3. The `pull_ifcfed_interactive` method is sent to the connector.
```json
{ "jsonrpc": "2.0", "id": "0", "method": "pull_ifcfed_interactive" }
```
4. The connector:
- (Does optional workflow) authenticates, browses projects, filters files, etc
- The user selects an .ifcfed file from the connector's UI
- Downloads (or retrieves from cache) the cloud .ifcfed into a connector managed directory
- The connector returns a path to the .ifcfed. The connector must also create an adjacent .ifcfed.manifest file:
```json
{ "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } }
```
5. The IfcViewer loads the `path`. This constitutes a fresh session.
6. The IfcViewer calls `pull_models`:
```json
{ "jsonrpc": "2.0", "id": "1", "method": "pull_models", "params": [
{ "display_name": ..., "id": ..., "source": ..., },
{ "display_name": ..., "id": ..., "source": ..., },
...
] }
```
7. The connector handles downloading files. It may always check and download the latest version of the file, or be designed to pin to a particular revision, or give the user the option of not downloading a file, etc. or retrieves from its own cache, and returns a path.
```json
{ "jsonrpc": "2.0", "id": "1", "result": [
{ "path": "/path/to/foo.ifc" },
{
"path": "/path/to/model.ifc", # Used to load the model in IfcViewer
"metadata": { "revision": "B", "date": "2nd Oct 2025" ... }, # Optional, used to display stats
},
null, # If skipped, error, etc
{ "path": "/path/to/bar.ifc" },
...
] }
```
8. The IfcViewer may call another connector with more models to be downloaded.
9. The IfcViewer will load the downloaded models as regular files. Typically this will also result in the IfcViewer reading / writing a cache (e.g. .ifcview) alongside this file, but it is not expected that the connector will know or care about this.
## Sync cloud to local
This refreshes cloud-sourced resources in the currently-open project to their
latest cloud revisions, without prompting the user. It is available whenever
the project has any cloud resources: a `.ifcfed.manifest` adjacent to the
.ifcfed, or one or more models whose `source.connector` is not `local`. The
.ifcfed-refresh phase and the model-refresh phase are independent — only the
first requires a manifest.
1. The user presses a button in the IfcViewer UI that says "Sync Cloud to Local"
2. IfcViewer reads the .ifcfed.manifest and invokes the relevant connector with the manifest data with the `pull_ifcfed` method:
```json
{ "jsonrpc": "2.0", "id": "0", "method": "pull_ifcfed", "params": {
"connector": "mycompany", "version": "2", "url": ...
} }
```
3. The connector does what it needs:
- Authenticates (optional)
- (Typically without user interaction) finds the .ifcfed on the cloud platform using the ifcfed manifest
- Downloads (or retrieves from cache) the cloud .ifcfed into a connector managed directory
- The connector returns a path to the .ifcfed. The connector must also create an adjacent .ifcfed.manifest file:
```json
{ "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } }
```
4. Continue with step 5 of the "Open from cloud" workflow.
If no `.ifcfed.manifest` is present, steps 24 are skipped. The .ifcfed on
disk is used as-is, and the IfcViewer continues from step 6 of the
"Open from cloud" workflow (calling `pull_models` for any cloud-sourced models
referenced in the .ifcfed).
Additionally, if the .ifcfed returned in step 3 is unchanged from the one
already loaded (e.g. the connector served a cached copy because the cloud
revision matched), the IfcViewer skips step 5 as well and continues from
step 6, preserving the current session rather than forcing an unnecessary
fresh one. How "unchanged" is determined (byte equality, hash, mtime, etc.)
is left to the IfcViewer.
## Save as to cloud
This pushes a .ifcfed to a fresh location on a cloud platform, chosen by the
user. It is the "Save As" equivalent and is the only way to first establish a
cloud location for a project that does not yet have a `.ifcfed.manifest`.
1. The user presses a button in the IfcViewer UI that says "Save As to Cloud"
2. The user chooses a connector.
3. The `push_ifcfed_interactive` method is called with the path to the .ifcfed. The connector should treat this as a temporary .ifcfed file, as the real project may or may not be actually saved on disk.
```json
{ "jsonrpc": "2.0", "id": "0", "method": "push_ifcfed_interactive", "params": { "path": "/tmp/path/to/project.ifcfed" } }
```
4. The connector does what it needs:
- (Does optional workflow) authenticates, browses projects, filters files, etc
- Selects existing or writes a new name for an .ifcfed file
- Uploads .ifcfed file to the cloud platform
- The connector returns a path to the .ifcfed. The connector must also create an adjacent .ifcfed.manifest file:
```json
{ "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } }
```
5. The IfcViewer "repoints" to the returned path. It is not necessary to do a full reload as no "changes" are made.
## Save to cloud
This pushes a .ifcfed back to the cloud location it originally came from,
without prompting the user. It is the "Save" equivalent and is only available
when there is a `.ifcfed.manifest` adjacent to the project.
1. The user presses a button in the IfcViewer UI that says "Save to Cloud"
2. IfcViewer reads the .ifcfed.manifest and invokes the relevant connector with the `push_ifcfed` method, passing both the local path and the manifest data:
```json
{ "jsonrpc": "2.0", "id": "0", "method": "push_ifcfed", "params": {
"path": "/tmp/path/to/project.ifcfed",
"manifest": { "connector": "mycompany", "version": "2", "url": "..." }
} }
```
3. The connector does what it needs:
- Authenticates (optional)
- (Typically without user interaction) locates the existing .ifcfed on the cloud platform using the manifest data
- Uploads the .ifcfed, overwriting or creating a new revision as the platform dictates
- The connector returns a path to the .ifcfed and rewrites the adjacent .ifcfed.manifest if any of its fields have changed (e.g. a new version number):
```json
{ "jsonrpc": "2.0", "id": "0", "result": { "path": "/path/to/project/file.ifcfed" } }
```
4. The IfcViewer "repoints" to the returned path. It is not necessary to do a full reload as no "changes" are made.
Conflict resolution for non-interactive push methods (the cloud copy moved on
since the manifest or source was captured, the user lacks write permission,
revision-pinning policies, etc.) is entirely the connector's responsibility.
The connector may silently overwrite, prompt the user, refuse with a JSON-RPC
error, or anything in between. The IfcViewer expresses no opinion. This rule
also applies to `push_model` below.
## Add model from cloud
1. The user presses a button in the IfcViewer UI that says "Add model from cloud"
2. The user chooses a connector.
3. The `pull_models_interactive` method is sent to the connector.
```json
{ "jsonrpc": "2.0", "id": "0", "method": "pull_models_interactive" }
```
4. The connector does what it needs:
- (Does optional workflow) authenticates, browses projects, filters files, etc
- Selects a model (.ifc, .ifcview, .rdbview, .rdb, etc)
- Downloads (or retrieves from cache) the model into a connector managed directory
- The connector returns a successful result:
```json
{ "jsonrpc": "2.0", "id": "0", "result": [
{
"display_name": "bar.ifc", # Stored in .ifcfed
"source": { "connector": "autodesk", ... }, # Stored in .ifcfed
"path": "/path/to/model.ifc", # Used to load the model in IfcViewer
"metadata": { "revision": "B", "date": "2nd Oct 2025" ... }, # Optional, used to display stats
},
{ ... },
...
] }
```
5. The IfcViewer updates the .ifcfed models section with new models using the
"source" and "display\_name" from the provided data. The models are
immediately loaded from the "path", and the IfcViewer stores the "metadata"
for display. The path and metadata is never stored in the .ifcfed.
## Save model as to cloud
This pushes a model to a fresh location on a cloud platform, chosen by the
user. It is the "Save As" equivalent and is the only way to first establish a
cloud `source` for a model whose current source is `local`.
1. The user presses a button in the IfcViewer UI that says "Save Model As to Cloud"
2. The user chooses a connector.
3. The `push_model_interactive` method is sent to the connector with a path to the model to be uploaded (typically a file, but RocksDB databases can be a folder).
```json
{ "jsonrpc": "2.0", "id": "0", "method": "push_model_interactive", "params": { "path": "/tmp/path/to/model.ifc" } }
```
4. The connector does what it needs:
- (Does optional workflow) authenticates, browses projects, filters files, etc
- Selects existing or types a new name for the model
- Uploads the model (only the file in params, though the connector is free to do optional additional work) to the cloud platform
- The connector returns a successful result:
```json
{ "jsonrpc": "2.0", "id": "0", "result": {
"display_name": "bar.ifc", # Stored in .ifcfed
"path": "/path/to/model.ifc",
"source": { "connector": "autodesk", ... }, # Stored in .ifcfed
"metadata": { "revision": "B", "date": "2nd Oct 2025" ... }, # Optional, used to display stats
} }
```
5. The IfcViewer updates the .ifcfed models section with the new model metadata from the provided data.
## Save model to cloud
This pushes a model back to the cloud location it originally came from,
without prompting the user. It is the "Save" equivalent and is only available
for models whose .ifcfed `source` already points at a cloud connector (i.e.
anything other than `local`).
1. The user presses a button in the IfcViewer UI that says "Save Model to Cloud"
2. IfcViewer invokes the connector named in the model's `source` with the `push_model` method, passing both the local path and the existing `source` object verbatim:
```json
{ "jsonrpc": "2.0", "id": "0", "method": "push_model", "params": {
"path": "/tmp/path/to/model.ifc",
"source": { "connector": "autodesk", "hub_id": "b.hub123", "item_id": "...", ... }
} }
```
3. The connector does what it needs:
- Authenticates (optional)
- (Typically without user interaction) locates the existing model on the cloud platform using the `source` data
- Uploads the model, overwriting or creating a new revision as the platform dictates
- The connector returns a successful result. The returned `source` reflects the just-uploaded revision (e.g. a new `version_id`) and replaces the existing one in the .ifcfed; `display_name` is omitted (the existing one is retained):
```json
{ "jsonrpc": "2.0", "id": "0", "result": {
"source": { "connector": "autodesk", ... }, # Replaces existing source in .ifcfed
"metadata": { "revision": "C", "date": "19th May 2026" ... }, # Optional, used to display stats
} }
```
4. The IfcViewer replaces the model's `source` in the .ifcfed and refreshes the stored metadata for display.
## Connector settings (optional)
A connector MAY implement an `open_settings` method that the IfcViewer invokes
when the user clicks the connector's settings entry (e.g. a gear icon next to
the connector name). The connector is responsible for the entire settings UI:
credentials, sign-out, default folders, anything connector-specific.
1. The user clicks the connector's settings entry in the IfcViewer UI.
2. The IfcViewer sends `open_settings`:
```json
{ "jsonrpc": "2.0", "id": "0", "method": "open_settings" }
```
3. The connector shows its own settings dialog. When the user closes it, the
connector returns:
```json
{ "jsonrpc": "2.0", "id": "0", "result": {} }
```
If the connector returns a JSON-RPC `Method not found` error (code `-32601`),
the IfcViewer should treat that connector as having no settings and hide its
settings entry. There is no other discovery mechanism — the viewer probes by
calling the method when needed.
The connector is free to use this method for things like:
- Signing in / signing out
- Setting API keys, client ids, or other credentials
- Choosing default upload folders or revision policies
- Clearing the connector's cache
The result object is currently always empty (`{}`); future revisions may add
optional fields (for example a fresh display label for the connector).
### Error Response
The connector owns all user-facing error handling: dialogs, retry prompts,
re-auth flows, logs. The IfcViewer does not interpret or display connector
errors directly.
The protocol expresses only two outcomes:
- **Per-item soft failure** (one model in a batch failed, others succeeded):
the connector returns `null` in that slot of the result array. The IfcViewer
skips it and continues.
- **Whole-call hard failure** (the connector cannot service the request at
all): the connector returns a JSON-RPC error object. The IfcViewer aborts
the operation. The `error.message` may be logged by the IfcViewer for
diagnostics, but is not shown to the user — the connector is expected to
have already surfaced the problem in its own UI.
Diagnostic detail (stack traces, codes, retry context) should be written to
stderr, which the IfcViewer captures for logs.
## Permissions
Permissions are completely managed by the connector. For example:
- one Autodesk model resolves successfully
- one Aconex model fails with access denied
- one Dropbox model resolves successfully
The viewer will tolerate partial failure and report skipped resources that the connector cannot resolve. A federation does not need to become all-or-nothing just because some remote models are permission-restricted.
## Connector discovery
A connector is shipped as a folder containing a `connector.json` manifest and
an executable entry point. The IfcViewer discovers connectors by scanning a
small, fixed set of locations for these folders.
### Connector bundle layout
```
<some-connectors-dir>/
autodesk/ # folder name is arbitrary; id comes from connector.json
connector.json # required, at the folder root
ifcviewer-autodesk # the executable (or a wrapper script)
... # anything else the connector ships
```
### `connector.json`
```json
{
"id": "autodesk",
"name": "Autodesk Forma",
"version": "0.1.0",
"exec": "./ifcviewer-autodesk"
}
```
- `id` — stable identifier used in `.ifcfed` `source.connector` fields and in
`.ifcfed.manifest`. Must be unique across all discovered connectors.
- `name` — human-readable label shown in the IfcViewer UI.
- `version` — connector version string; informational only.
- `exec` — how to launch the connector:
- Relative path (starts with `./` or `../`): resolved against the
connector folder. This is the recommended form for bundled connectors.
- Absolute path: used as-is.
- Bare name (no path separators): looked up via the system `PATH`.
On Windows, the IfcViewer will also try `<exec>.exe` if `<exec>` does not
exist as written.
### Search locations
The IfcViewer scans, in order of precedence (first match wins for a given `id`):
1. **`IFCVIEWER_CONNECTOR_PATH` environment variable.** A list of directories
separated by the platform path separator (`:` on Linux/macOS, `;` on
Windows). Intended for development and unusual installs.
2. **User connectors directory.** The platform's per-user application data
location:
- Linux: `~/.local/share/IfcOpenShell/IfcViewer/connectors/`
- macOS: `~/Library/Application Support/IfcOpenShell/IfcViewer/connectors/`
- Windows: `%APPDATA%\IfcOpenShell\IfcViewer\connectors\`
In each search location, the IfcViewer looks at every immediate subdirectory
and treats it as a connector iff it contains a `connector.json`. Connectors
are launched on demand when the user invokes a cloud workflow, not at startup.
### Conflicts and errors
- If two folders declare the same `id`, the one found earlier in the search
order wins; the loser is skipped and a warning is written to the IfcViewer's
log.
- A `connector.json` that is missing, unreadable, malformed, or missing
required fields causes that folder to be skipped (with a log entry); other
connectors are unaffected.
- A connector whose `exec` cannot be resolved or launched is reported to the
user only when the user actually tries to invoke it.
+97
View File
@@ -0,0 +1,97 @@
# `ifcviewer-autodesk`
Autodesk Forma (APS / Docs) connector for IfcViewer.
Implements the JSON-RPC connector contract defined in
[`CLOUD_SYNC_PROTOCOL.md`](CLOUD_SYNC_PROTOCOL.md). The connector is a separate
process the viewer launches and speaks to over stdio.
UI is built on **CustomTkinter** (Tcl/Tk under the hood), keeping the
packaged connector around 50 MB unpacked / 21 MB zipped on Linux. The Python
running this code must include `tkinter` (most distribution Python builds do;
on Gentoo make sure `USE="tk"` is set for `dev-lang/python`).
## Install
```bash
cd src/ifcviewer-autodesk
python -m venv venv
source venv/bin/activate
pip install -e .
```
## Run
```bash
ifcviewer-autodesk
```
The connector launches without any configuration; on first run, invoke
`open_settings` (or, equivalently, set the `APS_CLIENT_ID` env var) to
configure the Autodesk client id.
Then send newline-delimited JSON-RPC 2.0 requests on `stdin`. Examples:
```json
{"jsonrpc":"2.0","id":"0","method":"open_settings"}
{"jsonrpc":"2.0","id":"1","method":"pull_ifcfed_interactive"}
{"jsonrpc":"2.0","id":"2","method":"pull_models","params":[{"display_name":"foo.ifc","id":"abc","source":{"connector":"autodesk","hub_id":"b.hub","project_id":"b.proj","item_id":"urn:adsk...","version_id":"latest"}}]}
{"jsonrpc":"2.0","id":"3","method":"push_ifcfed_interactive","params":{"path":"/tmp/project.ifcfed"}}
{"jsonrpc":"2.0","id":"4","method":"push_ifcfed","params":{"path":"/tmp/project.ifcfed","manifest":{"connector":"autodesk","hub_id":"b.hub","project_id":"b.proj","item_id":"urn:adsk..."}}}
```
The viewer is expected to launch this binary once per session and keep it alive
until shutdown; closing the connector's `stdin` triggers a clean exit.
### Configuration
The connector reads the Autodesk client id from two places, in order:
1. The `APS_CLIENT_ID` environment variable (takes precedence — useful for dev
overrides).
2. `<config dir>/settings.json` (persisted via the settings dialog).
The config directory is platform-specific:
- Linux: `~/.config/ifcviewer-autodesk/`
- macOS: `~/Library/Application Support/ifcviewer-autodesk/`
- Windows: `%APPDATA%\ifcviewer-autodesk\`
OAuth tokens are stored in the OS keychain (Secret Service on Linux, Keychain
on macOS, Credential Manager on Windows), keyed by the client id, so changing
the client id starts a fresh session.
## Cache
The connector owns its own cache. Resolved files live under (Linux):
```
~/.cache/ifcviewer-autodesk/
ifcfeds/<hash>/<name>.ifcfed[.manifest]
models/<hash>/<filename>
```
Each file is the sole child in its directory so the viewer can write sidecar
files (e.g. `.ifcview`) next to it without colliding. A new resolved version of
a model lands in a fresh `models/<hash>/` directory; the old directory may be
removed manually to clear space.
## Status
What is implemented:
- Strict JSON-RPC 2.0 host over stdio
- APS PKCE sign-in with keyring-backed token store
- Hub / project / folder browsing (Qt UI)
- `pull_ifcfed_interactive`, `pull_ifcfed`, `pull_models`, `pull_models_interactive`
- `push_ifcfed_interactive`, `push_ifcfed`, `push_model_interactive`, `push_model`
- `open_settings` — edit the client id, sign out
- Connector-managed cache with sole-child invariant
- Adjacent `.ifcfed.manifest` written/read alongside `.ifcfed` files
What is intentionally not implemented:
- JSON-RPC notifications for progress streaming (the connector shows its own
progress dialog instead, per spec)
- Cancellation of in-flight downloads
- Subdirectory upload layouts inside push destinations (one flat file at a time)
+6
View File
@@ -0,0 +1,6 @@
{
"id": "autodesk",
"name": "Autodesk Forma",
"version": "0.1.0",
"exec": "ifcviewer-autodesk"
}
@@ -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")
+102
View File
@@ -0,0 +1,102 @@
# Packaging the Autodesk connector
The connector is shipped as a self-contained folder ready to drop into the
IfcViewer connectors directory. PyInstaller bundles the Python interpreter,
Qt, and all dependencies so end users do not need Python installed.
PyInstaller does **not** cross-compile. Each OS must build on itself —
typically via a CI matrix.
## Output
```
dist/
autodesk/ # the connector folder, ready to install
connector.json
ifcviewer-autodesk[.exe]
_internal/... # PyInstaller dependencies (Qt, Python, …)
autodesk-<os>-<arch>.zip # the distribution archive
```
The folder is what the IfcViewer expects under
`~/.local/share/IfcOpenShell/IfcViewer/connectors/` (or the OS equivalent).
## Build steps (any OS)
```bash
cd src/ifcviewer-autodesk
python -m venv venv
venv/bin/activate # or venv\Scripts\activate on Windows
pip install -e ".[build]"
python packaging/build.py
```
The build:
1. cleans `dist/` and `build/`
2. runs PyInstaller against `packaging/ifcviewer-autodesk.spec`
3. renames the produced folder to `autodesk/` and copies `connector.json` into it
4. zips the folder as `autodesk-<os>-<arch>.zip`
The UI is Tcl/Tk via CustomTkinter, which keeps the bundle small. Expect
~50 MB unpacked / ~21 MB zipped per OS. The Python used to run the build
must include `tkinter` — most distribution and python-build-standalone
builds do; on Gentoo make sure `USE="tk"` is set for `dev-lang/python`.
## Per-OS notes
### Linux
- Build on the **oldest glibc** you intend to support. Binaries built on a
newer glibc will not run on older distributions. Ubuntu 22.04 LTS
(glibc 2.35) is a reasonable lowest common denominator in 2026.
- The keyring backend used at runtime is `SecretService` (gnome-keyring or
KWallet); end users need a Secret Service provider running.
- Output: `autodesk-linux-x86_64.zip` (and/or `arm64`).
### macOS
- Each architecture builds separately. To support both Apple Silicon and
Intel, build on each and ship two zips, or post-process with `lipo` to
produce universal binaries.
- The keyring backend is the system Keychain.
- For distribution outside the developer's machine you will need to
**codesign** the executable and Tcl/Tk dylibs, and notarize the bundle.
Unsigned binaries trigger Gatekeeper warnings. Codesigning is left to the
caller; the spec's `codesign_identity` field can be wired up.
- Output: `autodesk-macos-arm64.zip` and/or `autodesk-macos-x86_64.zip`.
### Windows
- Build with the Microsoft Visual C++ runtime available (usually present in
any modern Python distribution).
- The keyring backend is Credential Manager.
- The `.exe` is built with `console=True` because the connector speaks
JSON-RPC over stdio. The IfcViewer must launch the connector with
`CREATE_NO_WINDOW` (Qt: `QProcess::setCreateProcessArgumentsModifier`) so
end users never see a console window flicker.
- For distribution: sign the `.exe` with an Authenticode certificate to
avoid SmartScreen warnings. Signing is left to the caller.
- Output: `autodesk-windows-x86_64.zip`.
## Installing a built connector
```bash
# Linux
unzip dist/autodesk-linux-x86_64.zip -d ~/.local/share/IfcOpenShell/IfcViewer/connectors/
# macOS
unzip dist/autodesk-macos-arm64.zip -d "~/Library/Application Support/IfcOpenShell/IfcViewer/connectors/"
# Windows (PowerShell)
Expand-Archive dist\autodesk-windows-x86_64.zip -DestinationPath "$env:APPDATA\IfcOpenShell\IfcViewer\connectors\"
```
The IfcViewer picks up the connector on next launch.
## Out of scope here
- Signing / notarization (caller's responsibility per OS)
- CI matrix (project-level concern)
- Auto-update (the IfcViewer or the host installer handles this)
- Universal macOS binaries via `lipo` (post-process step, not part of `build.py`)
+120
View File
@@ -0,0 +1,120 @@
"""Build the Autodesk connector bundle for the current OS.
Each OS builds on itself (PyInstaller does not cross-compile). The output is a
single zip ready to drop into the IfcViewer connectors directory:
dist/autodesk-<os>-<arch>.zip
autodesk/
connector.json
ifcviewer-autodesk[.exe]
_internal/...
Usage:
pip install -e ".[build]"
python packaging/build.py
"""
from __future__ import annotations
import json
import platform
import shutil
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
PACKAGING_DIR = PROJECT_ROOT / "packaging"
SPEC_FILE = PACKAGING_DIR / "ifcviewer-autodesk.spec"
DIST_DIR = PROJECT_ROOT / "dist"
BUILD_DIR = PROJECT_ROOT / "build"
CONNECTOR_FOLDER_NAME = "autodesk"
PYINSTALLER_OUTPUT_NAME = "ifcviewer-autodesk"
def _platform_tag() -> str:
system = platform.system()
if system == "Darwin":
os_name = "macos"
elif system == "Windows":
os_name = "windows"
else:
os_name = system.lower()
machine = platform.machine().lower()
if machine in {"amd64", "x86_64"}:
arch = "x86_64"
elif machine in {"arm64", "aarch64"}:
arch = "arm64"
else:
arch = machine
return f"{os_name}-{arch}"
def _clean() -> None:
for path in (DIST_DIR, BUILD_DIR):
if path.exists():
shutil.rmtree(path)
def _run_pyinstaller() -> Path:
subprocess.check_call(
[
sys.executable,
"-m",
"PyInstaller",
str(SPEC_FILE),
"--noconfirm",
"--distpath",
str(DIST_DIR),
"--workpath",
str(BUILD_DIR),
],
cwd=PROJECT_ROOT,
)
produced = DIST_DIR / PYINSTALLER_OUTPUT_NAME
if not produced.is_dir():
raise SystemExit(f"PyInstaller did not produce expected folder: {produced}")
return produced
def _assemble_connector_folder(pyinstaller_output: Path) -> Path:
connector_dir = DIST_DIR / CONNECTOR_FOLDER_NAME
if connector_dir.exists():
shutil.rmtree(connector_dir)
pyinstaller_output.rename(connector_dir)
# The source-controlled connector.json uses the bare entry-point name so
# `pip install -e .` works for development. For the bundled folder, the
# binary lives next to connector.json, so rewrite `exec` to a relative path.
manifest = json.loads((PROJECT_ROOT / "connector.json").read_text(encoding="utf-8"))
binary_name = "ifcviewer-autodesk.exe" if platform.system() == "Windows" else "ifcviewer-autodesk"
manifest["exec"] = f"./{binary_name}"
(connector_dir / "connector.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return connector_dir
def _zip_connector_folder(tag: str) -> Path:
archive_base = DIST_DIR / f"{CONNECTOR_FOLDER_NAME}-{tag}"
return Path(shutil.make_archive(str(archive_base), "zip", DIST_DIR, CONNECTOR_FOLDER_NAME))
def main() -> int:
tag = _platform_tag()
print(f"Building Autodesk connector for {tag}")
_clean()
pyinstaller_output = _run_pyinstaller()
connector_dir = _assemble_connector_folder(pyinstaller_output)
archive = _zip_connector_folder(tag)
print(f"Connector folder: {connector_dir}")
print(f"Distribution zip: {archive}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,81 @@
# PyInstaller spec for the IfcViewer Autodesk connector (Tk + CustomTkinter).
#
# The connector talks JSON-RPC over stdio, so `console=True` is required to
# attach stdin/stdout on Windows. The IfcViewer is expected to spawn the
# connector with the OS's "hide console window" flag on Windows
# (CREATE_NO_WINDOW) so end users never see a console pop up.
from pathlib import Path
PROJECT_ROOT = Path(SPECPATH).resolve().parent
# keyring uses entry points for backends — PyInstaller can't trace them
# without hints. Bundle every backend; the right one is picked at runtime
# per OS.
HIDDEN_IMPORTS = [
"keyring.backends.SecretService",
"keyring.backends.macOS",
"keyring.backends.Windows",
"keyring.backends.fail",
"keyring.backends.chainer",
]
a = Analysis(
[str(PROJECT_ROOT / "ifcviewer_autodesk" / "__main__.py")],
pathex=[str(PROJECT_ROOT)],
binaries=[],
datas=[],
hiddenimports=HIDDEN_IMPORTS,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
# Test / docs.
"test", "unittest", "pydoc_data",
# Protocols / formats we never touch. (email, html, http.cookies and
# http.cookiejar are required by http.server / httpx and must stay.)
"xmlrpc", "sqlite3", "ftplib", "imaplib", "poplib", "nntplib",
"smtplib", "telnetlib", "wsgiref",
# Concurrency we never use (asyncio is needed by httpx → anyio).
"multiprocessing", "concurrent.futures.process",
# Build / packaging tools.
"setuptools", "pip", "distutils", "ensurepip", "lib2to3",
# Heavy stdlib bits with no callers.
"decimal", "_decimal",
# tkinter test modules.
"tkinter.test", "test.test_tk",
],
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="ifcviewer-autodesk",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name="ifcviewer-autodesk",
)
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcviewer-autodesk"
version = "0.1.0"
description = "Autodesk cloud connector for IfcViewer"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"customtkinter>=5.2",
"httpx>=0.27",
"keyring>=25.2"
]
[project.optional-dependencies]
build = ["pyinstaller>=6.0"]
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
[project.scripts]
ifcviewer-autodesk = "ifcviewer_autodesk.__main__:main"