Guide: one Benchling-backed table, start to finish
🎨 Colour walkthrough: open the interactive visual version — colour before/after Excel-grid diagrams of every step. The Unicode grids below are the always-available text version.
This guide walks the full lifecycle of a single Benchling assay-result table in Excel: pull the schema’s columns from Benchling, lay out a managed table (title + headers + named ranges + manifest), read existing rows back, transform them in pandas, write results back, colour them, refresh the table when the schema changes in Benchling, and tidy up.
Everything here drives a live Excel workbook through xlwings (Excel COM on
Windows) and the public API exported from xlwings_package. Schema metadata
comes from Benchling through a context object (ctx) you build yourself.
Audience & scope. You are a scientist/analyst running Python against an open workbook. Benchling credentials are out of scope for this package — you construct a
BenchlingContext(ctx) and pass it in. See credentials.md for howctxis built.
Related: normal (non-Benchling) tables · multiple Benchling tables / multi-sheet · stacked tables & reinit deep mechanics · credential resolution · tools reference
The 7-step pattern
Every single-table workflow is the same loop. The rest of this guide expands each step.
- Initialise the table from the Benchling schema —
initialise_table_benchling - Read existing rows out of the sheet —
import_excel_table/scan_named_range_table - Transform in pandas (your analysis)
- Write results back into the table —
paste_df_to_named_range - Colour rows by outcome —
colour_rows - Refresh the table when the Benchling schema changes — re-run
initialise_table_benchling - Clean up stale named ranges and save —
cleanup_stale_table_names
import xlwings as xw
from xlwings_package import (
initialise_table_benchling,
import_excel_table,
paste_df_to_named_range,
colour_rows,
cleanup_stale_table_names,
)
wb = xw.Book.caller() # or xw.Book("MyWorkbook.xlsm")
Step 0 (external) — find your assay-result schema id in Benchling
This step happens in the Benchling web UI, not in this package. It is external to
mgtx-xlwings-utils; the guidance below is practical orientation, not an API contract.
initialise_table_benchling accepts Benchling ids only — strings with the
assaysch_ prefix (e.g. assaysch_abc123). Free-text name lookup has been
removed: schema retrieval is fully delegated to mgtx-benchling-wrapper, which
exposes no name-search endpoint for assay-result schemas. The package raises a
ValueError if you pass anything that does not start with assaysch_.
A result schema (what the package internally builds via
SchemaHandler.build_schema_definition(schema_id, "assay_results_schema"))
maps to one Excel table:
- the schema’s name becomes the table title (the merged top row), and
- each schema field becomes one column — its display label is written into the header cell, and its warehouse (system) name becomes the column’s named-range token.
Where to get the id (typical Benchling UI):
- Open the assay result schema in Benchling (Registry / Schemas → the result schema you record this assay against).
- The id appears in the page URL and/or the schema’s detail panel — it is
the token that starts with
assaysch_. Copy that whole token. - If you only know the schema by name, ask a Benchling admin (or use your
internal schema catalogue) to resolve the name →
assaysch_…id. The package will not search by name for you.
You will pass this id inside schema_map — as one entry of the per-sheet id list.
Step 1a — install the Benchling extra & build ctx
The Benchling integration depends on mgtx-benchling-wrapper, which is an
optional extra. The module imports it lazily — only when a Benchling
function actually runs — so a plain import xlwings_package works without it.
To use initialise_table_benchling, install the extra:
pip install "mgtx-xlwings-utils[benchling]"
If the extra is missing, the first call to initialise_table_benchling
raises:
ImportError: mgtx-benchling-wrapper is required.
Install with: pip install "mgtx-xlwings-utils[benchling]"
Build the context. Credential resolution is your responsibility — load the
secret however your deployment does (env var, config.yaml, keystore) and pass
the constructed ctx. Full discussion in credentials.md;
the short version:
from mgtx_benchling_wrapper.context.benchling_context import BenchlingContext
ctx = BenchlingContext(
client_id="your-oauth2-app-client-id",
client_secret=my_resolve_secret(), # plaintext, your loader
base_url="https://yourorg.benchling.com",
token_url="https://yourorg.benchling.com/api/v2/token",
)
What ctx is, in one line: a mgtx_benchling_wrapper BenchlingContext
(or any duck-typed object exposing a callable .benchling() method). The
package never sees your client_id / client_secret directly — it only calls
ctx.benchling() to obtain the underlying client and pull schema metadata.
Step 1b — initialise_table_benchling: lay out the table
Signature
initialise_table_benchling(
wb,
schema_map, # dict[sheet_name, list[assaysch_… ids]]
*,
ctx, # your BenchlingContext
mode="autopaste", # "autopaste" (auto-stack) or "paste" (pin to a cell)
column=None, # autopaste: int / "A" / {schema_id: col} / None (auto-detect)
at=None, # paste: top-left cell, e.g. "C5"; defaults to "A1"
table_gap=2, # empty rows between stacked tables
) -> list[SchemaInitResult]
For one table, the simplest call maps the target sheet to a one-element id list:
results = initialise_table_benchling(
wb,
# sheet (created if it doesn't exist) → [schema id from Step 0]
{"Assay Results": ["assaysch_abc123"]},
ctx=ctx,
)
What it writes onto the sheet
For each schema, the function lays out a managed table:
- Title row — the schema’s display name, bold, centred, with a blue
fill (
(189, 214, 238)). For a multi-column schema the title cell is merged across all columns. - Header row (directly below the title) — one cell per schema field, holding
the field’s display label, bold, with a lighter blue fill
(
(221, 235, 247)), and an outside border around the whole header row. - Per-column named ranges — each header cell gets a sheet-scoped name,
namespaced as
{anchor}__{token}(e.g.purification_assay__sample_id) so two tables that share a header on one sheet never collide. You still reference a column by its bare token (snake_case, lowercased — e.g.sample_id) when reading/writing/colouring; the readers and colour helpers strip and reconstruct the namespace for you. - Schema anchor name — a sheet-scoped name (the schema’s warehouse token,
e.g.
purification_assay) spanning the whole header row. This is thenamed_rangeyou pass topaste_df_to_named_range,import_excel_table, andcolour_rows. (Multi-cell, so it never shadows a single-column name.) - Schema-column manifest — a hidden sheet-scoped name
{anchor}__schema_columnswhose formula encodes the canonical column list (e.g.="sample_id,concentration,pass_fail"). The manifest records which columns Benchling owns, so a later refresh can tell schema columns apart from manual named ranges you may have added. (Used bycleanup_stale_table_namesand by reinit; missing it where one is required raisesManifestNotFoundError.)
Data bookmarks {anchor}_table_start / {anchor}_table_end are not written
at init time — they are created on the first paste_df_to_named_range
(Step 4) and then track the live data extent.
Before / after
Suppose the schema Purification Assay (warehouse token purification_assay)
has three fields: Sample ID, Conc (mg/mL), Pass/Fail. Anchoring at column
A on a blank sheet:
Calling initialise_table_benchling(wb, {"Assay Results": ["assaysch_abc123"]}, ctx=ctx)
on an empty sheet lays out the title band and header row:
Named ranges created (all sheet-scoped):
purification_assay→A2:C2(schema anchor, spans header row — bare)purification_assay__sample_id→A2purification_assay__conc_mg_ml→B2purification_assay__pass_fail→C2purification_assay__schema_columns→ manifest formula (hidden)
Column tokens are derived from the schema’s warehouse names, lowercased and sanitised to
snake_case(Conc (mg/mL)→conc_mg_ml). Use the token when you read/write columns, and use the schema anchor name when you pass anamed_rangefor the whole table.
Modes & anchoring (single-table essentials)
mode="autopaste"(default) — the column comes fromcolumn(int like3, Excel letter like"C", orNoneto auto-detect the leftmost managed bookmark on the sheet). The table is placed at the next free row.mode="paste"— you supply an exact cell viaat(e.g."C5"); the table is written there.columnis not allowed in this mode (andatis not allowed in autopaste).table_gapcontrols the blank rows left below the table for stacking (relevant when you add more tables later — see benchling-multiple.md).
If the target band already contains values, the function refuses to overwrite
and raises TableSpaceOccupiedError.
Return value
A flat list[SchemaInitResult], one entry per schema. For a single table you
get a one-element list. SchemaInitResult is a frozen, slotted dataclass —
access fields by attribute (results[0].action), not subscript. Its
attributes:
| Attribute | Meaning |
|---|---|
schema_name |
Human-readable schema name (or system name fallback). |
display_name |
Title text written into the sheet. |
warehouse_name |
Schema system (warehouse) name. |
range_name |
Schema anchor token (bare, e.g. purification_assay). |
sheet_name |
Target sheet. |
action |
"created" on first run, "reinitialised" on a refresh. |
columns |
tuple[ColumnInfo, …]; each ColumnInfo has display_name, warehouse_name, and range_name (the namespaced per-column Excel name). |
diff |
dict or None; populated only when action == "reinitialised" (see Step 6). |
table = results[0]
print(table.action) # "created"
print(table.range_name) # "purification_assay" (schema anchor token)
print([c.warehouse_name for c in table.columns])
# ['sample_id', 'conc_mg_ml', 'pass_fail'] (bare tokens — what you read/write by)
print([c.range_name for c in table.columns])
# ['purification_assay__sample_id', 'purification_assay__conc_mg_ml', 'purification_assay__pass_fail']
An INFO log line is also emitted per schema:
Schema 'Purification Assay' → Assay Results [created]
Step 2 — read existing rows out of the table
Two readers are available. Both return (DataFrame, mapping) where mapping
is {range_name_token: display_header}.
import_excel_table — anchor-driven (recommended)
Pass the schema anchor name. Data rows are discovered by scanning down from
the header; the _table_start / _table_end bookmarks are auto-healed (written
if missing, grown if stale).
df, mapping = import_excel_table(
wb,
"purification_assay", # the schema anchor token
scope_sheet="Assay Results",
use_name_range_names=True, # columns keyed by token (sample_id, ...)
)
# mapping == {"sample_id": "Sample ID", "conc_mg_ml": "Conc (mg/mL)", "pass_fail": "Pass/Fail"}
use_name_range_names=True(default) →dfcolumns are the tokens (sample_id).False→ columns are the display headers (Sample ID).check_empty_columns=True(default) raisesEmptyDataErrorif a column is entirely null. SetFalseto tolerate empty columns.include_list=[...]/ignore_list=[...]select a subset (selectors match either token or display header). Pass at most one of the two.
df, mapping = import_excel_table(
wb, "purification_assay", scope_sheet="Assay Results",
include_list=["sample_id", "conc_mg_ml"],
)
Tip — match
dfcolumns to what you’ll paste back. Read withuse_name_range_names=Trueso the DataFrame is keyed by token. The column tokens then line up positionally with the header on write-back (Step 4).
scan_named_range_table — explicit start/end bookmarks
When you’d rather drive off the data bookmarks directly (the header row is
inferred as the row above _table_start):
df, mapping = scan_named_range_table(
wb,
"purification_assay_table_start",
"purification_assay_table_end",
scope_sheet="Assay Results",
use_name_range_names=True,
)
Column width is found by scanning header cells rightward until the first blank,
so keep the header row contiguous (no blank separator columns). On a
freshly-initialised table that has never been pasted into, the bookmarks don’t
exist yet — prefer import_excel_table for the first read.
Step 3 — transform in pandas
Ordinary pandas, plus a few package helpers (strip_strings,
limit_columns_to_decimals, …). Keep the row order stable if you intend to
colour rows afterwards (colouring is positional).
from xlwings_package import strip_strings, limit_columns_to_decimals
df = strip_strings(df)
df = limit_columns_to_decimals(df, ["conc_mg_ml"], decimals=3)
df["pass_fail"] = df["conc_mg_ml"].apply(
lambda v: "Pass" if (v is not None and v >= 0.5) else "Fail"
)
Step 4 — write results back with paste_df_to_named_range
Signature
paste_df_to_named_range(
wb,
df, # DataFrame (or list of DataFrames)
named_range, # schema anchor token (or list of tokens)
scope_sheet=None,
sanitize_formulas=True, # apostrophe-escape =/+/-/@ text cells
) -> None
Write your transformed df back under the schema anchor:
paste_df_to_named_range(
wb,
df,
"purification_assay",
scope_sheet="Assay Results",
)
Column-order matching to the Benchling headers
paste_df_to_named_range writes df.values positionally under the header.
Before writing, it checks each DataFrame column against the existing header at
the same position (matching against either the per-column token or the header
display value, case/whitespace-normalised). A mismatch raises
ColumnOrderMismatchError instead of silently misaligning data.
- âś… Read with
use_name_range_names=True, transform, paste back → columns line up by token. Safe. - ⚠️ If you reorder, insert, or drop a column in the middle of the DataFrame so
it no longer matches the sheet header order, you’ll get
ColumnOrderMismatchError. Fix by reorderingdfto match the header, or re-initialise the table (Step 6) so the new schema order is applied first.
A DataFrame wider than the header auto-extends the header (new header cells
take their labels from df.columns); a narrower DataFrame clears the
trailing data cells and the _table_end bookmark tracks the narrower extent.
Only df.values is written — the index is never written.
sanitize_formulas (default True)
By default, text cells beginning with =, +, -, or @ are
apostrophe-escaped so externally-sourced data can’t smuggle in a live Excel
formula (CSV/formula injection). Numbers and numeric-looking strings
("-5.3", "+1e3") are left untouched, and bio labels like "+/+" / "-/-"
survive as text. Pass sanitize_formulas=False only when you trust the
source and deliberately want formula strings written through verbatim.
Re-running the paste is safe: existing data rows are cleared and the new rows
written, with rows inserted/deleted below so any table stacked underneath shifts
to preserve its gap. A table in a disjoint column band (different lane) is never
moved. (Row overlap within the same lane raises SideBySideNotSupportedError,
and loose content or shapes directly below the table raise
TableSpaceOccupiedError. Deep mechanics:
stacked tables & reinit.)
Step 5 — colour rows by outcome with colour_rows
Signature
colour_rows(
wb,
name_range, # schema anchor token (header row)
df, # same row order as the sheet
mode, # "group_alternate" | "equals_no" | "values_match"
column_name, # df column driving the colour logic
scope_sheet=None,
values_to_colour=None, # required for mode="values_match"
colour_name=None, # required for mode="values_match"
)
Three modes:
"group_alternate"— alternates light-blue / white bands each timecolumn_namechanges value (visual grouping of repeated categories)."equals_no"— colours red any row wherecolumn_name == "No"exactly (pass/fail flag columns)."values_match"— colours rows wherecolumn_nameis invalues_to_colour, usingcolour_name(a name from the built-in map, a CSS colour name, or a#RRGGBBhex).
Colour the failures red:
colour_rows(
wb,
name_range="purification_assay",
df=df,
mode="values_match",
column_name="pass_fail",
values_to_colour=["Fail"],
colour_name="light red",
scope_sheet="Assay Results",
)
Colouring is positional against the header row — the DataFrame must be in
the same row order as the sheet. Raises EmptyDataError if column_name is
missing from df or entirely null.
Step 6 — refresh the SAME table when the Benchling schema changes
When fields are added, renamed, reordered, or dropped in Benchling, re-run
initialise_table_benchling on the same sheet with the same schema id. The
function sees the existing schema anchor and reinitialises in place rather
than creating a new table. The returned SchemaInitResult’s action becomes
"reinitialised" and carries a diff.
results = initialise_table_benchling(
wb,
{"Assay Results": ["assaysch_abc123"]},
ctx=ctx,
)
print(results[0].action) # "reinitialised"
print(results[0].diff)
# {'updated': [...], 'removed': [...], 'added': [...], 'rebound': [...],
# 'reordered': [...]}
What reinit does (summary — full mechanics in stacked tables & reinit):
- Renames — column display names and the title are re-synced from Benchling on every call (Benchling is the source of truth).
- Added columns — appended/inserted to reflect the new schema order; named ranges and the manifest are updated.
- Reordered columns — the active band is rewritten in schema order and named ranges are rebound in place.
- Dropped columns are purged. A column removed from the Benchling schema is
removed outright: its cells are cleared, its named range deleted, the
remaining schema columns close up the gap contiguously, and the table
extent (schema anchor width, title merge, and
_table_endbookmark) shrinks to the new rightmost column — all without disturbing any table stacked below or any table in a neighbouring lane.
Dropped-column behaviour, before / after (Conc (mg/mL) removed from the
schema):
After a refresh that changed columns, re-read before you write back. Read the table again (Step 2) so your DataFrame’s columns match the new header order — otherwise the next
paste_df_to_named_rangewill raiseColumnOrderMismatchError.
Step 7 — clean up stale names, then save
#REF! names accumulate when a user deletes a column manually in Excel — the
name lingers but resolves to =#REF!#REF!. cleanup_stale_table_names prunes
all such broken names for the managed table.
Signature
cleanup_stale_table_names(
wb,
table_name, # the schema anchor token (e.g. "purification_assay")
*,
sheet=None, # restrict to one sheet (None = every sheet hosting the manifest)
) -> dict[str, list[str]]
report = cleanup_stale_table_names(
wb,
"purification_assay",
sheet="Assay Results",
)
# {"removed_ref": ["Assay Results!conc_mg_ml"]}
table_name is the schema anchor token; it must match the manifest name
{table_name}__schema_columns. If no manifest is found on the targeted
sheet(s), it raises ManifestNotFoundError (this function only manages tables
written by initialise_table_benchling). A non-existent sheet= raises
WorksheetNotFoundError.
Save
wb.save() # save the workbook in place
Or export the analysed DataFrame to a separate file via the package’s
save_dataframe (opens a save dialog by default):
from xlwings_package import save_dataframe
save_dataframe(df, default_file_name="purification_assay_results")
Full single-table script
import xlwings as xw
from mgtx_benchling_wrapper.context.benchling_context import BenchlingContext
from xlwings_package import (
initialise_table_benchling,
import_excel_table,
paste_df_to_named_range,
colour_rows,
cleanup_stale_table_names,
limit_columns_to_decimals,
strip_strings,
)
wb = xw.Book.caller()
ctx = BenchlingContext(
client_id="your-oauth2-app-client-id",
client_secret=my_resolve_secret(),
base_url="https://yourorg.benchling.com",
token_url="https://yourorg.benchling.com/api/v2/token",
)
SHEET = "Assay Results"
SCHEMA_ID = "assaysch_abc123"
# 1. Initialise (or refresh) the table from the Benchling schema.
# Dropped Benchling columns are always purged (cells cleared, gap closed).
results = initialise_table_benchling(wb, {SHEET: [SCHEMA_ID]}, ctx=ctx)
anchor = results[0].range_name # schema anchor token (Excel named range)
# 2. Read existing rows (skip if the table is brand new / empty).
try:
df, mapping = import_excel_table(
wb, anchor, scope_sheet=SHEET, use_name_range_names=True,
)
except Exception:
df = None # no data yet — populate df from your own source instead
# 3. Transform.
if df is not None:
df = strip_strings(df)
df = limit_columns_to_decimals(df, ["conc_mg_ml"], decimals=3)
df["pass_fail"] = df["conc_mg_ml"].apply(
lambda v: "Pass" if (v is not None and v >= 0.5) else "Fail"
)
# 4. Write results back (column order must match the header).
paste_df_to_named_range(wb, df, anchor, scope_sheet=SHEET)
# 5. Colour the failures.
colour_rows(
wb, name_range=anchor, df=df, mode="values_match",
column_name="pass_fail", values_to_colour=["Fail"],
colour_name="light red", scope_sheet=SHEET,
)
# 7. Tidy stale names and save.
cleanup_stale_table_names(wb, anchor, sheet=SHEET)
wb.save()
Common errors & fixes
| Error | When it fires | Fix |
|---|---|---|
ImportError: mgtx-benchling-wrapper is required |
First Benchling call without the optional extra installed. | pip install "mgtx-xlwings-utils[benchling]". |
ValueError: Schema lookup requires a Benchling ID … |
A schema_map id value doesn’t start with assaysch_. |
Pass the assaysch_… id (Step 0). Name lookup is unsupported. |
ManifestNotFoundError |
cleanup_stale_table_names can’t find {table_name}__schema_columns on the targeted sheet(s). |
Confirm table_name is the schema anchor token and the table was created by initialise_table_benchling; check the manifest wasn’t deleted manually. |
NamedRangeNotFoundError |
A reader/writer/colour call can’t resolve the anchor or a column token. | Verify the token spelling and scope_sheet; re-run initialise_table_benchling to (re)create the named ranges. |
TableSpaceOccupiedError |
Init target band is occupied, or loose content sits in a table’s danger zone below it on paste. | Move the stray content, choose a different sheet, or anchor elsewhere (column / at). |
ColumnOrderMismatchError |
paste_df_to_named_range finds df.columns don’t match the header at the existing positions. |
Reorder df to match the header, or re-initialise the table (Step 6) to apply the new schema before pasting. |
SideBySideNotSupportedError |
Two managed tables in the same lane (overlapping columns) have overlapping rows. | Tables within the same column band must be vertically stacked. Tables in disjoint column bands (different lanes) sharing rows are allowed. See stacked tables & reinit. |
EmptyDataError |
Read found an empty table/column, or colour_rows/paste got a column that’s empty or zero-row. |
Ensure the table has data; for reads, set check_empty_columns=False to tolerate null columns. |
ExcelColumnOverflowError |
Placing/widening the schema would push a column past Excel’s last column (XFD). | Use a leftmost anchor with more room, or split the schema across sheets. |
Related
- Normal (non-Benchling) managed tables — the same read/write/colour machinery without Benchling.
- Multiple Benchling tables & multi-sheet init —
stacking several schemas, per-schema
columnoverrides, and multi-sheetschema_map. - Plates — plate-layout helpers that share the bookmark machinery.
- Stacked tables & reinit — deep mechanics — the lane model, danger zone (including shape detection), reinit column add/remove/reorder, and bookmark auto-heal.
- Credential resolution — building the
ctxyou pass here. - Tools reference — every public function’s signature. ```