<nav class="docnav">
  <a href="../index.html">Home</a>
  <a href="normal-tables.html">Manual tables</a>
  <a href="benchling-single.html">One Benchling</a>
  <a href="benchling-multiple.html">Many Benchling</a>
  <a href="plates.html">Plates</a>
  <a href="../concepts/stacked-tables-and-reinit.html">Concepts</a>
  <a href="../reference/tools.html">Reference</a>
</nav>

# Guide: one Benchling-backed table, start to finish

> 🎨 **Colour walkthrough:** [open the interactive visual version](../visualizations/benchling-single.html) — 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](../credentials.md) for how `ctx` is built.

**Related:**
[normal (non-Benchling) tables](normal-tables.md) ·
[multiple Benchling tables / multi-sheet](benchling-multiple.md) ·
[stacked tables & reinit deep mechanics](../concepts/stacked-tables-and-reinit.md) ·
[credential resolution](../credentials.md) ·
[tools reference](../reference/tools.md)

---

## The 7-step pattern

Every single-table workflow is the same loop. The rest of this guide expands
each step.

1. **Initialise** the table from the Benchling schema — `initialise_table_benchling`
2. **Read** existing rows out of the sheet — `import_excel_table` / `scan_named_range_table`
3. **Transform** in pandas (your analysis)
4. **Write** results back into the table — `paste_df_to_named_range`
5. **Colour** rows by outcome — `colour_rows`
6. **Refresh** the table when the Benchling schema changes — re-run `initialise_table_benchling`
7. **Clean up** stale named ranges and **save** — `cleanup_stale_table_names`

```python
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:

```bash
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](../credentials.md);
the short version:

```python
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

```python
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:

```python
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 the
  `named_range` you pass to `paste_df_to_named_range`, `import_excel_table`,
  and `colour_rows`. (Multi-cell, so it never shadows a single-column name.)
- **Schema-column manifest** — a hidden sheet-scoped name
  `{anchor}__schema_columns` whose 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 by `cleanup_stale_table_names`
  and by reinit; missing it where one is required raises `ManifestNotFoundError`.)

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:

<div class="two-cols"><div><div class="col-title">BEFORE — empty "Assay Results" sheet</div>

<div class="grid" style="--cols:3">
<div class="col-label"></div>
<div class="col-label">A</div><div class="col-label">B</div><div class="col-label">C</div>
<div class="row-label">1</div>
<div class="cell empty"></div><div class="cell empty"></div><div class="cell empty"></div>
<div class="row-label">2</div>
<div class="cell empty"></div><div class="cell empty"></div><div class="cell empty"></div>
<div class="row-label">3</div>
<div class="cell empty"></div><div class="cell empty"></div><div class="cell empty"></div>
</div>

</div><div class="arrow-between">→</div><div><div class="col-title">AFTER — title band + header row laid out</div>

<div class="grid" style="--cols:3">
<div class="col-label"></div>
<div class="col-label">A</div><div class="col-label">B</div><div class="col-label">C</div>
<div class="row-label">1</div>
<div class="cell title">Purification Assay</div>
<div class="row-label">2</div>
<div class="cell header">Sample ID</div>
<div class="cell header">Conc (mg/mL)</div>
<div class="cell header">Pass/Fail</div>
<div class="row-label">3</div>
<div class="cell empty">—</div><div class="cell empty">—</div><div class="cell empty">—</div>
<div class="marker">named ranges + manifest created (sheet-scoped)</div>
</div>

</div></div>

Named ranges created (all sheet-scoped):

- `purification_assay` → `A2:C2` (schema anchor, spans header row — bare)
- `purification_assay__sample_id` → `A2`
- `purification_assay__conc_mg_ml` → `B2`
- `purification_assay__pass_fail` → `C2`
- `purification_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 a `named_range` for the whole table.

### Modes & anchoring (single-table essentials)

- **`mode="autopaste"` (default)** — the column comes from `column`
  (int like `3`, Excel letter like `"C"`, or `None` to 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 via `at` (e.g. `"C5"`); the table
  is written there. `column` is not allowed in this mode (and `at` is not allowed
  in autopaste).
- **`table_gap`** controls the blank rows left below the table for stacking
  (relevant when you add more tables later — see
  [benchling-multiple.md](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). |

```python
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).

```python
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) → `df` columns are the **tokens**
  (`sample_id`). `False` → columns are the **display headers** (`Sample ID`).
- `check_empty_columns=True` (default) raises `EmptyDataError` if a column is
  entirely null. Set `False` to tolerate empty columns.
- `include_list=[...]` / `ignore_list=[...]` select a subset (selectors match
  either token or display header). Pass at most one of the two.

```python
df, mapping = import_excel_table(
    wb, "purification_assay", scope_sheet="Assay Results",
    include_list=["sample_id", "conc_mg_ml"],
)
```

> **Tip — match `df` columns to what you'll paste back.** Read with
> `use_name_range_names=True` so 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`):

```python
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).

```python
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

```python
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:

```python
paste_df_to_named_range(
    wb,
    df,
    "purification_assay",
    scope_sheet="Assay Results",
)
```

