Variant builds

VS Code Any LSP editor CLI MCP Stable

Many projects need to maintain one set of requirements that describes several product variants — different hardware platforms, customer editions, build configurations, or deployment targets — and then produce a tailored output for each one.

ubCode supports this through what is often called a “150% model”: a single source of truth that contains the union of all variants (the 150%), from which each build selects the relevant 100%. Instead of copying documents per variant (and keeping the copies painfully in sync), you keep one model and let the build context decide which values, links, and content apply.

See also

variants-demo is a small, runnable project that demonstrates the workflow described here.

The demo also uses the if directive for conditional compilation (including or excluding whole content blocks per variant), documented below.

The build context

A “build” is described by two pieces of configuration:

build_tags

A list of tags describing the current target (for example the builder or environment). These mirror Sphinx’s tags and are available as the build_tags variable in filter expressions.

Variant data

A nested, read-only key-value store exposed under the var.* namespace, holding the parameters of the current variant (platform, architecture, enabled features, …). It lives in the shared [variants] table, or in the legacy [needs] keys ubCode still reads.

Both can be set in ubproject.toml and overridden per build (see Producing a build per variant below), so the same model resolves differently depending on the target.

Five building blocks

Variant builds combine five mechanisms. They share the same filter expression language (see Writing a filter). The first four decide what a document contains and see the full var.* / build_tags context; the fifth decides which documents the build contains at all, and its conditions see var.* only. The fifth has two keys, sharing one grammar and one validator: [[source.variant_sources]] selects files by glob, and if on a [[source.mounts]] entry gates a whole mounted bundle.

1. Variant data and var.* filtering

The variant’s parameters live in variant data, declared inline or loaded from a JSON file, in the shared [variants] table (data and data_file) or in the legacy [needs] variant_data and variant_data_file keys. Sphinx-Needs reads [variants] from 9.0.0 and sphinx-mounts from 0.3.0; an older release ignores the table silently, so a project built with one keeps the [needs] keys (see Variant data). For example:

build_tags = ["html"]

[variants.data]
platform = "arm"
archs = ["arm", "x86"]

[variants.data.build]
compiler = "clang"
features = ["networking", "logging"]

The var.* namespace can then be used in any filter — needextend and needimport directives, external_needs filters, conditional defaults (predicates), and the filters inside variant functions:

.. needextend:: var.platform == "windows"
   :status: supported

.. needimport:: shared.json
   :filter: "arm" in var.archs

Because var.* is global to the build (it does not depend on the current need), it is ideal for build-wide switches.

2. Injecting values with <{ ... }>

A variant data reference substitutes a var.* value directly into a field or link value. The field (or link) must opt in with parse_variants = true:

[needs.fields.arch]
schema = {type = "string"}
parse_variants = true
.. req:: Bootloader
   :id: REQ_001
   :arch: <{ var.platform }>

.. req:: Build banner
   :id: REQ_002
   :arch: built for <{ var.platform }>

This is a plain lookup — there is no condition, just a value taken from the current variant. See Referencing variant data in field values (<{ ... }>) for the embedding rules, type-checking, and the diagnostics emitted for unknown keys or type mismatches.

3. Choosing values with <<...>>

A variant function selects between candidate values using conditional logic. The first matching expression wins; the final comma-separated value is the fallback:

.. req:: Power management
   :id: REQ_003
   :status: <<[var.platform == "windows"]: active, inactive>>
   :priority: <<['html' in build_tags]: web_critical, medium>>

Unlike <{ ... }> (a direct substitution), <<...>> evaluates one or more filter expressions and picks the corresponding value. Those are need filters, so a field on both sides of in works there as it does in a :filter:: <<[owner in tags]: listed, unlisted>> gives each need listed when its owner is one of its tags (see Python syntax). That form is refused only in the conditions below.

4. Conditional content with the if directive

