Skip to content

Bentley ProjectWise

Client for a single Bentley ProjectWise datasource exposed through WSG.

One :class:ProjectWiseClient instance maps to one WSG instance — a single repository (datasource) on a single WSG host. Construct it with the WSG base URL, the repository id, and a :class:~workbench.clients.projectwise_token.WsgTokenProvider. The client owns transport (bearer header, error-body logging, instances envelope parsing) and exposes:

  • discoverylist_repositories (gateway-level; also an auth/health probe) and list_nav_children (Navigation tree walk), used by the Hoppa API's file-selection UI;
  • enrichmentget_documents / get_document (by stable GUID), used by the connector on re-bind;
  • contentfile_url (the $file endpoint) plus auth_headers (the bearer header the streaming fetch must send, since WSG offers no SAS/self- authenticating URL on this datasource — see specs/spec-projectwise-client.md §8.4);
  • writesupdate_environment_attributes and the environment-instance reads it depends on, used by the ProjectWise writeback connector (specs/spec-projectwise-writeback.md §4).

See specs/spec-projectwise-client.md for the full API field notes and gotchas. Key ones honoured here: the repository id keeps its ~3A encoding literal (never re-encoded); document reads are scoped (never an unscoped class sweep, §6 #1); and 4xx/5xx bodies (which carry errorId/errorMessage) are logged before the exception is raised (§6 #5).

ProjectWiseClient

Read and attribute-write access to one ProjectWise datasource via WSG.

Parameters:

Name Type Description Default
base_url str

WSG API root up to and including the version, e.g. https://example-pw-ws.bentley.com/ws/v2.9. A trailing slash is tolerated.

required
repository_id str

The WSG repository id, e.g. Bentley.PW--example-pw.bentley.com~3AEXAMPLE. Passed through verbatim — the ~3A (WSG's encoding of :) must NOT be URL-encoded again.

required
token_provider WsgTokenProvider

Supplies the Authorization header WSG accepts on demand (Bearer or Basic). The PoC uses :class:EnvBearerTokenProvider or :class:BasicAuthProvider; production swaps in an IMS exchange.

required
timeout int

Per-request timeout in seconds.

60

auth_headers

auth_headers()

Authorization header for an authenticated WSG request.

The provider owns the full header value (Bearer … or Basic …), so the client is auth-scheme agnostic. Also passed (as a callable) to AzureBlobDocumentVersion so the content-streaming fetch can authenticate $file — WSG requires the Authorization header and offers no self-authenticating URL on this datasource. Recomputed on every call so a refreshed token / rotated credential is picked up transparently.

build_web_url staticmethod

build_web_url(repository_id, instance_id)

Build the ProjectWise Web permalink from just a repository id + GUID.

Static (no client/credentials needed) so a metadata-only bind — where no authenticated client was stood up — can still produce the permalink.

ds is the repository id minus the Bentley.PW-- plugin prefix (~3A kept literal); doc is the document GUID with hyphens removed.

file_url

file_url(instance_id)

Return the $file content URL for a document.

The bytes stream through the gateway and the request must carry the bearer header (see :meth:auth_headers); there is no SAS/self-authenticating URL on this datasource (§8.4).

get_document

get_document(instance_id)

Fetch a single Document instance by GUID, or None if not found.

get_document_attributes

get_document_attributes(instance_id, doc=None, environment_id=None)

Return a document's custom/environment attributes as a flat dict.

Generic — nothing about the datasource is hardcoded. Resolves the document's EnvironmentId to its Env_<id>_* dynamic class at runtime, then reaches the per-document attribute instance by traversing the DocumentEnvironment relationship from the document. This is keyed by the document GUID (fast, and version-correct — the env instance's own id carries an unguessable _<n> suffix), and names the relationship explicitly to disambiguate it from DocumentDefaultEnvironment (WSG errors on the ambiguous form). Returns the related instance's properties, or an empty dict when the document has no environment.

Parameters:

Name Type Description Default
instance_id str

The document GUID.

required
doc dict | None

An already-fetched Document instance, to read EnvironmentId from without a re-fetch.

None
environment_id Any

The document's EnvironmentId if already known (e.g. from a config-blob snapshot) — avoids fetching the document.

None

get_documents

get_documents(instance_ids)

Fetch Document instances by GUID, batched into one query.

Uses the collection + $id in [...] filter form the ProjectWise Web UI uses (confirmed working on Example), rather than the per-instance path or the relationship-filter form (which 500s on this plugin — §6 #2). !poly includes derived classes.

get_dynamic_classes

get_dynamic_classes()

Cached list of PW_WSG_Dynamic class names (Env_*, PrType_*).

The Schema property on ECClassDef is the versioned name (e.g. PW_WSG_Dynamic.01.02), so it is matched by prefix, not eq.

get_environment_instance

get_environment_instance(instance_id, env_class)

One document's environment instance: instanceId, class and properties.

Reached by traversing DocumentEnvironment from the document rather than by addressing the instance directly, because the instance's own id carries an unguessable _<n> suffix that cannot be derived from the document GUID. That id is what :meth:update_environment_attributes writes to, so a write always begins with this read.

Returns None when the document has no instance of env_class — which is how a writeback run detects that a document's environment has changed since it was selected, and declines to write against a schema the document no longer has.

get_environment_instances

get_environment_instances(instance_ids, env_class)

Batched :meth:get_environment_instance, keyed by document GUID.

Chunks at :data:BATCH_SIZE GUIDs per query, which turns a 200-document writeback preview into ~8 calls rather than 200 — worth doing because per-call latency, not the rate ceiling, is what bounds a run. Every document in a writeback target shares one environment class by construction, so a target batches cleanly.

Documents with no instance of env_class are simply absent from the result; the caller distinguishes them from a read failure, which raises.

get_instance

get_instance(schema, class_name, instance_id)

Fetch any single class instance by exact instance id, or None.

identity

identity()

The logical account this client authenticates as, if the provider knows.

Never the credential itself — a username or equivalent handle. Feeds :attr:WritebackReport.written_as so an audit row can record which service account a writeback run wrote as, which matters because ProjectWise attributes every change to that account rather than to the Hoppa user who triggered it.

Returns None when the provider cannot say (a bearer token carries no username) or has not yet resolved its credential — deliberately, so an accessor never triggers a Key Vault fetch as a side effect.

list_nav_children

list_nav_children(node_id=None)

List child nodes of a Navigation node (or the roots when None).

Each node's properties carry Key_ClassName (Project = folder, Document = doc) and Key_InstanceId (the real GUID for metadata / $file). HasChildren is unreliable (§6 #4) — don't gate recursion on it; attempt to descend any Project node within a depth bound.

list_repositories

list_repositories()

List the datasources (repositories) this token can see on the host.

Gateway-level call (no datasource session required), so it doubles as an auth/health probe and as the source for instance discovery. Each instance carries instanceId (the repository id) and a DisplayLabel.

query

query(schema, class_name, *, filter_expr=None, select=None, top=None, poly=False)

Run a scoped query against any schema/class; return instances.

Schema-agnostic primitive. filter_expr / select are raw OData $filter / $select values (e.g. "$id+in+['abc']" and "DocumentEnvironment-forward-PW_WSG_Dynamic.Env_104_EXAMPLE.*") — built into the URL so requests doesn't reshape the operators. Always scope queries (a folder, an id, …); never sweep a class unscoped (§6 #1).

update_environment_attributes

update_environment_attributes(env_instance_id, env_class, properties)

Write environment attributes for one document; return the STORED values.

POSTs a changed instance and returns changedInstance.instanceAfterChange.properties — what ProjectWise actually stored, which is not necessarily what was sent. Values longer than an attribute's configured limit are silently truncated on an HTTP 200, and that limit is not exposed anywhere in MetaSchema, so comparing this return against the argument is the only way to detect it. Callers must compare.

Only the properties passed are written; unsent attributes are left untouched (WSG merges rather than replaces — confirmed empirically), so there is no read-modify-write of the full property set and the payload can carry just the fields that changed.

Parameters:

Name Type Description Default
env_instance_id str

The environment instance id from :meth:get_environment_instance — not the document GUID, and never constructed by hand (it carries an unguessable suffix).

required
env_class str

The Env_<id>_<NAME> dynamic class name.

required
properties dict[str, Any]

Attribute name → value. Values are sent as strings.

required

Raises:

Type Description
WsgError

On any non-2xx. Notably HTTP 400 with "document having final status", which is a business-rule rejection, not a transport failure, and nothing is written.

web_url

web_url(instance_id)

Stable ProjectWise Web permalink that opens the document in a browser.

The pwlink app=webview form opens the document viewer directly and needs only the datasource + document GUID — no work-area/project ids — so it is built purely from data already held, with no network call. It is GUID/URN-based, so it survives the document being moved or renamed.

WsgError

Bases: RuntimeError

A non-2xx response from WSG, carrying the gateway's own diagnostic.

WSG returns {errorId, errorMessage, errorDescription} on failure, and callers need to tell an auth failure from a throttle from a business-rule rejection. Note that error_id may be None — the final-status write rejection returns a null id with a perfectly good message — so never key classification on it alone; use status_code first and treat message as the user-facing text.

Attributes:

Name Type Description
status_code

The HTTP status.

error_id

WSG's errorId, or None when it did not supply one.

message

WSG's errorMessage, safe to show a user. Falls back to the raw body when the response was not JSON at all.

url

The URL that failed, for logs.

from_response classmethod

from_response(response, url)

Build a :class:WsgError from a failed requests response.

Parses the body defensively: the BOM WSG prepends is stripped, and a body that is not JSON (an HTML gateway error page, say) degrades to its first 500 characters rather than masking the real failure with a decode error.

make_projectwise_client

make_projectwise_client(*, organization, wsg_base_url, repository_id)

Build an authenticated client for one ProjectWise datasource.

The one place the Key Vault naming convention for ProjectWise service accounts is applied, so a caller never has to know it.

organization is the org slug — identical in meaning to the value AzureBlobSession and :class:KeyVaultBasicAuthProvider take. It is not the Auth0 org_id: passing that builds a different secret name, which resolves to nothing and fails at the first authenticated call rather than here.

Deliberately not session-based. The writeback /columns and /validate paths rebuild their target from the echoed scope and have no initialized session to draw a client from, so requiring one would force a document load those paths were designed to skip.