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.

Four building blocks

Variant builds combine four mechanisms. They share the same filter expression language (see Writing a filter) and the same var.* / build_tags context.

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) 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). The supported forms — all matching Python’s semantics, which is what Sphinx-Needs evaluates with — 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 literals like True, 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 for results that are identical under both tools.

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.

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