Where <{ ... }> and <<...>> choose a value, the if directive (matching Sphinx-Needs 8.2’s if directive, documented at 8.5.0) includes or excludes a whole block of content — paragraphs, needs, tables, anything — based on a var.* expression. Its argument is a filter expression evaluated against the current variant; a truthy result keeps the body, a falsy result skips it entirely:

.. if:: var.platform == "windows"

   .. req:: Windows power management
      :id: REQ_WIN_PM

      This requirement only exists in the Windows variant.

.. if:: "arm" in var.archs

   This paragraph, and any needs it contains, appear only when the
   ``arm`` architecture is enabled.

When the condition is truthy, the body is treated exactly as if the if wrapper were not there — a need inside it is collected and indexed normally. When the condition is falsy, the body is skipped completely: its needs are not collected, and any targets or references inside it do not resolve — matching Sphinx-Needs.

Supported expression forms

The condition uses the same filter language as everything else (see Writing a filter), which follows Python closely but is not Python. The difference shows up wherever an operation combines values of different types — a comparison or a membership test. Those are strict here (see Writing a filter), so var.debug == 0 is false where Python’s False == 0 is true; a mixed-type < or > is an error that excludes the body rather than a value; and a wrongly-typed in (2 in var.tags against a list of strings) is an error too, where Python would simply answer. Sphinx-Needs evaluates the directive with real Python, so keeping a condition’s operands to one type is what keeps the two tools agreeing.

The supported forms are:

  • comparisons against literals or other var.* fields: ==, !=, <, <=, >, >= (e.g. var.opt_level >= 2, var.platform == "windows")

  • truthiness of a bare field (var.debug — 0, "" and [] are falsy, as in Python)

  • nested attribute access (var.build.compiler == "clang")

  • membership: "x" in var.features, var.platform in ["arm", "x86"], and not in

  • is None / is not None

  • boolean combinators and / or / not, with parentheses

  • the string methods .upper(), .lower(), .startswith("…"), .endswith("…")

Anything else is an error, and the body is excluded, reported as an if.invalid_expression diagnostic. That covers:

  • an unknown var.* key, a syntax error, or a type-mismatched operation (Sphinx-Needs also warns and excludes for these);

  • the filter-language extensions that only make sense for need filters — len(...), search(...) — which Sphinx-Needs would also reject in an if condition (it evaluates with all Python builtins removed, so len raises NameError);

  • a field on both sides of in (var.edition in var.names), which a need filter and a <<[...]>> value accept but a condition does not: test a literal against the field ("pro" in var.names) or the field against a list literal (var.edition in ["pro", "team"]) instead;

  • Python forms outside the list above (bare non-boolean literals like 1, chained comparisons like 1 < x < 5, arithmetic, indexing, …). Note this last group is where ubCode is deliberately stricter than Sphinx-Needs: its full-Python eval would evaluate them, ubCode reports them loudly rather than risk a silent misreading — keep conditions to the supported forms, and to operands of one type, for results that are the same under both tools.

A bare True or False is accepted (they are Python keywords, so Sphinx-Needs evaluates them too): an if condition of True always includes the body and False always excludes it.

An if needs variant data to evaluate against at all: with none configured, every if — .. if:: True included — reports if.invalid_expression and excludes its body, as Sphinx-Needs warns and excludes when needs_variant_data is empty.

Tip

