Skip to content

Commit dfa857e

Browse files
authored
✨ Add needs_variant_data — rich structured variant data (deprecates needs_filter_data) (#1715)
## Summary Introduces `needs_variant_data` (inline nested dict) and `needs_variant_data_file` (path to JSON file) as new configuration options, providing a namespaced, structured replacement for the flat `needs_filter_data`. In filter expressions, the data is accessible under the `var` namespace: ```rst .. needtable:: :filter: var.cpu == "arm" and var.build.debug == True .. needtable:: :filter: "feature_a" in var.build.features ``` `needs_filter_data` is deprecated with a warning pointing users to the new option. ## Motivation - `needs_filter_data` is flat (`dict[str, str]`) and injects keys at root level, risking collisions with need field names. - Users working with kconfig-like systems need nested structured data (e.g. `{"build": {"arch": "arm", "debug": true}}`). - A dedicated `var` namespace avoids collisions and has been developed for compatibility with the ubcode query engine which already parses `var.a.b` attribute chains natively. ## Design Decisions | Decision | Choice | |----------|--------| | Namespace | `var` (e.g. `var.cpu == "arm"`) | | Config style | Single slot + file override (no named profiles) | | Switching | `sphinx-build -D needs_variant_data_file=other.json` | | `filter_data` | Deprecated with warning, keeps working | | Leaf types | `str`, `bool`, `int`, `float` | | Arrays | Uniform scalar type only | | Precedence | File loaded first, inline deep-merged on top (inline wins) | | Attribute access | Dot-only (`var.a.b`), no item access | | Missing key | Strict `AttributeError` (catches typos in filters) | ## Alternatives Considered ### Named profiles (rejected) We considered letting users define multiple named variant configurations upfront and selecting between them: ```toml [needs] selected_variant = "arm_debug" [needs.variant_profiles.arm_debug] file = "variants/arm_debug.json" ``` **Why rejected:** - Adds config complexity without clear benefit over just switching the file path directly. - Users with multiple profiles already manage them as separate files; they don't need an indirection layer in sphinx config. - The simpler "just point at a file" approach composes better with external tooling (CI matrices, kconfig generators, etc.) that already produce a single output file. - Can always be added later as a convenience layer on top if demand materializes. ### Namespace name Also considered: - `env` — too easily confused with environment variables or Sphinx `BuildEnvironment` - `cfg` — Rust-flavored, less obvious to Python/RST users - No namespace (keep flat like `filter_data`) — loses the collision-safety motivation entirely `var` was chosen as short, unambiguous, and evocative of "variant" / "variable". ### `SimpleNamespace` via `json.loads` object_hook (rejected) Quick and elegant for prototyping, but rejected for production because: - No validation of leaf types or uniform arrays - No controlled error messages for missing keys (gives generic `AttributeError`) - Exposes internal `SimpleNamespace` attributes (`__dict__`, etc.) to eval context - No way to add `__contains__` for `"x" in var.tags` support without subclassing ### `__getitem__` fallback (rejected) Considered allowing both `var.a.b` and `var["a"]["b"]`. Rejected because: - Dot-access is significantly more readable in filter expressions: `var.build.debug` vs `var["build"]["debug"]` - Consistent with Jinja2 template syntax where `foo.bar` and `foo["bar"]` are equivalent — users of Sphinx/Jinja are already familiar with dot-access for nested data - Keeps the API surface minimal and forces a single idiomatic style - Can be added later if needed ## User-facing Configuration ```python # conf.py — inline (simple cases) needs_variant_data = { "cpu": "arm", "debug": True, "build": {"optimization": 2, "features": ["feature_a", "feature_b"]}, } # Or point to a file (kconfig-generated JSON, etc.) needs_variant_data_file = "variants/arm_debug.json" # Both can be combined: file loaded first, inline overrides on top ``` Switch at build time: `sphinx-build -D needs_variant_data_file=variants/x86_release.json docs/ _build/` ### Allowed Data Shape ```json { "cpu": "arm", "debug": true, "build": { "optimization": 2, "features": ["feature_a", "feature_b"] } } ``` Constraints: - Top-level must be a dict - Intermediate nodes: dicts only - Leaf values: `str | bool | int | float | list[str] | list[bool] | list[int] | list[float]` - Arrays must be uniform (all elements same scalar type) - No `null` values, no dicts inside arrays ## Implementation - **`sphinx_needs/variant_data.py`** (new) — `VariantDataProxy`, validation, JSON file loading, deep-merge - **`sphinx_needs/config.py`** — Two new fields: `variant_data` and `variant_data_file` - **`sphinx_needs/needs.py`** — Resolution in `prepare_env` (load file, merge, validate, store); deprecation warning in `check_configuration` - **`sphinx_needs/filter_common.py`** — Injects `var` proxy at all filter eval sites (including `filter_single_need`, `filter_import_item`, `apply_default_predicate`) - **`sphinx_needs/functions/functions.py`** — Injects `var` in variant expression context - **`sphinx_needs/ubquery.py`** — AST fast-path support for `var.*` attribute chains (comparisons, `in`/`not in`), avoiding `eval()` overhead ### Fast-path details (`ubquery.py`) The AST-based fast-path (`try_build_simple_predicate`) compiles simple filter expressions to native Python lambdas without `eval()`. This PR extends it to handle `var.*` attribute chains: 1. `_unpack_attribute_chain()` — unpacks `var.build.debug` AST into `("var", "build", "debug")` 2. `_resolve_chain()` — traverses the fallback context to resolve the chain at eval time 3. Comparison handlers recognize `ast.Attribute` chains rooted in `_FALLBACK_ROOTS` (`{"var"}`) 4. `"value" in var.field` membership tests are also compiled natively 5. Bare `var` as an `ast.Name` bails to the slow path (added to `_CONTEXT_ONLY_NAMES`) ## Documentation - **`docs/configuration.rst`** — New `needs_variant_data` and `needs_variant_data_file` sections with full usage examples; `needs_filter_data` moved to "Deprecated Options" with a `.. deprecated:: 8.2.0` directive pointing to the new options - **`docs/directives/need.rst`** — Updated `jinja_content` context reference - **`docs/dynamic_functions.rst`** — Updated variant evaluation context reference - All predicates context lists (under `needs_fields`, `needs_links`, `needs_global_options`) now reference `needs_variant_data` ## Tests - **`tests/test_variant_data.py`** — Unit tests for `VariantDataProxy`, validation, deep-merge, file loading, fast-path compilation (28 tests) - **`tests/test_variant_data_integration.py`** — Integration tests with Sphinx builds: inline config with `needs_warnings`, file loading with inline override (2 tests) - **`tests/test_ubquery.py`** — `TestVariantDataFastPath` class: comparisons, nested access, membership, compound expressions, error cases (23 new tests) - Updated existing tests (`test_needs_filter_data`, `test_variants`, `test_parallel_execution`) to expect the new deprecation warning ## Notes & Edge Cases - **`-D` override granularity**: Sphinx `-D` cannot set nested dict keys individually. Switching is done by pointing at a different JSON file. - **Array uniformity**: Validated at config-load time (fail fast with clear error). - **`eval()` safety**: `VariantDataProxy` has no `__getitem__`, `__setattr__`, or `__delattr__` — only `__getattr__` for reads. Cannot mutate data from a filter. - **Relative paths**: `needs_variant_data_file` is resolved relative to the Sphinx `confdir`. ## Future Improvements - **Include variant data in `needs.json`**: The resolved variant data could be emitted in the `needs.json` metadata section, enabling downstream tools (e.g. ubcode) to know which variant configuration was active during the build. This would support diffing outputs across variants and reproducing builds.
1 parent a50a2f9 commit dfa857e

21 files changed

Lines changed: 992 additions & 42 deletions

docs/configuration.rst

Lines changed: 108 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ For ``predicates``, the match expression is a string, using Python syntax, that
342342
- ``is_import`` (``bool``)
343343
- :ref:`needs_fields`
344344
- :ref:`needs_links` (``tuple[str, ...]``)
345-
- :ref:`needs_filter_data`
345+
- :ref:`needs_variant_data` (via the ``var`` namespace)
346346

347347
For example:
348348

@@ -454,7 +454,7 @@ For ``predicates``, the match expression is a string, using Python syntax, that
454454
- ``is_import`` (``bool``)
455455
- :ref:`needs_fields`
456456
- :ref:`needs_links` (``tuple[str, ...]``)
457-
- :ref:`needs_filter_data`
457+
- :ref:`needs_variant_data` (via the ``var`` namespace)
458458

459459
For example:
460460

@@ -570,55 +570,101 @@ Use ``style_start`` and ``style_end`` like this:
570570
and orientation (``left``, ``rigth``, ``up`` and ``down``). We suggest to set the orientation
571571
in ``style_end`` like in the example above, as this is more often supported.
572572

573-
.. _`needs_filter_data`:
573+
.. _`needs_variant_data`:
574574

575-
needs_filter_data
576-
~~~~~~~~~~~~~~~~~
575+
needs_variant_data
576+
~~~~~~~~~~~~~~~~~~
577577

578-
This option allows to use custom data inside a :ref:`filter_string`.
578+
.. versionadded:: 8.2.0
579+
580+
Provides structured variant data accessible under the ``var`` namespace in :ref:`filter expressions <filter_string>`.
581+
This replaces the flat :ref:`needs_filter_data` option with a namespaced, nested data structure
582+
that avoids collisions with need field names.
583+
584+
The dot-access syntax (``var.build.debug``) is more readable than dictionary-style access (``var["build"]["debug"]``)
585+
and mirrors `Jinja2 template syntax <https://jinja.palletsprojects.com/en/latest/templates/#variables>`__,
586+
which Sphinx users are already familiar with.
579587

580588
Configuration example:
581589

582590
.. code-block:: python
583591
584-
def custom_defined_func():
585-
return "my_tag"
586-
587-
needs_filter_data = {
588-
"current_variant": "project_x",
589-
"sphinx_tag": custom_defined_func(),
592+
needs_variant_data = {
593+
"cpu": "arm",
594+
"debug": True,
595+
"build": {
596+
"optimization": 2,
597+
"features": ["feature_a", "feature_b"],
598+
},
590599
}
591600
592-
The defined ``needs_filter_data`` must be a dictionary. Its values can be a string variable or a custom defined
593-
function. The function get executed during config loading and must return a string.
601+
The data is then available in filter expressions via the ``var`` namespace:
594602

595-
The value of ``needs_filter_data`` will be available as data inside :ref:`filter_string` and can be very powerful
596-
together with internal needs information to filter needs.
603+
.. code-block:: rst
597604
598-
The defined extra filter data can be used like this:
605+
.. needtable::
606+
:filter: var.cpu == "arm" and var.build.debug == True
599607
600-
.. code-block:: rst
608+
.. needlist::
609+
:filter: "feature_a" in var.build.features
610+
611+
**Allowed data shape:**
612+
613+
- Top-level must be a dictionary with string keys.
614+
- Intermediate nodes: dictionaries only.
615+
- Leaf values: ``str``, ``bool``, ``int``, ``float``, or uniform lists of these types.
616+
- Arrays must be uniform (all elements the same scalar type).
617+
- No ``None`` values, no dictionaries inside arrays.
618+
619+
Accessing a missing key raises an ``AttributeError``, which helps catch typos in filter expressions.
620+
621+
Default: ``{}``
622+
623+
.. seealso::
624+
625+
:ref:`needs_variant_data_file` for loading variant data from a JSON file.
626+
627+
.. _`needs_variant_data_file`:
628+
629+
needs_variant_data_file
630+
~~~~~~~~~~~~~~~~~~~~~~~
631+
632+
.. versionadded:: 8.2.0
633+
634+
Path to a JSON file containing variant data. The file is loaded and its contents
635+
are made available under the ``var`` namespace, just like :ref:`needs_variant_data`.
636+
637+
If both ``needs_variant_data_file`` and ``needs_variant_data`` are set, the file is loaded first
638+
and the inline dictionary is deep-merged on top (inline values win on conflict).
601639

602-
.. needextend:: type == "req" and sphinx_tag in tags
603-
:+tags: my_external_tag
640+
The path is resolved relative to the Sphinx ``confdir`` (the directory containing ``conf.py``).
604641

605-
or if project has :ref:`needs_fields` defined like:
642+
Configuration example:
606643

607644
.. code-block:: python
608645
609-
needs_fields = {'variant': {}}
646+
needs_variant_data_file = "variants/arm_debug.json"
610647
611-
The defined extra filter data can also be used like:
648+
Where ``variants/arm_debug.json`` contains:
612649

613-
.. code-block:: rst
650+
.. code-block:: json
614651
615-
.. needlist::
616-
:filter: variant != current_variant
652+
{
653+
"cpu": "arm",
654+
"debug": true,
655+
"build": {
656+
"optimization": 2,
657+
"features": ["feature_a", "feature_b"]
658+
}
659+
}
660+
661+
This is particularly useful for switching configurations at build time:
617662

618-
.. needextract::
619-
:filter: type == "story" and variant == current_variant
620-
:layout: clean
621-
:style: green_border
663+
.. code-block:: bash
664+
665+
sphinx-build -D needs_variant_data_file=variants/x86_release.json docs/ _build/
666+
667+
Default: ``None``
622668

623669
.. _`needs_allow_unsafe_filters`:
624670

@@ -2472,6 +2518,37 @@ final need and schema looks like.
24722518
Deprecated Options
24732519
------------------
24742520

2521+
.. _`needs_filter_data`:
2522+
2523+
needs_filter_data
2524+
~~~~~~~~~~~~~~~~~
2525+
2526+
.. deprecated:: 8.2.0
2527+
2528+
Use :ref:`needs_variant_data` and :ref:`needs_variant_data_file` instead.
2529+
These provide a dedicated ``var`` namespace that avoids collisions with need field names
2530+
and supports nested structured data.
2531+
2532+
This option allows to use custom data inside a :ref:`filter_string`.
2533+
2534+
Configuration example:
2535+
2536+
.. code-block:: python
2537+
2538+
def custom_defined_func():
2539+
return "my_tag"
2540+
2541+
needs_filter_data = {
2542+
"current_variant": "project_x",
2543+
"sphinx_tag": custom_defined_func(),
2544+
}
2545+
2546+
The defined ``needs_filter_data`` must be a dictionary. Its values can be a string variable or a custom defined
2547+
function. The function gets executed during config loading and must return a string.
2548+
2549+
The value of ``needs_filter_data`` will be available as data inside :ref:`filter_string` and can be very powerful
2550+
together with internal needs information to filter needs.
2551+
24752552
.. _`needs_extra_options`:
24762553

24772554
needs_extra_options
@@ -2672,7 +2749,7 @@ A match expression is a string, using Python syntax, that will be evaluated agai
26722749
- ``is_import`` (``bool``)
26732750
- :ref:`needs_fields`
26742751
- :ref:`needs_links` (``tuple[str, ...]``)
2675-
- :ref:`needs_filter_data`
2752+
- :ref:`needs_variant_data` (via the ``var`` namespace)
26762753

26772754
If no predicates match, the ``default`` value is used (if present).
26782755

docs/directives/need.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ The option activates jinja-parsing for the content of a need.
319319
If the value is set to ``true``, you can specify `Jinja <https://jinja.palletsprojects.com/>`_ syntax in the content.
320320

321321
The **:jinja_content:** option give access to all need data, including the original content
322-
and the data in :ref:`needs_filter_data`.
322+
and the data in :ref:`needs_variant_data`.
323323

324324
If you set the option to **False**, you deactivate jinja-parsing for the need's content.
325325

docs/dynamic_functions.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Rules for specifying variant definitions
170170
* Variants gets checked from left to right.
171171
* When evaluating a variant definition, we use data from the current need object,
172172
`Sphinx-Tags <https://www.sphinx-doc.org/en/master/man/sphinx-build.html#cmdoption-sphinx-build-t>`_,
173-
and :ref:`needs_filter_data` as the context for filtering.
173+
and :ref:`needs_variant_data` as the context for filtering.
174174
Sphinx tags are injected under the name ``build_tags`` as a set of strings.
175175
* You can set a *need option* to multiple variant definitions by separating each definition with either
176176
the ``,`` symbol, like ``var_a:open, ['name' in tags]:assigned``.|br|

docs/filter.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,45 @@ If it is invalid or returns False, the related need is not taken into account fo
189189
.. needlist::
190190
:filter: "filter_example" in tags and (("B" in tags or ("spec" == type and "closed" == status)) or "test" == type)
191191

192+
.. _filter_variant_data:
193+
194+
Using variant data (``var``)
195+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
196+
197+
.. versionadded:: 8.2.0
198+
199+
If :ref:`needs_variant_data` or :ref:`needs_variant_data_file` is configured,
200+
a ``var`` namespace is available in filter expressions, providing dot-access to nested structured data.
201+
202+
This is useful for switching build outputs based on external configuration (CPU architecture, debug/release mode, feature flags, etc.)
203+
without polluting the need field namespace.
204+
205+
The dot-access syntax mirrors `Jinja2 template syntax <https://jinja.palletsprojects.com/en/latest/templates/#variables>`__,
206+
making it familiar to Sphinx users.
207+
208+
**Examples:**
209+
210+
.. code-block:: rst
211+
212+
.. needtable::
213+
:filter: var.cpu == "arm"
214+
215+
.. needlist::
216+
:filter: var.build.debug == True and var.build.optimization > 1
217+
218+
.. needtable::
219+
:filter: "feature_a" in var.build.features
220+
221+
Accessing a key that doesn't exist raises an error, which helps catch typos:
222+
223+
.. code-block:: rst
224+
225+
.. needlist::
226+
:filter: var.typo == "x"
227+
.. this will raise AttributeError: Unknown variant key: var.typo
228+
229+
See :ref:`needs_variant_data` for configuration details and allowed data shapes.
230+
192231
.. _filter_current_page:
193232

194233
Filtering for needs on the current page
@@ -248,6 +287,8 @@ To improve performance, certain common patterns are identified and optimized by
248287
- ``status == 'value'`` / ``status == "value"`` / ``'value' == status`` / ``"value" == status``
249288
- ``status in ['value1', 'value2', ...]`` / ``status in ("value1", "value2", ...)``
250289
- ``'value' in tags`` / ``"value" in tags``
290+
- ``var.key == 'value'`` / ``var.nested.key == 'value'`` (see :ref:`filter_variant_data`)
291+
- ``'value' in var.key`` / ``'value' not in var.key``
251292

252293
Also filters containing ``and`` will be split into multiple filters and evaluated separately for the above patterns.
253294
For example, ``type == 'spec' and other == 'value'`` will first be filtered performantly by ``type == 'spec'`` and then the remaining needs will be filtered by ``other == 'value'``.

sphinx_needs/config.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,42 @@ def functions(self) -> Mapping[str, NeedFunctionsType]:
732732
filter_data: dict[str, str] = field(
733733
default_factory=dict, metadata={"rebuild": "html", "types": ()}
734734
)
735-
"""Additional context data for filters."""
735+
"""Additional context data for filters.
736+
737+
.. deprecated:: 8.2.0
738+
Use :confval:`needs_variant_data` with the ``var.*`` namespace instead.
739+
"""
740+
variant_data: dict[str, Any] = field(
741+
default_factory=dict, metadata={"rebuild": "html", "types": (dict,)}
742+
)
743+
"""Nested variant data accessible as ``var.*`` in filter expressions.
744+
745+
Supports nested dicts with scalar leaf values (str, bool, int, float)
746+
and uniform-type arrays of scalars.
747+
748+
Example::
749+
750+
needs_variant_data = {
751+
"cpu": "arm",
752+
"debug": True,
753+
"build": {"optimization": 2},
754+
}
755+
756+
Then in filters: ``var.cpu == "arm"`` or ``var.build.optimization > 1``.
757+
"""
758+
variant_data_file: str | None = field(
759+
default=None,
760+
metadata={
761+
"rebuild": "html",
762+
"types": (str, type(None)),
763+
"toml_convert": _abs_path,
764+
},
765+
)
766+
"""Path to a JSON file containing variant data.
767+
768+
If both this and :confval:`needs_variant_data` are set, the file is loaded first
769+
and inline values are deep-merged on top (inline wins).
770+
"""
736771
allow_unsafe_filters: bool = field(
737772
default=False, metadata={"rebuild": "html", "types": (bool,)}
738773
)

sphinx_needs/filter_common.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -541,8 +541,14 @@ def filter_needs_and_parts(
541541

542542
# === Fast path: try compiled predicate to avoid eval() entirely ===
543543
simple_pred = try_build_simple_predicate(filter_string)
544+
var_proxy = config.variant_data_proxy
545+
544546
if simple_pred is not None:
545-
fallback = config.filter_data or None
547+
fallback: dict[str, Any] | None = None
548+
if config.filter_data or var_proxy is not None:
549+
fallback = dict(config.filter_data) if config.filter_data else {}
550+
if var_proxy is not None:
551+
fallback["var"] = var_proxy
546552
found_needs: list[NeedItem | NeedPartItem] = []
547553
error_reported = False
548554
for filter_need in needs:
@@ -631,12 +637,14 @@ def apply_default_predicate(
631637
632638
:raises NeedsInvalidFilter: if the predicate is not valid
633639
"""
634-
predicate_context = {
640+
predicate_context: dict[str, Any] = {
635641
**context,
636642
**extras,
637643
**links,
638644
**config.filter_data,
639645
}
646+
if (var_proxy := config.variant_data_proxy) is not None:
647+
predicate_context["var"] = var_proxy
640648
try:
641649
# Set filter_context as globals and not only locals in eval()!
642650
# Otherwise, the vars not be accessed in list comprehensions.
@@ -657,6 +665,8 @@ def filter_import_item(
657665
filter_context = context.copy()
658666
# Get needs external filter data and merge to filter_context
659667
filter_context.update(config.filter_data)
668+
if (var_proxy := config.variant_data_proxy) is not None:
669+
filter_context["var"] = var_proxy
660670
filter_context["search"] = need_search
661671
try:
662672
# Set filter_context as globals and not only locals in eval()!
@@ -694,10 +704,16 @@ def filter_single_need(
694704
695705
:return: True, if need passes the filter_string, else False
696706
"""
707+
var_proxy = config.variant_data_proxy
708+
697709
# === Fast path: short-circuit simple expressions ===
698710
simple_pred = try_build_simple_predicate(filter_string)
699711
if simple_pred is not None:
700-
fallback = config.filter_data or None
712+
fallback: dict[str, Any] | None = None
713+
if config.filter_data or var_proxy is not None:
714+
fallback = dict(config.filter_data) if config.filter_data else {}
715+
if var_proxy is not None:
716+
fallback["var"] = var_proxy
701717
try:
702718
result = simple_pred(need, fallback)
703719
if not isinstance(result, bool):
@@ -721,6 +737,8 @@ def filter_single_need(
721737

722738
# Get needs external filter data and merge to filter_context
723739
filter_context.update(config.filter_data)
740+
if var_proxy is not None:
741+
filter_context["var"] = var_proxy
724742

725743
filter_context["search"] = need_search
726744

sphinx_needs/functions/functions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ def resolve_functions(
281281
) -> None:
282282
"""Resolve all dynamic/variant functions in all needs."""
283283
needs_schema = SphinxNeedsData(app.env).get_schema()
284+
var_proxy = needs_config.variant_data_proxy
284285
for need in needs.values():
285286
if not need.has_dynamic_fields:
286287
continue
@@ -317,6 +318,8 @@ def resolve_functions(
317318
**needs_config.filter_data,
318319
"build_tags": set(app.builder.tags),
319320
}
321+
if var_proxy is not None:
322+
var_context["var"] = var_proxy
320323
if (
321324
var_return := _get_variant(
322325
item, needs_config.variants, var_context

0 commit comments

Comments
 (0)