mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 23:36:20 +00:00
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:
@@ -128,3 +128,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
|||||||
*.claude
|
*.claude
|
||||||
*.py.tmp*
|
*.py.tmp*
|
||||||
*.json.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 2–4 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.
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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`)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user