Variant builds

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, …).

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:

build_tags = ["html"]

[needs.variant_data]
platform = "arm"
archs = ["arm", "x86"]

[needs.variant_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.

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.debug0, "" 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);

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

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.

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:

[needs.variant_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.

[needs.variant_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 variant_data / variant_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 "needs.variant_data_file = 'variants2.json'"

The same override works for ubc build index and other commands. Individual values can be overridden directly too, using TOML dotted-key syntax — -c "needs.variant_data.platform = 'x86'" — and -c may be repeated to apply several overrides.

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