In the editor and preview, a falsy if block is not hidden: the RST preview renders it as a collapsed, greyed block labelled with its condition, and the editor fades the inactive source region (like #if-disabled code in a C editor). This is a deliberate difference from a Sphinx-Needs build, which omits the content entirely — the previewer is a development tool, so seeing that inactive content exists (and why) is useful. The built output (needs.json, the index) still excludes it exactly as Sphinx-Needs does. Nothing written inside an excluded body is reported either — an unknown directive there does not warn, because Sphinx-Needs never parses it, and a substitution defined there does not exist — and a .. default-role:: or a content-less .. rst-class:: inside one does not change the rest of the document. A missing blank line after the if block itself is still reported: that is about where the block ends, which both tools check.

Choosing one of several branches with choose, when and otherwise

Added in version 0.37.0.

Where a set of if blocks would repeat one question, a choose asks it once. Its body holds only when and otherwise directives (and comments); the when conditions are evaluated in order and the first that holds is included. A final otherwise is the default, taken when no when condition holds:

.. choose::

   .. when:: var.arch == "arm"

      .. req:: ARM power management
         :id: REQ_ARM_PM

   .. when:: var.arch == "x86"

      x86 content.

   .. a comment may stand between branches

   .. otherwise::

      The default. It must be the last branch.

These are the names XSLT, JSTL and MSBuild use for the same construct. If you know switch / case: there is a condition on every when, and no subject.

Each condition is the same expression an if takes, evaluated the same way (see Supported expression forms), and the included branch behaves exactly as if the choose and the branch were not there: its needs are collected, and a heading in it is a real section. The other branches are skipped completely, as a falsy if body is. The rules, shared with Sphinx-Needs:

  • The first true when wins. The conditions after it are not evaluated, so a later branch cannot warn.

  • With no condition true, the otherwise is taken; with no otherwise, nothing is included, and nothing warns.

  • Every when needs a condition, and otherwise takes none. A when whose condition was forgotten is reported (choose.when_without_condition) rather than silently becoming the default, and a condition written on otherwise is reported (choose.otherwise_with_condition).

  • A branch written with one colon (.. when: cond), or without the space after :: (.. when::cond), is a comment to the parser, and would hand the choice to the otherwise: so a comment in a choose whose text begins with when or otherwise and then a colon is reported (choose.invalid_child), and the whole choose is skipped. A comment that merely begins with the word, such as .. when we migrate, drop this, stays a comment. In MyST the same holds for a % comment such as % when: cond and for a +++ block break such as +++ when: cond.

  • A mistake skips the whole choose, the otherwise included, and is reported once: a condition that cannot be evaluated before the winning branch (choose.invalid_condition — so a mistake that makes a condition unevaluable, such as a misspelt key or a syntax error, never renders a later branch or the otherwise in its place), a when without a condition or an otherwise with one (see above), an otherwise that is not last, or a second otherwise (choose.otherwise_not_last), anything in the body that is not a when, an otherwise or a comment — a paragraph, a target, a heading, an .. include::, a need written directly in the choose (choose.invalid_child), an argument on choose itself (choose.argument_unsupported), and a body with no branch, empty or of comments only (choose.no_branch). With no variant data configured, a choose is skipped with choose.invalid_condition, reported at the choose, even when it has only an otherwise. When one choose holds two mistakes, only the first one found is reported. The checks run in this order: an argument on choose; the children, in document order (anything that is not a branch or a comment, and a comment that reads like a branch written with one colon); no branch at all; each branch’s own condition, in document order (a when without a condition, an otherwise with one); a second otherwise; an otherwise that is not last; the configuration; and last, the conditions themselves.

  • A when or otherwise outside a choose is reported (choose.branch_outside_choose) and its content skipped.

In MyST the comments are % lines (and +++ block breaks); an HTML <!-- --> comment is not a comment there, so it is an invalid child. The outer fence must be longer than the inner ones — a fence closes at the first later line of at least as many fence characters, so a when fence as long as the choose fence would end the choose early. Use one more backtick per level, or colon fences:

````{choose}
```{when} var.arch == "arm"
ARM content.
```
% a comment
```{otherwise}
The default.
```
````

A sphinx-design tab-item inside the branch taken warns that its parent should be a tab-set, exactly as it does inside a true if, in ubCode and in Sphinx-Needs alike: the branch body is parsed on its own, outside the enclosing tab-set. Inside a tab-set the tab still shows in the set, and the set itself reports nothing; a choose (or an if) that contributes nothing to a tab-set is simply not there. To avoid the warning, put the whole tab-set inside the branch instead.

In the editor, each branch that is not taken fades on its own (choose.inactive, with the reason: its condition was false, or it was never evaluated because an earlier branch was taken), and the branch taken stays crisp; a choose that was skipped fades whole. The preview shows each branch not taken as its own collapsed, greyed block, and ubc build html and ubc build pdf leave it out.

5. Selecting whole files with variant_sources

The four mechanisms above all work inside a document. [[source.variant_sources]] works on the document set itself: it decides which files are part of the build for the current variant.

Each rule pairs a condition with a set of glob patterns. Every rule whose condition is false removes its files from the build; a file that no false rule matches is unaffected:

[variants.data]
edition = "basic"

[[source.variant_sources]]
if = "var.edition == 'pro'"
files = ["reference/pro/**/*.rst"]

[[source.variant_sources]]
if = "'networking' in var.build.features"
files = ["chapters/networking.rst", "specs/net/**"]

With edition = "basic" the first rule is false, so nothing under reference/pro/ is discovered. A removed file is not read at all: it produces no page, has no document name, declares no needs, and nothing in it reaches search, objects.inv, cross-references or needs.json. That is the difference from the if directive, which gates content within a document that still exists.

Rules only ever narrow the discovered set — they never pull in a file that [source] discovery would not have found — and their order does not matter. Several rules may match one file, in which case the file is in the build only if every matching rule is true.

What the globs mean

files patterns use the same pattern dialect and the same base as [source] exclude.

Two consequences worth knowing before you write a rule:

A pattern with no path separator matches by file name. files = ["internal.rst"] gates every internal.rst in the project — and in every mounted tree — not one file. Give a pattern a path (reference/internal.rst) to gate one place.

Two spellings are refused, because they do not mean the same thing to every tool that reads ubproject.toml: {a,b} alternation (write one pattern per alternative), and a pattern that climbs out of the project with ... Either one refuses the configuration — nothing is built until you rewrite the pattern. It is deliberately not a warning that skips the rule: skipping a rule leaves every file it names in the build, including the files its other patterns name, which is the one outcome this key exists to prevent.

The condition

if uses the same expression language as the if directive, narrowed in two ways.

The condition must be a boolean. A bare field (var.debug) is a configuration error here — write var.debug == False instead. So is a bare call to a string transformer, .upper() or .lower(), because it yields a string: write var.name.upper() == 'WIDGET'. The string predicates .startswith(…) and .endswith(…) yield a boolean, so if = "var.name.startswith('Widget')" is a complete, valid condition.

Every field reference must be rooted at var. var.edition == 'pro' is fine; a prefix-less edition == 'pro' is a configuration error, even though the if directive accepts it. So is any other bare name — build_tags included, which belongs to only rather than to a variant condition.

Note

Both narrowings rest on one rule: a rule condition is evaluated over a var-only namespace with Python’s builtins removed. Both engines implement it — ubCode resolves nothing outside var, and sphinx-mounts interprets the condition with no namespace object, no builtins and no eval at all. The direction is the safe one either way: the cost of a narrowing is a configuration error on a condition that would have been fine, never a rule that quietly stops gating files.

The narrowing exists because the same string is meant to be read by two engines, and a value that is not a boolean is where they disagree. So this is accepted:

if = "var.edition == 'pro' and var.count > 1"
if = "'net' in var.build.features"
if = "var.name.startswith('Widget')"
if = "var.edition is not None"

and this is not:

if = "var.debug"              # not a boolean; write `var.debug == False`
if = "edition == 'pro'"       # not rooted at `var`; write `var.edition`
if = "'html' in build_tags"   # `build_tags` is not a variant condition context
if = "len(var.features) > 1"  # `len` is a builtin, and the other engine has none
if = "var.count + 1 > 2"      # arithmetic is outside the grammar

Note that the boolean literals are Python’s True and False, not TOML’s true and false: if holds an expression, not a TOML value, so a lower-case spelling is read as a field name — and, being a bare name, is reported as such with the right spelling suggested.

Both narrowings exist for the same reason: the other engine evaluates this string over a var-only namespace with no builtins, so a form it cannot evaluate must be an error here rather than something ubCode quietly evaluates differently. That is the difference between one rule string and two document sets.

A condition that cannot be evaluated — an unknown var.* key is the common case — is reported and the rule’s files are excluded. That is the same warn-and-exclude contract the if directive has, and it is the safe direction for a rule whose purpose is keeping content out.

Toctrees and the root document

A toctree entry naming a document this variant excluded reports toctree.variant_excluded, naming the document and the rule that removed it — not toctree.nonexisting_document, which would send you looking for a typo that is not there. A :glob: entry whose only matches were excluded reports the same code rather than toctree.empty_glob.

That code is informational, so it does not fail ubc check: in a 150% model a shared index listing every edition’s pages is the normal shape, so it fires on configurations that are perfectly correct. It is still reported, because it is the only place left where a rule that removed more than you meant is visible — the file is gone from everything else. Silence it with [lint] ignore, or for one tree with [lint] per-file-ignores, if you would rather not see it (see Linting).

A rule that is false for the current variant and would remove the configured [project] root_doc is a hard configuration error: the root document is what the navigation tree and the document ordering are built from, so a build without it is not a smaller site. The message names the pattern to narrow; change that, or change root_doc.

Note the difference between the two hard errors. This one is variant-dependent — a rule matching the root document is perfectly legal while its condition holds, so files = ["**"] with a true condition is a valid “this whole tree, this variant only”. The refused-glob error above is variant-independent: a pattern this key cannot interpret is unusable in every variant, so it is refused whatever the variant data says, and you fix it once.

Warning

Whether sphinx-build reads the variant-gating keys depends on what is installed beside it.

Both variant_sources and a mount if are shared-configuration keys. The Sphinx-side reader is sphinx-mounts — a reader for these keys, not a general [source] bridge — so:

  • With a sphinx-mounts release that supports them, sphinx-build narrows its document set the same way ubCode does, and the two tools agree. The toctree entries naming a variant-excluded document are downgraded rather than left to warn.

  • Without it, the same ubproject.toml gives the two tools different document sets: ubCode honours the keys, and sphinx-build builds every file.

Nothing in ubCode can observe which of the two you have, so it reports an informational config.variant_sources_sphinx_unsupported diagnostic on any project that declares either key — the difference is never silent, whichever way it falls.

Until the Sphinx side is known to be reading them, do not run sphinx-build -W on a variant-gating project: a toctree entry naming a variant-excluded document warns, and -W turns that into a build failure.

Gating a whole bundle with a mount if

A rule narrows a file set by glob. Its companion key removes a whole mounted bundle: if on a [[source.mounts]] entry.

[variants.data]
edition = "basic"

[[source.mounts]]
dir = "../bundles/reference-pro"
mount_at = "reference/pro"
attach_to = "index"
if = "var.edition == 'pro'"       # gated off for edition = "basic"

[[source.mounts]]
dir = "../bundles/reference-basic"
mount_at = "reference/basic"
attach_to = "index"
if = "var.edition == 'basic'"     # this one is built

The condition goes through the same validator and the same evaluator as a rule’s, so one condition string means one thing wherever it is written. Everything a rule does on failure, this does too: a false, unreadable or unevaluable condition all gate the mount off, and each is reported.

Reach for it when the unit you are switching is a bundle rather than a set of files. It needs no globs, so it can gate a tree outside the project — which a rule glob deliberately cannot, since a pattern climbing out with .. is refused. It is also the only way to gate a files mount: per-file gating of a file list is supported by neither key, in either tool.

Note

Keep a gated bundle’s document names clear of everything else the build provides. A gated mount whose name the build already uses is not credited with the documents it removed — so references to its pages report toctree.nonexisting_document, and config.mount_gated_contested names the contested document and what claims it. Both tools behave this way, deliberately: crediting the gate with a name that is built would turn a genuine broken reference into an informational note.

Two things can claim it. Two bundles that swap per variant should be given distinct mount_at prefixes; and a mount’s prefix should not lead to a name the host’s own tree already provides — that contest needs a different prefix (or a renamed host document), and no amount of renaming other mounts helps, since there may be none. Clear of both, ubc check and sphinx-build -W are clean.

Each variant is a separate cache

Because the rules and the variant data that decides them are both part of the resolved configuration, switching variants changes the project’s configuration fingerprint — so each variant gets its own cache directory. That is what makes a flip correct (one variant can never read the other’s index), and it means per-variant CI pays a cold index on every flip.

Producing a build per variant

The point of a 150% model is to build it more than once, once per variant, by swapping the build context.

Keep the shared model in your sources and the per-variant parameters in separate files, for example variants1.json and variants2.json:

variants1.json
{"platform": "arm", "build": {"compiler": "clang"}}
variants2.json
{"platform": "x86", "build": {"compiler": "gcc"}}

With ubc, select the file with a -c/--config override (repeatable, accepts any ubproject.toml snippet):

# Default build (uses the [variants] data / data_file from ubproject.toml)
ubc build needs --pretty --output needs.arm.json

# Build the second variant by overriding the data file
ubc build needs --pretty --output needs.x86.json \
  -c "variants.data_file = 'variants2.json'"

The same override works for ubc build index and other commands. For a project that still keeps its variant data in the legacy [needs] keys, override needs.variant_data_file instead: -c "needs.variant_data_file = 'variants2.json'". Individual values can be overridden directly too, using TOML dotted-key syntax — -c "variants.data.platform = 'x86'", or -c "needs.variant_data.platform = 'x86'" in [needs] — and -c may be repeated to apply several overrides. Each is read as its own TOML document, so a [table] header in one does not reach the next; tables they share are merged key by key, arrays of tables ([[…]]) are appended, and any other key given in more than one takes the value of the last. An inline override is merged key by key: it replaces platform and keeps every other value, whether that value comes from the data file or from the project’s own inline table — the same rule that applies across an extend chain. A data-file override replaces the file.

The variant data is always read from one table, and [variants] wins wherever the chain or an override declares it: an override or configuration that sets [variants] data or data_file switches a [needs] project to it, and one that sets [needs] under a [variants] declaration is ignored — both warned. So migrating a shared base moves every child still on [needs] to the base’s table, and each such child’s own [needs] values are dropped (warned at its keys). For -c: -c "variants.data.platform = 'x86'" on a project still on [needs] reads [variants] alone, so the project’s [needs] values (and its data file) are not read, and -c "needs.variant_data.platform = 'x86'" on a [variants] project is ignored; each ignored key is reported as config.variant_data_location. Override in the location the project uses.

When building with Sphinx-Needs itself, pass the equivalent Sphinx config value with -D:

sphinx-build -E . _build -D needs_variant_data_file=variants2.json

Comparing variants

Because each variant is just a different build context, you can use ubc diff to see exactly how the resolved needs differ between two variants.

The --config mode of ubc diff compares the current project against the same project with a configuration override applied — perfect for diffing one variant against another:

# Compare the default variant against variants2.json
ubc diff -c "variants.data_file = 'variants2.json'"

# The same, for a project that keeps its variant data in the legacy [needs] keys
ubc diff -c "needs.variant_data_file = 'variants2.json'"

The output reports which needs changed — added, removed, or modified fields and links — making it easy to review the impact of a variant or to catch unintended differences between targets.

See also