Skip to content

Metadata Writeback

Write quality-controlled Hoppa metadata back to the originating document system (SharePoint, Autodesk ACC / BIM 360, Bentley ProjectWise) as custom columns/attributes.

The subpackage exposes a uniform, source-agnostic contract that a caller (the Hoppa API) drives over any supported source. Each source has one WritebackConnector, resolved from a registry by storage_provider:

from workbench.writeback import get_writeback_connector, FieldMapping, MappedField, WritebackReport

connector = get_writeback_connector(document_version.storage_provider)

# 1. Discover deduped write targets (pure — no source I/O)
targets = connector.discover_targets(session)

# 2-3. Read the target's columns/attributes, then auto-match to the standard
schema = connector.list_columns(target, client)          # client injected by the caller
suggestion = connector.suggest_matches(schema, session.metadata_specification)

# 4. Reconcile source column definitions to the standard (report-only on Autodesk)
mapping = FieldMapping(target.target_key, [MappedField("document_summary", "Description")])
sync = connector.validate_columns(target, mapping, session.metadata_specification, client)
# Each result carries an explicit action (created/updated/unchanged/validated/missing)
# and the resolved column id + types; proceed when none carries an `error`.
blocked = [r for r in sync if r.error is not None]

# 5-6. Preview current-vs-new, then write per document (idempotent)
previews = connector.read_current_values(target, mapping, docs, session.metadata_specification, client)
results = [connector.write_values(target, mapping, d, session.metadata_specification, client) for d in docs]

report = WritebackReport.from_results(results, target_keys=[target.target_key])

Key design points:

  • Every method on the ABC is callable on every connector. That includes written_as(client) (who the source will record the change against — None when that is the Hoppa user) and clear_cache(target_key) (drop cached schema between runs). Neither needs a getattr probe or a try/except.
  • The caller injects the authenticated source client (SharePointClient / AutodeskClient); the connector holds no auth state.
  • Values are QC-merged — writes use the user/AI-edited value (via merged_value), never raw workflow output — and value coercion is per-source.
  • Capabilities differ per source. SharePoint can create and update column definitions; Autodesk cannot (its validate_columns is report-only) and also reconstructs the file naming standard, which can rename the file; ProjectWise cannot either, and additionally writes as a shared service account. Drive UI from the whole WritebackCapabilities object.

What a source will not tell you

Two statuses exist because a source can succeed at a write and still not hold what Hoppa intended, or refuse a write it happily let you read:

  • WriteStatus.ALTERED — the write returned success and the source stored something else. ProjectWise silently truncates a value to a per-attribute length it publishes nowhere, so the connector compares the write's echo against what it sent. FieldWriteResult.intended_value / stored_value carry the evidence, and only on ALTERED, so a clean run stays small. A truncated field reports ALTERED on every run until the value is shortened at source — it never converges, deliberately, because the discrepancy is still true.
  • DocumentPreview.writable — available answers "could we read it" and its false branch means the item is gone. writable is the separate axis for "we read it fine and may not write to it", which is exactly a ProjectWise document in a final workflow state.

TargetSchema.mapping_advisory carries the same honesty at the mapping step: plain strings naming what the source does not expose (ProjectWise reports neither attribute length limits nor which attributes are read-only or derived), so the operator can check the one place those facts do exist.

Contract & types

The WritebackConnector abstract base class.

One concrete subclass per source system (SharePoint, Autodesk), registered in workbench.writeback.registry.WRITEBACK_REGISTRY keyed by storage_provider — mirroring the read-side workbench.bindings.connectors registry, but kept separate because writeback is a distinct concern from read/download.

The connector — never the API — resolves Hoppa values to source field values, so read_current_values and write_values take mappings, not pre-resolved field values (see specs/spec-metadata-writeback.md §6). This guarantees preview and execute share one value path and cannot diverge.

WritebackConnector

Bases: ABC

Uniform writeback contract over one source system.

Concrete subclasses are stateless singletons registered by key (aside from an internal per-target read cache). Class attributes advertise the connector's capabilities so the API/frontend degrade gracefully without branching on the source key.

Auth / clients. The API owns source-client construction (it already builds SharePointClient(user_id=...) for reads) and injects the authenticated client into each source-hitting verb via client. The connector holds no auth state — this keeps it a pure singleton and makes it trivial to test with a fake client. client is the connector's own source client type (SharePointClient / AutodeskClient); each concrete connector documents what it expects.

