One workbook, many managed Benchling assay-result tables — spread across several sheets, or stacked vertically on one sheet — all stood up from a single Python plan dict, then filled in bulk. This page mirrors the guide: the two layouts and the one rule, the dict that drives them, and the multi-paste that fills them.
A managed Benchling table is a merged title band, a header row, and a set
of named ranges bracketed by <name>_table_start /
<name>_table_end bookmarks. Many such tables coexist in
one of two layouts.
Each tab is its own canvas — tabs name themselves after the assay, and there is no row-collision risk between tables. This is the default; when in doubt, one table per sheet.
Both tables start at column A and share the same columns — that is
fine. The Purity table (the stacked-below NEIGHBOUR)
owns its own row cursor, landing table_gap rows below
Identity's _table_end.
The lane rule: two managed tables on the same
sheet must occupy disjoint rows if they are in the same
lane (overlapping column bands). Tables in disjoint column bands are
independent lanes and may share rows — side-by-side, L-shaped, and
T-shaped layouts across separate column bands are supported. A row
overlap within a lane raises SideBySideNotSupportedError at
paste time. Tables on different sheets have no constraints.
The function's second argument is a
dict[sheet_name, list[schema_id]] — each sheet maps to the
ordered list of assaysch_… ids placed on it. Two ids under
one sheet stack on it, in list order. This is the only
calling shape: there is no positional sheet + list
form and no flat {schema_id : sheet} dict.
Identity maps to a two-id list, so
assaysch_b and assaysch_c stack there in
list order; Potency holds a one-id list, so
assaysch_a gets that tab to itself.
The plan dict is the argument — pass it in a single call, no loop and no flattening. It reads exactly how you think about the layout: "this sheet holds these tables."
# Plan keyed by sheet — this IS the native schema_map argument tables = { "Potency": ["assaysch_a"], # one table on the Potency tab "Identity": ["assaysch_b", "assaysch_c"], # two stacked on Identity, in list order } results = initialise_table_benchling(wb, tables, ctx=ctx)
The sheet is created if absent and reused if present. Each id is resolved
against Benchling (assaysch_… ids only — name lookup is not
supported). results is a flat
list[SchemaInitResult] — one typed object per schema across
all sheets — each exposing the attributes schema_name,
display_name, warehouse_name,
range_name, sheet_name, columns
(a tuple[ColumnInfo, …]), action
("created" or "reinitialised"), and — on reinit
— diff.
When you still want a loop: a single call shares one
column anchor across every sheet. To set a different anchor
column per sheet, or to catch errors sheet-by-sheet, loop and call once
per sheet — each entry is itself a valid one-entry schema_map:
initialise_table_benchling(wb, {sheet: ids}, ctx=ctx, column=…).
paste_df_to_named_range writes a DataFrame into a managed
table addressed by its named-range token (the schema
warehouse name, snake_case). It has a built-in list mode:
pass a list of DataFrames and a matching
list of tokens — the lengths must match; it zips and pastes
each pair (a convenience loop, no batch magic).
paste_df_to_named_range(
wb,
[identity_df, purity_df], # list of DataFrames
["identity_assay", "purity_assay"], # matching list of range tokens
scope_sheet="Identity", # both tables live on this one tab
)
In list mode scope_sheet is a single value
forwarded unchanged to every paste — so list mode is the right tool
only when all the target tables live on the same sheet.
# Each table needs its own scope_sheet → loop the single-paste form.
to_write = {
("potency_assay", "Potency"): potency_df,
("identity_assay", "Identity"): identity_df,
("purity_assay", "Identity"): purity_df,
}
for (token, sheet), df in to_write.items():
paste_df_to_named_range(
wb, df, token, scope_sheet=sheet,
)
Column order is matched per table. Each paste validates
your DataFrame's columns against that table's existing headers,
position by position — data is written positionally
(df.values), so a reordered frame would silently misalign. A
mismatch raises ColumnOrderMismatchError before
anything is written. Match against the warehouse-name token or the header
display text (case- / whitespace-insensitive). If Benchling added columns,
re-initialise that table first so its headers exist, then paste the wider
frame.
List mode shares one scope_sheet. Because the
single forwarded value applies to every pair, never put tables from
different sheets in one list-mode call — loop the single-paste form for
multi-sheet writes. The DataFrame and token lists must also be the same
length, or list mode raises ValueError. The index is never
written (only df.values); a zero-row frame raises
EmptyDataError.