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 —Nonewhen that is the Hoppa user) andclear_cache(target_key)(drop cached schema between runs). Neither needs agetattrprobe 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_columnsis 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 wholeWritebackCapabilitiesobject.
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_valuecarry the evidence, and only onALTERED, so a clean run stays small. A truncated field reportsALTEREDon every run until the value is shortened at source — it never converges, deliberately, because the discrepancy is still true.DocumentPreview.writable—availableanswers "could we read it" and its false branch means the item is gone.writableis 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 —
tagsentries 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 neverFAILED; the source is not holding what Hoppa intends, so it is neverWRITTEN. This generalisesPARTIALfrom "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. Therepository_idembeds 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: |
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_attributeswith{"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):
- 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. - 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. Soread_onlyis always False here, and the user is told plainly rather than guessed at (mapping_advisory). - There are no select lists. Attributes are typed free text, so
allowed_valuesis 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.