clear_cache

clear_cache(target_key=None)

Drop cached per-target context — all of it, or one target's.

Also part of the contract: the API calls this between runs to avoid acting on a schema that has since been edited at source.

Concrete, not abstract, and the default really is nothing to do — a connector that caches nothing should not have to write an empty method to satisfy the contract. The three that hold a context cache override it.

default_picklist_format_for

default_picklist_format_for(property_type)

The pre-selected format, or None when there is no choice to make.

describe_property

describe_property(key, prop)

Describe one Hoppa property's writeback options for the mapping UI.

discover_targets abstractmethod

discover_targets(session)

Group the session's documents into deduped write targets.

Pure (no source I/O): derives target_key from each document's connection_details via :func:workbench.writeback.targets.target_key_for. SharePoint targets are site+drive; Autodesk targets are project+folder. display_name is best-effort from local data — a richer label may need a source call the caller makes separately. → UX Step 7.

formats_for

formats_for(mapping)

Per-property picklist format for a mapping, indexed by property key.

The single place MappedField.picklist_format is None ("not specified") becomes a concrete format. Shared by every connector so the fallback cannot differ by source — and so there is exactly one default in the system, the connector's own, rather than one here and one on the DTO.

list_columns abstractmethod

list_columns(target, client)

Read the target's writable columns/attributes and type vocabulary.

Returns the full mapping-step payload: normalized + native column types, allowed values, supported_column_types, the Hoppa→source type_compatibility map, and capabilities. → UX Steps 5-6.

picklist_formats_for

picklist_formats_for(property_type)

The formats offered for one Hoppa property type, possibly none.

read_current_values abstractmethod

read_current_values(target, mapping, documents, metadata_spec, client, custom_properties=None)

Current source value AND resolved new value, per doc per mapped field.

Both values come from the connector (one value path) so preview and execute never diverge. → UX Step 9.

suggest_matches abstractmethod

suggest_matches(schema, metadata_spec, custom_properties=None)

Auto-match Hoppa properties to existing source columns. → UX Step 5.

Pure: operates on the schema from :meth:list_columns so no second source round-trip is needed. custom_properties (from Session.custom_properties) are merged with the governed spec and matched identically — pass them so ungoverned "custom columns" appear in the mapping.

Names are compared case-insensitively first. Properties still unmatched are then compared through :func:loose_matches, so "Role Family" maps to "ROLE_FAMILY".

validate_columns abstractmethod

validate_columns(target, mapping, metadata_spec, client, custom_properties=None)

Reconcile source column definitions to the Hoppa spec (UX Step 8).

Returns one :class:ColumnSyncResult per mapped field, carrying an explicit action and the resolved/created column id + types. SharePoint creates/updates definitions to match (CREATED/UPDATED/ UNCHANGED); Autodesk is report-only — it reports VALIDATED / MISSING and cannot alter ACC definitions. Idempotent; the user may proceed when no result is blocking (none carries an error).

write_values abstractmethod

write_values(target, mapping, document, metadata_spec, client, custom_properties=None)

Write one document's mapped values to the source. → UX Steps 11-12.

Per-document by design — the API owns the fan-out loop and progress rows. Must be idempotent (safe under queue retries). May return a PARTIAL status (Autodesk: attributes vs filename rename).

written_as

written_as(client)

The account the source will attribute this run's changes to, if not the user.

Part of the contract, not a per-connector extra: a caller assembling a :class:WritebackReport must be able to ask any connector this and get an answer, rather than probing for the method and defaulting when it is absent.

None is the answer for a connector that authenticates as the Hoppa user — the source's own audit trail already names the right person, so there is nothing extra to record. It is non-None only where capabilities.writes_attributed_to_service_account is True, which is the same fact stated as a flag. Deriving one from the other here means they cannot disagree, and a future connector gets this for free by setting the flag.

The client is asked to name itself (never for a credential); one that cannot yields None.

loose_matches

loose_matches(unmatched, columns)

Match properties to columns, ignoring case, spaces, underscores and hyphens.

Lets "Role Family" match "ROLE_FAMILY". A property is matched only when exactly one column fits, and each column goes to at most one property.

Parameters:

Name Type Description Default
unmatched Mapping[str, list[str]]

