Skip to content

Commit 0af25cb

Browse files
docs: WIP packaging concept
This PR provdies a way to disable requirements based on feature flags set in bazel. It has a 'translation' layer where we can map feature flags to tags, which are then used to disable/hide requirements.
1 parent edb023b commit 0af25cb

File tree

10 files changed

+275
-3
lines changed

10 files changed

+275
-3
lines changed

docs/BUILD

+15
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
4444
load("@rules_python//python:defs.bzl", "py_library")
4545
load("@rules_python//python:pip.bzl", "compile_pip_requirements")
4646
load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs")
47+
load("//tools/feature_flags:feature_flags.bzl", "define_feature_flags")
48+
49+
50+
define_feature_flags(name = "filter_tags")
4751

4852
# all_requirements include esbonio which we don't need most of the time.
4953
# Hint: the names are e.g. "@@rules_python~~pip~pip_sphinx//esbonio:pkg"
@@ -61,11 +65,13 @@ sphinx_docs(
6165
config = ":conf.py",
6266
extra_opts = [
6367
"--keep-going",
68+
"-Dfilter_tags_file_path=$(location :filter_tags)"
6469
],
6570
formats = [
6671
"html",
6772
],
6873
sphinx = ":sphinx_build",
74+
tools = [":filter_tags"],
6975
tags = [
7076
"manual",
7177
],
@@ -76,6 +82,7 @@ sphinx_build_binary(
7682
deps = sphinx_requirements + [
7783
":extensions",
7884
"//docs/_tooling/sphinx_extensions",
85+
"//docs/_tooling/sphinx_extensions/sphinx_extensions/build:modularity"
7986
],
8087
)
8188

@@ -124,9 +131,11 @@ pkg_tar(
124131
py_binary(
125132
name = "incremental",
126133
srcs = ["_tooling/incremental.py"],
134+
data = [":flags_file"],
127135
deps = sphinx_requirements + [
128136
":extensions",
129137
"//docs/_tooling/sphinx_extensions",
138+
"//docs/_tooling/sphinx_extensions/sphinx_extensions/build:modularity"
130139
],
131140
)
132141

@@ -138,3 +147,9 @@ py_venv(
138147
# Until release of esbonio 1.x, we need to install it ourselves so the VS Code extension can find it.
139148
deps = sphinx_requirements + [requirement("esbonio")],
140149
)
150+
151+
filegroup(
152+
name = "flags_file",
153+
srcs = [":filter_tags"],
154+
output_group = "flags_file",
155+
)

docs/_tooling/incremental.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@
2121
if workspace:
2222
os.chdir(workspace)
2323

24-
24+
flags_file = "docs/feature_flags.txt"
2525
sphinx_main(
2626
[
2727
"docs", # src dir
2828
"_build", # out dir
2929
"--jobs",
3030
"auto",
3131
"--conf-dir",
32-
"docs",
32+
"docs",
33+
"-D", f"filter_tags_file_path={flags_file}"
3334
]
3435
)

docs/_tooling/sphinx_extensions/BUILD

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs
1818
py_library(
1919
name = "sphinx_extensions",
2020
srcs = glob(["sphinx_extensions/**/*.py"]),
21-
imports = ["."],
21+
imports = ["sphinx_extensions"],
2222
visibility = [
2323
"//visibility:public",
2424
],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
load("@pip_sphinx//:requirements.bzl", "all_requirements", "requirement")
2+
load("@rules_python//python:defs.bzl", "py_library")
3+
4+
py_library(
5+
name = "modularity",
6+
srcs = glob(["modularity/*.py"]),
7+
imports = ["modularity"],
8+
visibility = [
9+
"//visibility:public",
10+
],
11+
deps = [
12+
requirement("sphinx"),
13+
requirement("sphinx-needs"),
14+
],
15+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
2+
# Modularity Sphinx Extension
3+
4+
A Sphinx extension that enables conditional documentation rendering based on feature flags.
5+
6+
## Overview
7+
8+
The extension consists of two main components:
9+
10+
1. `feature_flags.bzl`: A Bazel translation layer that converts commands into tags
11+
2. `modularity`: A Sphinx extension that filters documentation based on feature flags
12+
13+
It currently functions as a blacklist.
14+
**The extension is setup so it only disables requirements where *all* tags are contained within the filtered ones.**
15+
16+
## Usage
17+
18+
### Command Line
19+
20+
Disable features when building documentation:
21+
22+
```bash
23+
docs build //docs:docs --//docs:feature1=true
24+
```
25+
26+
## Configuration
27+
28+
### Feature Flags (feature_flags.bzl)
29+
30+
The `feature_flags.bzl` file takes care of the following things:
31+
32+
- Feature-to-tag translation
33+
- Feature name to flag mappings
34+
- Default values for flags
35+
- Temporary file generation for flag storage
36+
37+
Example configuration:
38+
39+
Mapping of features to tags is defined as follows:
40+
41+
```bzl
42+
FEATURE_TAG_MAPPING = {
43+
"feature1": ["some-ip", "tag2"], # --//docs:feature1=true -> will expand to 'some-ip', 'tag2'
44+
"second-feature": ["test-feat", "tag6"],
45+
}
46+
```
47+
48+
The default values are defined like such:
49+
```bzl
50+
def define_feature_flags(name):
51+
bool_flag(
52+
name = "feature1",
53+
build_setting_default = False,
54+
)
55+
bool_flag(
56+
name = "second-feature",
57+
build_setting_default = False,
58+
)
59+
60+
feature_flag_translator(
61+
name = name,
62+
flags = {":feature1": "True", ":second-feature": "True"},
63+
)
64+
```
65+
As can be seen here, each flag needs to be registered as well as have a default value defined.
66+
After adding new flags it's possible to call them via the `--//docs:<flag-name>=<value>` command
67+
68+
The `feature_flag.bzl` file will after parsing the flags and translating them write a temporary file with all to be disabled tags.
69+
70+
### Extension (modularity)
71+
72+
The extension processes the temporary flag file and disables documentation sections tagged with disabled features.
73+
74+
#### Use in requirements
75+
76+
All requirements which tags are **all** contained within the tags that we look for, will be disabled.
77+
Here an eaxmple to illustrate the point.
78+
We will disable the `test-feat` tag via feature flags. If we take the following example rst:
79+
```rst
80+
.. tool_req:: Test_TOOL
81+
:id: TEST_TOOL_REQ
82+
:tags: feature1, test-feat
83+
:satisfies: TEST_STKH_REQ_1, TEST_STKH_REQ_20
84+
85+
We will see that this should still be rendered but 'TEST_STKH_REQ_1' will be missing from the 'satisfies' option
86+
87+
.. stkh_req:: Test_REQ disable
88+
:id: TEST_STKH_REQ_1
89+
:tags: test-feat
90+
91+
This is a requirement that we would want to disable via the feature flag
92+
93+
.. stkh_req:: Test_REQ do not disable
94+
:id: TEST_STKH_REQ_20
95+
:tags: feature1
96+
97+
This requirement will not be disabled.
98+
```
99+
We can then build it with our feature flag enabled via `bazel build //docs:docs --//docs:second-feature=true`
100+
This will expand via our translation layer `feature_flag.bzl` into the tags `test-feat` and `tag5`.
101+
102+
**The extension is setup so it only disables requirements where *all* tags are contained within the filtered ones.**
103+
In the rst above `TEST_TOOL_REQ` has the `test-feat` tag but it also has the `feature1` tag, therefore it wont be disabled.
104+
In contrast, TEST_STKH_REQ_20 has only the `test-feat` tag, therefore it will be disabled and removed from any links as well.
105+
106+
If we now look at the rendered HTML:
107+
![](rendered_html.png)
108+
109+
We can confirm that the `TEST_STKH_REQ_1` requirement is gone and so is the reference to it from `TEST_TOOL_REQ`.
110+
111+
112+
*However, keep in mind that in the source code the actual underlying RST has not changed, it's just the HTML.*
113+
This means that one can still see all the requirements there and also if searching for it will still find the document where it is mentioned.
114+
115+
116+
### How the extension achieves this?
117+
118+
The extension uses a sphinx-needs buildin option called `hide`. If a need has the `hide=True` it will not be shown in the final HTML.
119+
It also gatheres a list of all 'hidden' requirements as it then in a second iteration removes these from any of the possible links.
120+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from pprint import pprint
2+
from sphinx_needs.data import SphinxNeedsData, NeedsInfoType
3+
from sphinx_needs.config import NeedsSphinxConfig
4+
from sphinx.util import logging
5+
from copy import deepcopy
6+
import os
7+
import json
8+
9+
logger = logging.getLogger(__name__)
10+
11+
def read_filter_tags(app):
12+
print(f"Attempting to read filter tags from: {app.config.filter_tags_file_path}")
13+
try:
14+
with open(app.config.filter_tags_file_path, "r") as f:
15+
content = f.read().strip()
16+
filter_tags = [tag.strip() for tag in content.split(",")] if content else []
17+
print(f"Successfully read filter tags: {filter_tags}")
18+
return filter_tags
19+
except Exception as e:
20+
print(f"Could not read filter tags. Error: {e}")
21+
return []
22+
23+
def set_hide(app, env):
24+
filter_tags = read_filter_tags(app)
25+
Need_Data = SphinxNeedsData(env)
26+
needs = Need_Data.get_needs_mutable()
27+
extra_links = [x['option'] for x in NeedsSphinxConfig(env.config).extra_links]
28+
rm_needs_docs = set()
29+
rm_needs = []
30+
for need_id, need in needs.items():
31+
if need['tags'] and all(tag in filter_tags for tag in need['tags']):
32+
rm_needs.append(need_id)
33+
need["hide"] = True
34+
35+
# Remove references
36+
for need_id in needs:
37+
for opt in extra_links:
38+
needs[need_id][opt] = [x for x in needs[need_id][opt] if x not in rm_needs]
39+
needs[need_id][opt + "_back"] = [x for x in needs[need_id][opt + "_back"] if x not in rm_needs]
40+
41+
42+
def setup(app):
43+
app.add_config_value('filter_tags', [], 'env', [list])
44+
app.add_config_value('filter_tags_file_path', None, 'env') # Change default from "" to None
45+
46+
# Add validation
47+
def validate_config(app, config):
48+
if not config.filter_tags_file_path:
49+
logger.warning("No filter_tags_file_path configured")
50+
elif not os.path.exists(config.filter_tags_file_path):
51+
logger.error(f"Filter tags file not found at: {config.filter_tags_file_path}")
52+
53+
app.connect('config-inited', validate_config)
54+
app.connect("env-updated", set_hide)
55+
56+
return {
57+
'version': '1.0',
58+
'parallel_read_safe': True,
59+
'parallel_write_safe': True,
60+
}
61+
62+
63+
return {
64+
'version': '1.0',
65+
'parallel_read_safe': True,
66+
'parallel_write_safe': True,
67+
}
68+
69+
Loading

docs/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
extensions = [
3838
"sphinx_design",
3939
"sphinx_needs",
40+
"modularity"
4041
]
4142

4243
exclude_patterns = [

tools/feature_flags/BUILD

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package(default_visibility = ["//visibility:public"])

tools/feature_flags/feature_flags.bzl

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# tools/feature_flags/feature_flags.bzl
2+
load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "BuildSettingInfo")
3+
4+
OutputPathInfo = provider(fields = ["path"])
5+
6+
FEATURE_TAG_MAPPING = {
7+
"feature1": ["some-ip", "tag2"],
8+
"second-feature": ["test-feat", "tag6"],
9+
}
10+
11+
def _feature_flag_translator_impl(ctx):
12+
output = ctx.actions.declare_file("feature_flags.txt")
13+
tags = []
14+
for flag, _ in ctx.attr.flags.items():
15+
if flag[BuildSettingInfo].value:
16+
flag_name = flag.label.name
17+
if flag_name in FEATURE_TAG_MAPPING:
18+
tags.extend(FEATURE_TAG_MAPPING[flag_name])
19+
else:
20+
tags.append(flag_name)
21+
22+
content = ",".join(tags)
23+
ctx.actions.write(output = output, content = content)
24+
25+
return [DefaultInfo(files = depset([output]))]
26+
27+
feature_flag_translator = rule(
28+
implementation = _feature_flag_translator_impl,
29+
attrs = {
30+
"flags": attr.label_keyed_string_dict(
31+
mandatory = True,
32+
providers = [BuildSettingInfo],
33+
),
34+
},
35+
)
36+
37+
def define_feature_flags(name):
38+
bool_flag(
39+
name = "feature1",
40+
build_setting_default = False,
41+
)
42+
bool_flag(
43+
name = "second-feature",
44+
build_setting_default = False,
45+
)
46+
47+
feature_flag_translator(
48+
name = name,
49+
flags = {":feature1": "True", ":second-feature": "True"},
50+
)

0 commit comments

Comments
 (0)