Shared queue-app architecture¶
How the portal (queue_app.py) and local (queue_app_local.py) marimo
notebooks avoid code duplication by sharing a single, B-Fabric-free helper
module (queue_app_shared.py). Read this before editing either notebook.
Motivation¶
The two notebooks solve the same problem (build a sample queue from a set of inputs) through the same widgets, filters, and views. They differ only at the edges: the portal loads samples from B-Fabric and uploads a workunit; the local app uploads a CSV/XLSX and offers a download.
Before the refactoring (commit 105b93b, Jul 2026) that difference was drowned
out by duplication: about 50 cell bodies were byte-identical across the two
files, roughly half of each. queue_app.py was 1573 lines and
queue_app_local.py was 1445. Every change to a shared widget or view had to be
made twice and kept in sync by hand, and the two copies drifted.
The pattern: shared bodies, thin binding cells¶
marimo requires one cell per reactive symbol: a global such as
instrument_field or samples_table must be assigned in its own @app.cell
function so the reactive graph can track it. That constraint is why the two
notebooks cannot simply import each other's cells.
The refactoring splits each shared cell into two parts:
- The substance moves into a plain function in
queue_app_shared.py(no marimo, no B-Fabric), defined once. - A thin binding cell stays in each notebook. It only wires the notebook's reactive inputs into the shared function and returns the output symbol.
A binding cell is now a one-liner over shared:
@app.cell
def _(config, tech_area_field):
name_suffix = shared.make_name_suffix(config, tech_area_field.value)
return (name_suffix,)
The wrapper is unavoidable (marimo needs the cell), but the logic behind it lives in exactly one place.
The shared module is imported once per notebook, in the with app.setup: cell
(from qg.apps import queue_app_shared as shared), next to the other common
imports and configure_logging(). app.setup is marimo's sanctioned home for
imports and constants every cell may use, so binding cells never re-import
shared.
What moved¶
The extracted helpers fall into three categories.
- Views (
render_*) return the marimo layout for a panel: plate/timeline/ visualizations, valid-combinations, the concentration block, validation status, sample-selection content, the sidebar body, and the tabbed layout. - Transforms are pure functions over data:
filter_by_column(which collapses the seven-cell faceted-filter chain into one reusable call),validate_selection,resolve_qc_layout_preview,available_method_names,load_methods_table,resolve_default_qc_frequency,resolve_level_concentrations, andbuild_queue_parameters. - Widget factories (
make_*) buildmo.uicontrols: the menu dropdowns, method fields, the polarity group, the queue-type field (plus its warning), the samples table and editor, the visualization controls, and the tab selector.
As of this writing queue_app_shared.py is about 1010 lines exposing ~45 public
helpers, and the notebooks are 1037 (queue_app.py) and 950
(queue_app_local.py) lines, almost all of it binding cells and the divergent
seam below.
The variable-name contract¶
Both notebooks (and the GUI tests) agree on a fixed set of global names, so a
shared cell body reads and writes the same symbols regardless of which notebook
it is bound in. The contract includes full_samples_df, selected_orders,
container_has_*, queue_input, queue_output_str, and the widget fields
(tech_area_field, instrument_field, samples_table, ...).
Two consequences:
- A shared helper depends on names, not on which app is running.
filter_by_columnis called identically in both notebooks. - The GUI tests drive the notebooks by these names, so one test body can cover
both apps. When a test previously ran only on the portal, moving the logic to
a shared helper made the local equivalent testable too (for example the local
Visualizations tab, added as
tests/gui_local/test_local_viz.py).
Renaming one of these globals in a notebook without updating the contract (and the shared helpers that reference it) will silently break the other app and the tests. Keep the names identical across both notebooks.
What stays divergent (the integration seam)¶
The refactoring deliberately did not try to share the parts that genuinely differ between the two apps. These cells remain per-notebook:
- Sample source (portal: B-Fabric order/sample loading; local: CSV/XLSX
upload and synthesized
selected_orders). - Tech-area / user fields and the sidebar reproduce summary, which the portal derives from the B-Fabric session and the local app synthesizes.
- The
queue_parametersreproduce override (the local app can replay an uploaded params JSON verbatim, bypassingsample_df). - Generation, preview, and the sink (portal: workunit upload; local: file download).
The portal app imports B-Fabric; the local app must not. Keeping the seam in
each notebook is what lets queue_app_shared.py stay B-Fabric-free and
core-installable, so the local app and the test:core-no-bfabric CI job never
pull in the qg[bfabric] extra. Swappable source/sink integrations live under
apps/integrations/ (see AGENTS.md).
Alignment with marimo's modularization model¶
marimo offers three ways to share code, and this design uses them as intended.
- Import a plain Python module (what
queue_app_shared.pyis). Because a marimo notebook is an ordinary.pyfile, a notebook can import normal functions and call them from cells. This is the primary reuse and testability path, and it is where all shared substance lives. - The setup cell (
with app.setup:) holds imports and constants shared by every cell. Both notebooks use it for thesharedimport and common dependencies. @app.function/@app.class_definitionexport a single top-level, pure function or class defined inside a notebook so other scripts or notebooks can import it directly. We deliberately do not use this to share between the two apps (see below).
A fair reading of the marimo docs is: marimo is good at modularizing logic and poor at modularizing the reactive layer, and that split is by design, not an accident.
- Logic modularizes cleanly. Pushing pure functions into a module (or into
@app.function) is explicitly encouraged; it makes the code importable, unit-testable, and editable in a normal editor. This is a real improvement over Jupyter, whose JSON cell format resists extraction. Ourrender_*/ transform /make_*helpers are exactly this. - The reactive graph does not modularize across notebooks. Every global must be defined by exactly one cell (variable uniqueness), execution order comes from the dependency DAG, and there is no "import these cells" or reusable reactive sub-component that spans notebooks. So whenever two notebooks share a reactive symbol, each must carry its own binding cell. The residual boilerplate (the one-line wrappers, the variable-name contract) is a property of the reactive model, not of this design.
Why a shared module rather than @app.function cross-imports. @app.function
could expose a helper from one notebook to the other, but that would make the
local app import the portal notebook, which imports B-Fabric, breaking the
core-installable boundary. A separate module keeps the helpers B-Fabric-free,
testable without spinning up a notebook, and equally reachable from both apps and
the test suite. For this codebase the module is the correct choice; @app.function
is the right tool only for single-notebook reuse.
What the constraint costs, and what it does not. The pattern does not
eliminate every duplicated line; each shared reactive symbol still needs a
one-line binding cell in both notebooks, and the two must agree on global names.
What it does eliminate is duplicated substance: the body that actually does the
work exists once. The binding cells are mechanical and cheap to keep in sync
(marimo check --strict and the GUI parity tests catch drift); the pre-refactor
duplication was ~50 full cell bodies that silently diverged.
Possible refinements (not yet done, not urgent):
- Split
queue_app_shared.py(~1010 lines) intoviews/transforms/widgetssubmodules if it keeps growing; the three categories are already clear seams. - The variable-name contract is enforced only by convention and the GUI tests. A typed container passed to the helpers would make it explicit, but it fights marimo's model (reactive globals are the interface), so the convention plus test coverage is the pragmatic choice for now.
- Audit underscore-prefixed cell-locals: the marimo guidance warns against
over-prefixing, so prefer a fresh descriptive name over
_-prefixing a whole cell's variables where it does not prevent a genuine name clash.
The config editors: one core, marimo + two Dash variants¶
The same "shared substance, thin GUI layer" principle governs the config editors, and it is what let a full authenticated Dash editor be added without a second GUI implementation.
Location note. Only
editor_coreand the marimo editor live in this repo. The Dash editors were extracted to the separateqg-dashpackage (sibling../qg_dashrepo), which depends onqgand importseditor_core,_bfabric_auth, and the GitLab bridge from it. The paths below prefixedqg_dash/refer to that package; the architecture is unchanged by the split (that it could be split cleanly is the point of the shared core).
qg.apps.editor_core(this repo) is the framework-neutral core (no marimo, dash, B-Fabric, or GitLab imports):compact_toml, the table/TOML section contracts, and reconstruction of a validatedQGConfigurationfrom browser/table payloads (build_config_from_payload,config_from_dataframes,methods_store_from_config). Apreserve_commentsflag lets the validate-only path drop TOML header comments while the save/review paths round-trip them.config_editor.py(marimo, this repo) binds to the core: its cells convert data-editor DataFrames to row-dicts and delegate reconstruction. It keeps the marimo-only concerns: the B-Fabric employee gate,write_allsave, and GitLab review submission.qg_dash/app.py(qg-config-viewer, inqg-dash) is the local, validation-only Dash editor:create_app()=_layout+_register_callbacks, calling the core for validation. It imports no B-Fabric/GitLab (guarded by a test).qg_dash/full_app.py(qg-editor-dash, inqg-dash) is the full editor.create_full_appreuses the local_layoutand_register_callbacksand adds only save/review controls, a session banner, and the employee gate. The portal seam lives inqg_dash/integrations.py(the one Dash file importing B-Fabric/GitLab): session resolution,save_config(write_all), andsubmit_review(submit_config_changes).
Reusing the ASGI auth stack for a WSGI app¶
The B-Fabric auth is ASGI middleware (create_bfabric_fastapi_app) that sets
scope["meta"] and is agnostic to the app it wraps. Dash is a WSGI (Flask) app, so
create_bfabric_wsgi_app wraps it as ASGI with an asgiref WsgiToAsgi subclass
that copies scope["meta"] into the WSGI environ under WSGI_META_KEY. The Dash
callbacks then recover the authenticated session with the same resolve_app_session
the marimo apps use, reading flask.request.environ[WSGI_META_KEY]. Production
serves it via qg_dash/bfabric_dash_editor.py (in qg-dash), parallel to this
repo's bfabric_app_editor.py. create_bfabric_wsgi_app stays here in
apps/_bfabric_auth.py.
This is why marimo vs Dash matters for app variants: Dash's layout is a plain
composable function and its callbacks are functions registered on an app object,
so create_full_app parametrizes one body (local layout + callbacks) into a second
variant. marimo cannot share a reactive graph across notebooks, so its two apps
carry the binding-cell boilerplate described above. Both, though, keep their
substance in a shared plain module — the constant across both frameworks.
Working on the apps¶
- A change to a shared widget, view, or transform goes in
queue_app_shared.py, once. Both notebooks pick it up through their binding cells; no second edit. - A change to config-editor parsing/serialization/reconstruction goes in
editor_core.py, once; the marimo editor here and the Dash editors inqg-dashpick it up. B-Fabric/GitLab concerns stay inconfig_editor.py(marimo) orqg-dash'sqg_dash/integrations.py(full Dash), never in the core. - A change to how an app loads samples or emits its output goes in that
notebook's seam cell (or the relevant
apps/integrations/module), not in the shared module. - Never import B-Fabric from
queue_app_shared.py. If a helper needs data that only B-Fabric can provide, pass it in as an argument from the portal notebook's seam. - After editing either notebook, run
marimo check --stricton both and thetest_queue_app_shared.pyunit tests plus the GUI suites; the refactoring is guarded by unit tests for the pure helpers and by GUI parity tests that run the same body against both apps.