Candidate names per Hoppa property key, for the properties that found no exact match.

required
columns list[ColumnDescriptor]

The columns no property has matched yet.

required

Returns:

Type Description
dict[str, ColumnDescriptor]

The matched column per property key. A property with no match, or with

dict[str, ColumnDescriptor]

more than one, is left out.

loose_name

loose_name(name)

Lowercase a name and drop its spaces, underscores and hyphens.

Source-agnostic data types for metadata writeback.

These are the serializable DTOs exchanged across the SDK↔API boundary. They are plain dataclasses with a to_dict() that yields JSON-ready snake_case dicts (enums collapse to their .value); the API layer re-maps to its own camelCase Pydantic models. No HTTP or framework types leak in here — the SDK stays framework-agnostic.

See specs/spec-metadata-writeback.md §4 for the contract these implement.

ColumnAction

Bases: StrEnum

What validate_columns did to (or found for) one mapped column.

SharePoint (can mutate definitions): CREATED / UPDATED / UNCHANGED. Autodesk: VALIDATED (exists and compatible) / CREATED (new definition added at the project root) / MISSING (mapped to an id that isn't there). ERROR marks any blocking failure — the reason is on ColumnSyncResult.error. Non-blocking actions are CREATED/UPDATED/UNCHANGED/VALIDATED; the caller may proceed when no result is ERROR/MISSING (equivalently, when no result carries an error).

ColumnDescriptor dataclass

Bases: _DTO

One writable source column/attribute, normalized for the mapping step.

ColumnKind

Bases: StrEnum

What a writable field is in the source system.

SharePoint fields are all COLUMN. Autodesk splits into ATTRIBUTE (custom attribute, set by id) and NAMING_PART (a component of the file naming standard — writing it reconstructs and renames the file).

ColumnSyncResult dataclass

Bases: _DTO

Per-mapped-field outcome of validate_columns (UX Step 8).

Explicit action (never inferred from create_new) plus the resolved or newly-created source_column_id and its normalized/native types, so the API can pass column ids + types straight through and the frontend can render an authoritative "prepared N columns" readout. error is set (and action is ERROR or MISSING) when the field is blocking.

notice is advisory and never blocking: something the user should know that does not stop the run (e.g. a picklist format switch leaving the old vocabulary in place). error blocks; notice informs.

DocumentPreview dataclass

Bases: _DTO

Current-vs-new values for one document (UX Step 9).

error is set (and the current values left blank) whenever the document's source item could not be read; the SDK surfaces the source's own message rather than classifying per-source error codes. The one deliberate exception is not-found (404): the item no longer exists (deleted/moved) — a terminal state — so available is set False and the app can flag/exclude it. Every other read error keeps available True (the item may still exist; the app shows the message and lets the user decide).

writable is the orthogonal axis: we read this document fine and still cannot write to it. A ProjectWise document in a final workflow state is exactly that — perfectly readable, and guaranteed to reject the write with a 400. Overloading available with a reason code was rejected because it would make one field answer two different questions depending on why it is False, and change the meaning of a field two shipped connectors already depend on. error is always populated when writable is False.

DocumentWriteResult dataclass

Bases: _DTO

Outcome of writing one document (UX Steps 11-12).

Returned per-document — the API owns the fan-out loop and persists these as polling rows. status may be PARTIAL (Autodesk: attributes vs rename).

FieldMapping dataclass

Bases: _DTO

The confirmed mapping for one target: a list of :class:MappedField.

overwrite_existing=False is "fill in the blanks only": a field that already holds any value in the source is left alone, even if Hoppa would write something different. Only genuinely empty fields are filled.

It is enforced at write time rather than by filtering upstream, because that is the only place the guarantee holds — a caller filtering on a preview is working from a snapshot, and a value written by someone else in between would be clobbered anyway. It is per-target-and-run, not per-field: the same answer applies to every mapped field on every document in the run.

skip_uncertain=True (the default) withholds a value that is UNCERTAIN or has low certainty, whatever the source currently holds. Pass False to write those values too. It is also per-target-and-run.

by_key

by_key()

Index the mapped fields by Hoppa property key.

FieldPreview dataclass

Bases: _DTO

Current-vs-new value for one mapped field on one document.

changed always describes the value diff — whether new differs from current — and nothing else. skipped says the write will be withheld even so. Keeping them separate is what lets the UI tell "keeping the existing value" apart from "no change needed": both write nothing, but only the first is discarding a value Hoppa would otherwise have written.

changed=True,  skipped=True   → preserving current, withholding new
changed=False, skipped=False  → already correct, nothing to do
changed=True,  skipped=False  → will be written

current_value and new_value stay populated when skipped, so the user can see both the value being kept and the one being withheld.

skip_reason says which setting withheld the field: preserved_existing_value (overwrite_existing=False), or uncertain_value / low_certainty (skip_uncertain=True). An uncertain value is skipped whether or not it differs from the current one, so changed may be False alongside it.

error marks a field that cannot be written — today, a classification whose code is no longer in the standard. new_value is then None and changed False: the preview shows the field as unwritable rather than inventing a value for it, and write_values reports the same field as FAILED.

FieldWriteResult dataclass

Bases: _DTO

Outcome of writing one mapped field on one document.

intended_value and stored_value are populated only when status is ALTERED, and left None otherwise. That is deliberate rather than incidental: a clean run adds nothing to the persisted job row, which matters because the API's Results field has a 64 KB cap that would otherwise truncate precisely the runs most likely to contain altered fields.

A SKIPPED field carries its reason in error: uncertain_value or low_certainty when skip_uncertain withheld it.

MappedField dataclass

Bases: _DTO

One Hoppa-property → source-column mapping decision.

source_column_id names an existing column; create_new requests a new one (rejected by connectors whose can_create_definitions is False). Properties absent from a :class:FieldMapping are skipped at writeback.

picklist_format selects what a classification is written as, and must be drawn from the connector's supported_picklist_formats. It governs the column's choice list and the value together — they share one derivation — so changing it changes both. No effect on non-classification properties; tags are label-only.

None means "not specified", and the connector fills in its own default_picklist_format (:meth:WritebackConnector.formats_for). It is deliberately not defaulted to a concrete format here: a literal default in this DTO is a second default competing with the one each connector already declares, and the two can disagree — which is exactly what happened when this field defaulted to DESCRIPTION while ProjectWise, whose whole picklist design depends on writing the code, declared CODE. One default, owned by the connector that knows its source.

MappingSuggestion dataclass

Bases: _DTO

Auto-match result for one target (UX Step 5).

matched and needs_update both point at existing columns; needs_update additionally requires a definition sync (e.g. choice list out of date). to_create / unmatched hold Hoppa property keys.

properties describes every property considered, across all four buckets, so the mapping UI can render each row's options without re-deriving them.

PropertyDescriptor dataclass

Bases: _DTO

A Hoppa property as the mapping step sees it, with its writeback options.

picklist_formats is what the user may choose from for this property on this connector — the support is a function of both, which is why it is not a connector-level flag. Empty means no format choice, for either reason:

  • the property is not a picklist at all (freetext, date); or
  • it is a picklist whose label is fixed — tags entries are {id, title} and the id is an internal handle, so a tag is always written as its title.

So the frontend never has to know what a tag is: an empty list means show no picker. A single entry means the format is fixed (an ACC attribute's allowed values belong to ACC, so ["code"]), and several means offer a choice. default_picklist_format is None exactly when the list is empty.

SchemaValidationError dataclass

Bases: _DTO

A blocking issue from validate_columns (UX Step 8).

message carries the source's own error verbatim where relevant (e.g. "Choice column has conflicting values", or an Autodesk missing-attribute). Surfaced inline on the failing :class:ColumnSyncResult.

SuggestedMatch dataclass

Bases: _DTO

An auto-suggested mapping of a Hoppa property to an existing column.

TargetSchema dataclass

Bases: _DTO

Everything the mapping step (UX 5-7) needs for one target.

type_compatibility maps a Hoppa property type (e.g. "classification") to the source WritebackDataTypes it may legally map to, so the frontend can flag conflicts and constrain the create-new type picker.

mapping_advisory carries whole-target caveats to render at the mapping step — things the source will not tell us and the user therefore has to know. Plain strings, not {code, message}: no consumer needs a code today, and adding one later is a smaller change than carrying an unused one until then.

WriteStatus

Bases: StrEnum

Outcome of writing one document, or one field on it.

PARTIAL is required for Autodesk, where custom attributes and the filename rename are two independent writes that can each fail.

ALTERED is a field status: the write succeeded but the source stored something other than what was sent — ProjectWise silently truncates a value to a per-attribute limit it does not publish, and still answers 200. It is deliberately not a failure (the write landed) and deliberately not a success (the source is not holding what Hoppa intends), so it rolls a document up to PARTIAL. Only ProjectWise emits it today; the other connectors do not compare sent against stored.

WritebackCapabilities dataclass

Bases: _DTO

Per-connector capability flags driving graceful UI degradation.

The whole object drives behaviour — never branch on a single flag. Autodesk can create attribute definitions via API but cannot edit existing ones, so can_create_definitions is True and can_update_definitions is False.

Picklist-format support is deliberately not here: it varies by property type as well as by connector (tags are label-only whatever the connector), so it is declared per property on :class:PropertyDescriptor.

creates_new_version and writes_attributed_to_service_account warn the user before they commit to a run — they are read at the targets step, when no :class:TargetSchema exists yet, which is why they live here at connector level rather than per target. Resolving them per target would force a source call at target-list time and defeat the lazy-resolution design.

writes_attributed_to_service_account stays separate from the API's authModel even though the two coincide for every connector today: they answer different questions — who supplies the credential, versus whose name lands in the source's audit trail. A shared login behind a real per-user Connect flow would separate them.

WritebackDataType

Bases: StrEnum

Normalized column/attribute data type, source-agnostic.

Each connector maps its native types onto this vocabulary and preserves the source's own label in ColumnDescriptor.native_type for display.

WritebackReport dataclass

Bases: _DTO

Aggregate of a writeback run — the API logs this as audit "Raw details".

Assembled from the stream of :class:DocumentWriteResults. counts holds at least total/succeeded/failed/partial/skipped.

written_as names the account the source will attribute the changes to, when that is not the Hoppa user who triggered the run. ProjectWise writes as a shared service account, so the audit row has to say which one — without it the log records who asked and the source records something else, and nobody can reconcile the two. None when the connector authenticates as the user, or when the credential cannot name itself.

from_results classmethod

from_results(results, *, target_keys=None, operation='WritebackResults', written_as=None)

Tally a list of per-document results into a report.

WritebackTarget dataclass

Bases: _DTO

A unique write destination within a session.

scope holds the structured source locator — SharePoint {site_id, drive_id}; Autodesk {hub_id, project_id, folder_id}. target_key is derived by :func:workbench.writeback.targets.target_key_for and is the join key across every writeback call.

status_from_fields

status_from_fields(field_results)

Roll one document's per-field outcomes up to its document status.

Shared by every connector so the meaning of a status cannot drift between sources. Precedence, most severe first:

  • nothing to report → SKIPPED
  • every field failed → FAILED
  • some field failed → PARTIAL (a bad field never sinks the document: Autodesk's attributes-vs-rename split, or one unresolvable code)
  • anything was altered → PARTIAL, even when every field was altered. The write landed, so it is never FAILED; the source is not holding what Hoppa intends, so it is never WRITTEN. This generalises PARTIAL from "some fields failed" to "some fields need attention".
  • anything was written → WRITTEN; filling one blank while preserving another still counts as a write
  • anything was withheld → SKIPPED; reserved for documents nothing was written to because existing values were preserved or uncertain values were skipped
  • otherwise → UNCHANGED; nothing needed doing and nothing was withheld

Target identity

Write-target identity — the single source of truth for target_key.

The SDK owns target-key derivation because the source-scope ids are not part of the canonical connection_details projection (workbench/clients/metadata.py) — they are tree-client-injected and provider-shaped. SharePoint carries site_id / drive_id at the top level; Autodesk's write-scope lives in the provider-specific raw.relationships sub-tree, and its hub_id / project_id may be absent. No layer above the SDK should parse connection_details / raw — they call :func:target_key_for instead so both sides derive the identical key.

Formats (see specs/spec-metadata-writeback.md §4a):

  • SharePoint: sharepoint:{site_id}:{drive_id}
  • Autodesk: autodesk:{project_id}:{folder_id} — folder-scoped, because ACC custom attributes and naming standards vary per folder.
  • ProjectWise: projectwise:{repository_id}:{environment_id} — environment-scoped, because the writable attributes are defined by the document's environment, not by where it sits in the folder tree. The repository_id embeds the WSG host, so the key is globally unique without the base URL.

autodesk_scope

autodesk_scope(connection_details)

Extract the Autodesk write-scope from a connection-details blob.

Returns {hub_id, project_id, folder_id, version_id} (any may be None). Centralises all knowledge of the provider-specific raw.relationships shape so no layer above the SDK parses it — the connector and :func:target_key_for both read scope through here.

projectwise_scope

projectwise_scope(connection_details)

Extract the ProjectWise write-scope from a connection-details blob.

Returns {wsg_base_url, repository_id, environment_id, instance_id} (any may be None). The single place the blob's shape is read, so the connector and :func:target_key_for cannot disagree about what a document's target is.

EnvironmentId lives under raw (the verbatim WSG Document properties), with a top-level fallback for older or hand-made blobs — the same precedence the read-side binding already uses.

target_key_for

target_key_for(connection_details)

Derive the stable target_key for a document's source location.

Returns None when the provider is unsupported or the required scope ids are not resolvable from the blob (the caller treats that document as having no write target).

Value access

The single seam for reading quality-controlled metadata values.

Writeback must write the QC-merged value, not raw workflow output. That merge already happens inside the binding: DocumentVersion.results.body overlays workflow results (method="workflow") with AI edits (method="cleanup") and then user edits (method="user", applied last and therefore highest priority) — see AzureBlobResults.body / _get_results in workbench/bindings/azure.py.

So this module does not re-implement the merge; it is the one place the connectors read merged values through, keeping value access uniform and giving a single point to adapt if the overlay model ever changes. Connectors call :func:merged_value / :func:merged_values rather than touching results.body directly.

Note: the returned PropertyValue.value shape varies by provenance — a workflow classification is {"code", "description"}, a search result is {"response", "certainty", "explanation"} and a user edit is {"value": ...}. Connectors pass PropertyValue.value through :func:unwrap_value first, then turn the result into a source-native field value themselves (per-source coercion).

document_label

document_label(document)

Human-facing document name for display (previews, results).

Preference order: name → file_name → id. On external sources DocumentVersion.name is the source file name (e.g. SharePoint's A-101.pdf) while file_name is a storage-facing name derived from the id (the config-blob / zip-member name — see DocumentVersion.file_name), so name must win. For direct uploads the two coincide. file_name and id are last-resort fallbacks for the rare doc with no source name.

is_blank

is_blank(value)

Is a source field effectively empty, i.e. free to fill?

The judgement behind FieldMapping.overwrite_existing=False, and the reason that flag is enforced here rather than upstream: "empty" depends on the source's value shape, which is the connector's knowledge. A field is blank when it is absent, null, an empty/whitespace-only string, or an empty collection (a multi-select with nothing selected).

Deliberately not not value: 0 and False are values a user chose, and treating them as blank would overwrite real data — the precise failure this flag exists to prevent.

A column's default value is likewise not blank. Once SharePoint has applied a default the field holds it, and nothing distinguishes it from a deliberate pick of the same option — so it counts as present and is preserved. That follows the rule as agreed: any value present is left alone.

merged_properties

merged_properties(metadata_spec, custom_properties=None)

Governed spec properties overlaid with ungoverned custom columns.

Custom columns (from Session.custom_properties) share the governed-property shape, so the mapping / validate / write path treats them identically — every connector method merges them here rather than special-casing. None/empty leaves the governed set unchanged, so a backend without custom columns behaves exactly as before.

merged_value

merged_value(document, property_key)

Return the QC-merged :class:PropertyValue for one property, or None.

None means the document has no value for property_key (the property is skipped at writeback). Reading results.body may trigger a lazy load from storage; callers that iterate many properties should hold the result of :func:merged_values rather than calling this repeatedly.

merged_values

merged_values(document, property_keys=None)

Return the QC-merged values for a document as {property_key: value}.

Loads results.body once. With property_keys given, returns only those keys that have a value; with None, returns every property present on the document. Properties absent from the results are omitted (not None-valued), so key in result is a clean "has a value to write" test.

uncertain_reason

uncertain_reason(property_value)

Return why skip_uncertain withholds a value, or None if it does not.

A value is withheld when it is UNCERTAIN (as a string or a classification code) or when its certainty is low. UNCERTAIN is checked first.

Returns:

Type Description
str | None

data:UNCERTAIN_VALUE, :data:LOW_CERTAINTY or None.

unwrap_value

unwrap_value(value)

Return the writable value inside a PropertyValue.value.

A search result is stored as {"response", "certainty", "explanation"} and a user edit as {"value": ...}. Both are reduced to the value they carry. Classifications ({"code", "description"}), lists and scalars are returned unchanged.

Connectors

SharePoint metadata-writeback connector.

Productionizes notebooks/templates/04_sharepoint_metadata_writeback.ipynb into the uniform :class:WritebackConnector contract, reusing the pure mapping helpers in workbench/clients/sharepoint_helpers.py and the Graph calls on SharePointClient.

SharePoint models metadata as list columns: a document library drive is backed by a list, whose columns are field definitions the connector can create and update, and whose list items hold the per-document field values. The connector caches the (drive→list, columns) context per target so a writeback loop over many documents pays those reads once.

SharePointWritebackConnector

Bases: WritebackConnector

Writeback connector for SharePoint document libraries (Microsoft Graph).

client on every source-hitting verb is a SharePointClient the API constructs and injects.

clear_cache

clear_cache(target_key=None)

Drop cached target context (all, or one). The API may call between runs.

Autodesk ACC / BIM 360 metadata-writeback connector.

Autodesk models document metadata two ways, and this connector writes both:

  • Custom attributes — folder-scoped definitions (get_folder_named_attributes) set on a document version by numeric id (post_version_custom_attributes). New definitions can be created (create_folder_custom_attribute, at the project root so subfolders inherit them), but existing ones cannot be altered — so a mapping to an existing attribute is validated report-only: a picklist missing a value it needs is an error to fix in Forma, not something to sync.
  • Naming standard — a folder-scoped, ordered set of filename parts (get_folder_naming_standard). Writing these reconstructs the filename and renames the version (patch_version_attributes with {"name": ...}).

Both are folder-scoped, so a write target is a project + folder (see targets.autodesk_scope / target_key_for); the per-document version (tip) rides on each document. Attributes are written before the rename — doing it the other way round can lock the file. A document can therefore end PARTIAL when one of the two writes fails.

Current values come from two places, because Forma keeps them in two: naming parts are parsed back out of the filename, and attribute values are batch-read (get_versions_custom_attributes). That read is best-effort — on failure preview degrades to blank current values rather than raising. Only value-bearing attributes come back, so an unset one is simply absent and previews as blank.

Forma has no multi-value attribute type and its array type is a single-select drop-list, so tags are written — and created — as text holding a comma-separated join. Picklist vocabulary and written values share one derivation (clients.picklist); see :meth:AutodeskWritebackConnector._acc_create_spec.

AutodeskWritebackConnector

Bases: WritebackConnector

Writeback connector for Autodesk ACC / BIM 360.

client on every source-hitting verb is an AutodeskClient the API constructs and injects.

build_filename

build_filename(naming_standard, values_by_field, file_type)

Reconstruct a filename from the naming standard and per-field values.

Returns (filename, {field_name: status}). values_by_field should hold the intended value for every field (mapped Hoppa value, else current segment, else None to fall back to defaults).

coerce_acc_value

coerce_acc_value(prop_type, value)

Coerce a Hoppa PropertyValue.value to an ACC field string.

Classifications resolve to their code (ACC picklists/naming parts use the short code, e.g. "A", not the description). Tags join to a comma list.

parse_current_segments

parse_current_segments(file_name, naming_standard)

Split a current filename into {field_name: segment} by the standard.

Best-effort positional split on the delimiter (extension stripped). Lets unmapped naming fields keep their current value during a rename instead of being reset to a default.

resolve_autodesk_labels

resolve_autodesk_labels(targets, client)

Upgrade Autodesk target labels from opaque ids to human project names, in place.

discover_targets runs without a client, so Autodesk targets start with a provisional "Autodesk · <project-guid>" label. Given a client, this resolves the real project name via the Forge Data Management API and rewrites display_name. Non-Autodesk targets are left untouched, and it is best-effort: any Forge failure leaves the provisional label in place rather than raising.

Cost is minimised: one get_projects(hub_id) call per DISTINCT hub across the targets (usually one for a whole session) — independent of the number of targets/projects. The call for a hub is reused across every target in that hub. (The hub/account name would be a fixed +1 get_hubs() call if richer labels are ever wanted.)

Writeback connector for Bentley ProjectWise (WSG).

The third connector after SharePoint and Autodesk, and the one whose source is least forthcoming. Three things ProjectWise does not tell us shape almost every decision here, and all three were established empirically (see specs/projectwise-writeback-brief.md §3):

  1. Attribute length limits are invisible. A value longer than an attribute allows is stored truncated, on an HTTP 200, and nothing in MetaSchema says how long "too long" is. The only detection is comparing the write's echo against what was sent — hence WriteStatus.ALTERED.
  2. Read-only is neither reported nor enforced. Every environment property reports ReadOnly: false, including ones ProjectWise Administrator shows as read-only and derives itself — and a write to one succeeds, beating ProjectWise's own derivation. So read_only is always False here, and the user is told plainly rather than guessed at (mapping_advisory).
  3. There are no select lists. Attributes are typed free text, so allowed_values is always empty and no CHOICE column is ever produced.

Two things that were feared and turned out benign: writes merge (unsent attributes survive), and no version is minted — on write or on check-in. So the payload carries only what changed, and a redelivered job is inert.

One inversion worth stating, because intuition gets it backwards: a checkout does not block an attribute write (and the write survives the check-in), while a final workflow status does (HTTP 400). See read_current_values.

client on every source-hitting verb is a :class:~workbench.clients.projectwise_client.ProjectWiseClient the API constructs (via make_projectwise_client) and injects.

Full design: specs/spec-projectwise-writeback.md.

ProjectWiseWritebackConnector

Bases: WritebackConnector

Writeback connector for Bentley ProjectWise environment attributes.

discover_targets

discover_targets(session)

Group the session's ProjectWise documents by repository + environment.

Pure — no source call. The environment name in the display label comes from the dynamic-class list the read-side binding already cached on its own client, so even the label costs no round trip; without a bound client the label falls back to the raw environment id.

Documents whose environment cannot be resolved are excluded: they have no writable attributes, so no target can legitimately claim them. The API reports the count so the wizard can say "N documents have no resolvable environment" rather than dropping them silently.

list_columns

list_columns(target, client)

The environment's attribute definitions, as mappable columns.

read_current_values

read_current_values(target, mapping, documents, metadata_spec, client, custom_properties=None)

Current source value and resolved new value, per doc per mapped field.

One batched read for the whole document set rather than two per document. Each query returns the documents themselves (which carry the workflow state) and their environment instances (which carry the current values).

suggest_matches

suggest_matches(schema, metadata_spec, custom_properties=None)

Match Hoppa properties to attributes by name. Pure.

needs_update is always empty: it means "this column's choice list is out of date", and ProjectWise has no choice lists to be out of date.

validate_columns

validate_columns(target, mapping, metadata_spec, client, custom_properties=None)

Report-only. Never mutates the environment — WSG offers no way to.

A type mismatch is a notice, not an error: every attribute is stored as text-ish anyway and the write will not reject it, so the user is warned rather than blocked.

write_values

write_values(target, mapping, document, metadata_spec, client, custom_properties=None)

Write one document's mapped values. Idempotent under queue retry.

Costs two calls: one to resolve the environment instance (which returns the current values in the same response) and one to write. A document with nothing to change costs one, because the diff finds no difference and no write is issued — that diff is what makes a redelivered job safe regardless of version semantics.

The payload carries only the attributes that changed, which WSG's merge semantics make safe and which keeps a truncation-prone write minimal.

coerce_pw_value

coerce_pw_value(prop_type, value, *, definition, picklist_format=PicklistFormat.CODE, data_type=WritebackDataType.TEXT, property_key=None)

Coerce a Hoppa PropertyValue.value to a ProjectWise attribute string.

Shape-tolerant by necessity: a value's provenance varies (a workflow classification is {"code", "description"}; a user edit carries whatever the edit stored).

Returns None for "no value for this property", which means the field is skipped rather than written blank — writeback fills and corrects, it does not erase.

Raises:

Type Description
UnresolvedPicklistCode

The classification's code is not in the standard. Fails that one field; never falls back to writing the raw code, which would inject an out-of-vocabulary value.

ValueError

A non-numeric value was mapped onto a numeric attribute.

datasource_name

datasource_name(repository_id)

The readable datasource part of a WSG repository id.

environment_name

environment_name(env_class)

The human part of an Env_<id>_<NAME> dynamic class name.

Returns the class name unchanged when it does not follow that shape, so a label is always produced rather than an empty string.