<div class="grid" style="--cols:3">
<div class="col-label"></div>
<div class="col-label">A</div><div class="col-label">B</div><div class="col-label">C</div>
<div class="row-label">1</div>
<div class="cell title">Purification Assay</div>
<div class="row-label">2</div>
<div class="cell header">Sample ID</div>
<div class="cell header">Conc (mg/mL)</div>
<div class="cell header">Pass/Fail</div>
<div class="row-label">3</div>
<div class="cell data">VS0001</div><div class="cell data">0.812</div><div class="cell data">Pass</div>
<div class="row-label">4</div>
<div class="cell data">VS0002</div><div class="cell data">0.430</div><div class="cell data">Fail</div>
<div class="row-label">5</div>
<div class="cell data">VS0003<span class="tag">end</span></div><div class="cell data">1.204</div><div class="cell data">Pass</div>
</div>

### 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 **reordering `df` to 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](../concepts/stacked-tables-and-reinit.md).)

---

## Step 5 — colour rows by outcome with `colour_rows`

### Signature

```python
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 time
  `column_name` changes value (visual grouping of repeated categories).
- **`"equals_no"`** — colours red any row where `column_name == "No"` exactly
  (pass/fail flag columns).
- **`"values_match"`** — colours rows where `column_name` is in
  `values_to_colour`, using `colour_name` (a name from the built-in map, a CSS
  colour name, or a `#RRGGBB` hex).

Colour the failures red:

```python
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",
)
```

<div class="grid" style="--cols:3">
<div class="col-label"></div>
<div class="col-label">A</div><div class="col-label">B</div><div class="col-label">C</div>
<div class="row-label">2</div>
<div class="cell header">Sample ID</div>
<div class="cell header">Conc (mg/mL)</div>
<div class="cell header">Pass/Fail</div>
<div class="row-label">3</div>
<div class="cell data">VS0001</div><div class="cell data">0.812</div><div class="cell data">Pass</div>
<div class="row-label">4</div>
<div class="cell refused">VS0002</div><div class="cell refused">0.430</div><div class="cell refused">Fail</div>
<div class="row-label">5</div>
<div class="cell data">VS0003</div><div class="cell data">1.204</div><div class="cell data">Pass</div>
</div>

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`.

```python
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](../concepts/stacked-tables-and-reinit.md)):

- **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_end` bookmark) **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):

<div class="two-cols"><div><div class="col-title">BEFORE — schema [Sample ID, Conc (mg/mL), Pass/Fail]</div>

<div class="grid" style="--cols:3">
<div class="col-label"></div>
<div class="col-label">A</div><div class="col-label">B</div><div class="col-label">C</div>
<div class="row-label">1</div>
<div class="cell title">Purification Assay</div>
<div class="row-label">2</div>
<div class="cell header">Sample ID</div>
<div class="cell selected">Conc (mg/mL)</div>
<div class="cell header">Pass/Fail</div>
<div class="row-label">3</div>
<div class="cell data">VS0001</div><div class="cell selected">0.812</div><div class="cell data">Pass</div>
<div class="row-label">4</div>
<div class="cell data">VS0002</div><div class="cell selected">0.430</div><div class="cell data">Fail</div>
<div class="row-label">5</div>
<div class="cell data">VS0003</div><div class="cell selected">1.204</div><div class="cell data">Pass</div>
</div>

</div><div class="arrow-between">→</div><div><div class="col-title">AFTER — column purged, gap closed, table shrinks to A:B</div>

<div class="grid" style="--cols:3">
<div class="col-label"></div>
<div class="col-label">A</div><div class="col-label">B</div><div class="col-label">C</div>
<div class="row-label">1</div>
<div class="cell title" style="grid-column:2 / 4;">Purification Assay</div>
<div class="cell empty"></div>
<div class="row-label">2</div>
<div class="cell header">Sample ID</div>
<div class="cell header">Pass/Fail</div>
<div class="cell cleared">name deleted</div>
<div class="row-label">3</div>
<div class="cell data">VS0001</div><div class="cell data">Pass</div><div class="cell cleared"></div>
<div class="row-label">4</div>
<div class="cell data">VS0002</div><div class="cell data">Fail</div><div class="cell cleared"></div>
<div class="row-label">5</div>
<div class="cell data">VS0003<span class="tag">end</span></div><div class="cell data">Pass</div><div class="cell cleared"></div>
<div class="marker danger">Conc (mg/mL) purged · Pass/Fail slid left into B</div>
</div>

</div></div>

> **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_range` will raise
> `ColumnOrderMismatchError`.

---

## 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

```python
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]]
```

```python
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

```python
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):

```python
from xlwings_package import save_dataframe

save_dataframe(df, default_file_name="purification_assay_results")
```

---

## Full single-table script

```python
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](../concepts/stacked-tables-and-reinit.md). |
| `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](normal-tables.md) — the same
  read/write/colour machinery without Benchling.
- [Multiple Benchling tables & multi-sheet init](benchling-multiple.md) —
  stacking several schemas, per-schema `column` overrides, and multi-sheet `schema_map`.
- [Plates](plates.md) — plate-layout helpers that share the bookmark machinery.
- [Stacked tables & reinit — deep mechanics](../concepts/stacked-tables-and-reinit.md)
  — the lane model, danger zone (including shape detection), reinit column
  add/remove/reorder, and bookmark auto-heal.
- [Credential resolution](../credentials.md) — building the `ctx` you pass here.
- [Tools reference](../reference/tools.md) — every public function's signature.
```