Merge pull request 'Docs: refactor all option documentation to use tree representations' (#2676) from hsjobeki/clan-core:hsjobeki-main into main
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -82,19 +83,39 @@ def join_lines_with_indentation(lines: list[str], indent: int = 4) -> str:
|
||||
return "\n".join(indent_str + line for line in lines)
|
||||
|
||||
|
||||
def sanitize_anchor(text: str) -> str:
|
||||
parts = text.split(".")
|
||||
res = []
|
||||
for part in parts:
|
||||
if "<" in part:
|
||||
continue
|
||||
res.append(part)
|
||||
|
||||
return ".".join(res)
|
||||
|
||||
|
||||
def render_option(
|
||||
name: str, option: dict[str, Any], level: int = 3, short_head: str | None = None
|
||||
name: str,
|
||||
option: dict[str, Any],
|
||||
level: int = 3,
|
||||
short_head: str | None = None,
|
||||
) -> str:
|
||||
read_only = option.get("readOnly")
|
||||
|
||||
res = f"""
|
||||
{"#" * level} {sanitize(name) if short_head is None else sanitize(short_head)}
|
||||
{"#" * level} {sanitize(name) if short_head is None else sanitize(short_head)} {"{: #"+sanitize_anchor(name)+"}" if level > 1 else ""}
|
||||
|
||||
"""
|
||||
|
||||
res += f"""
|
||||
{f"**Attribute: `{name}`**" if short_head is not None else ""}
|
||||
|
||||
{"**Readonly**" if read_only else ""}
|
||||
|
||||
{option.get("description", "No description available.")}
|
||||
{option.get("description", "")}
|
||||
"""
|
||||
if option.get("type"):
|
||||
res += f"""
|
||||
|
||||
**Type**: `{option["type"]}`
|
||||
|
||||
@@ -104,7 +125,7 @@ def render_option(
|
||||
**Default**:
|
||||
|
||||
```nix
|
||||
{option["default"]["text"] if option.get("default") else "No default set."}
|
||||
{option.get("default",{}).get("text") if option.get("default") else "No default set."}
|
||||
```
|
||||
"""
|
||||
example = option.get("example", {}).get("text")
|
||||
@@ -131,18 +152,23 @@ def render_option(
|
||||
res += f"""
|
||||
:simple-git: [{name}]({source_path})
|
||||
"""
|
||||
res += "\n"
|
||||
res += "\n\n"
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def print_options(options_file: str, head: str, no_options: str) -> str:
|
||||
def print_options(
|
||||
options_file: str, head: str, no_options: str, replace_prefix: str | None = None
|
||||
) -> str:
|
||||
res = ""
|
||||
with (Path(options_file) / "share/doc/nixos/options.json").open() as f:
|
||||
options: dict[str, dict[str, Any]] = json.load(f)
|
||||
|
||||
res += head if len(options.items()) else no_options
|
||||
for option_name, info in options.items():
|
||||
if replace_prefix:
|
||||
option_name = option_name.replace(replace_prefix + ".", "")
|
||||
|
||||
res += render_option(option_name, info, 4)
|
||||
return res
|
||||
|
||||
@@ -223,10 +249,13 @@ Every clan module has a `frontmatter` section within its readme. It provides mac
|
||||
This provides an overview of the available attributes of the `frontmatter` within the `README.md` of a clan module.
|
||||
|
||||
"""
|
||||
for option_name, info in options.items():
|
||||
if option_name == "_module.args":
|
||||
continue
|
||||
output += render_option(option_name, info)
|
||||
# for option_name, info in options.items():
|
||||
# if option_name == "_module.args":
|
||||
# continue
|
||||
# output += render_option(option_name, info)
|
||||
root = options_to_tree(options, debug=True)
|
||||
for option in root.suboptions:
|
||||
output += options_docs_from_tree(option, init_level=2)
|
||||
|
||||
outfile = Path(OUT) / "clanModules/frontmatter/index.md"
|
||||
outfile.parent.mkdir(
|
||||
@@ -251,28 +280,48 @@ def produce_clan_core_docs() -> None:
|
||||
with CLAN_CORE_DOCS.open() as f:
|
||||
options: dict[str, dict[str, Any]] = json.load(f)
|
||||
module_name = "clan-core"
|
||||
for option_name, info in options.items():
|
||||
outfile = f"{module_name}/index.md"
|
||||
|
||||
# Create separate files for nested options
|
||||
if len(option_name.split(".")) <= 3:
|
||||
# i.e. clan-core.clanDir
|
||||
output = core_outputs.get(
|
||||
outfile,
|
||||
module_header(module_name) + clan_core_descr + options_head,
|
||||
transform = {n.replace("clan.core.", ""): v for n, v in options.items()}
|
||||
split = split_options_by_root(transform)
|
||||
|
||||
# Prepopulate the index file header
|
||||
indexfile = f"{module_name}/index.md"
|
||||
core_outputs[indexfile] = (
|
||||
module_header(module_name) + clan_core_descr + options_head
|
||||
)
|
||||
|
||||
for submodule_name, split_options in split.items():
|
||||
outfile = f"{module_name}/{submodule_name}.md"
|
||||
print(
|
||||
f"[clan_core.{submodule_name}] Rendering option of: {submodule_name}... {outfile}"
|
||||
)
|
||||
init_level = 1
|
||||
root = options_to_tree(split_options, debug=True)
|
||||
|
||||
print(f"Submodule {submodule_name} - suboptions", len(root.suboptions))
|
||||
|
||||
module = root.suboptions[0]
|
||||
print("type", module.info.get("type"))
|
||||
|
||||
module_type = module.info.get("type")
|
||||
|
||||
if module_type is not None and "submodule" not in module_type:
|
||||
outfile = indexfile
|
||||
init_level = 2
|
||||
|
||||
output = ""
|
||||
for option in root.suboptions:
|
||||
output += options_docs_from_tree(
|
||||
option,
|
||||
init_level=init_level,
|
||||
prefix=["clan", "core"],
|
||||
)
|
||||
output += render_option(option_name, info)
|
||||
# Update the content
|
||||
|
||||
# Append the content
|
||||
if outfile not in core_outputs:
|
||||
core_outputs[outfile] = output
|
||||
else:
|
||||
# Clan sub-options
|
||||
[_, sub] = option_name.split(".")[1:3]
|
||||
outfile = f"{module_name}/{sub}.md"
|
||||
# Get the content or write the header
|
||||
output = core_outputs.get(outfile, render_option_header(sub))
|
||||
output += render_option(option_name, info)
|
||||
# Update the content
|
||||
core_outputs[outfile] = output
|
||||
core_outputs[outfile] += output
|
||||
|
||||
for outfile, output in core_outputs.items():
|
||||
(Path(OUT) / outfile).parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -386,7 +435,7 @@ def produce_clan_modules_docs() -> None:
|
||||
links: dict[str, str] = json.load(f)
|
||||
|
||||
for module_name, options_file in links.items():
|
||||
print(f"Rendering {module_name}")
|
||||
print(f"Rendering ClanModule: {module_name}")
|
||||
readme_file = CLAN_CORE_PATH / "clanModules" / module_name / "README.md"
|
||||
with readme_file.open() as f:
|
||||
readme = f.read()
|
||||
@@ -445,7 +494,12 @@ def produce_clan_modules_docs() -> None:
|
||||
|
||||
The following options are available when using the `{role}` role.
|
||||
"""
|
||||
output += print_options(role_options_file, heading, no_options)
|
||||
output += print_options(
|
||||
role_options_file,
|
||||
heading,
|
||||
no_options,
|
||||
replace_prefix=f"clan.{module_name}",
|
||||
)
|
||||
else:
|
||||
# No roles means no inventory usage
|
||||
output += """## Usage via Inventory
|
||||
@@ -463,7 +517,6 @@ The following options are available when using the `{role}` role.
|
||||
**This module cannot be imported directly in your nixos configuration.**
|
||||
|
||||
"""
|
||||
|
||||
else:
|
||||
output += module_nix_usage(module_name)
|
||||
no_options = "** This module doesnt require any options to be set.**"
|
||||
@@ -562,7 +615,9 @@ Each attribute is documented below
|
||||
"""
|
||||
with Path(BUILD_CLAN_PATH).open() as f:
|
||||
options: dict[str, dict[str, Any]] = json.load(f)
|
||||
for option_name, info in options.items():
|
||||
|
||||
split = split_options_by_root(options)
|
||||
for option_name, options in split.items():
|
||||
# Skip underscore options
|
||||
if option_name.startswith("_"):
|
||||
continue
|
||||
@@ -571,8 +626,11 @@ Each attribute is documented below
|
||||
if option_name.startswith("inventory."):
|
||||
continue
|
||||
|
||||
print(f"Rendering option of {option_name}...")
|
||||
output += render_option(option_name, info)
|
||||
print(f"[build_clan_docs] Rendering option of {option_name}...")
|
||||
root = options_to_tree(options)
|
||||
|
||||
for option in root.suboptions:
|
||||
output += options_docs_from_tree(option, init_level=2)
|
||||
|
||||
outfile = Path(OUT) / "nix-api/buildclan.md"
|
||||
outfile.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -580,6 +638,43 @@ Each attribute is documented below
|
||||
of.write(output)
|
||||
|
||||
|
||||
def split_options_by_root(options: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Split the flat dictionary of options into a dict of which each entry will construct complete option trees.
|
||||
{
|
||||
"a": { Data }
|
||||
"a.b": { Data }
|
||||
"c": { Data }
|
||||
}
|
||||
->
|
||||
{
|
||||
"a": {
|
||||
"a": { Data },
|
||||
"a.b": { Data }
|
||||
}
|
||||
"c": {
|
||||
"c": { Data }
|
||||
}
|
||||
}
|
||||
"""
|
||||
res: dict[str, dict[str, Any]] = {}
|
||||
for key, value in options.items():
|
||||
parts = key.split(".")
|
||||
root = parts[0]
|
||||
if root not in res:
|
||||
res[root] = {}
|
||||
res[root][key] = value
|
||||
return res
|
||||
|
||||
|
||||
@dataclass
|
||||
class Option:
|
||||
name: str
|
||||
path: list[str]
|
||||
info: dict[str, Any]
|
||||
suboptions: list["Option"] = field(default_factory=list)
|
||||
|
||||
|
||||
def produce_inventory_docs() -> None:
|
||||
if not BUILD_CLAN_PATH:
|
||||
msg = f"Environment variables are not set correctly: BUILD_CLAN_PATH={BUILD_CLAN_PATH}. Expected a path to the optionsJSON"
|
||||
@@ -600,47 +695,21 @@ It can be set via the `inventory` attribute of the [`buildClan`](./buildclan.md#
|
||||
with Path(BUILD_CLAN_PATH).open() as f:
|
||||
options: dict[str, dict[str, Any]] = json.load(f)
|
||||
|
||||
def by_cat(item: tuple[str, dict[str, Any]]) -> Any:
|
||||
name, _info = item
|
||||
parts = name.split(".") if "." in name else ["root", "sub"]
|
||||
clan_root_option = options_to_tree(options)
|
||||
# Find the inventory options
|
||||
inventory_opt: None | Option = None
|
||||
for opt in clan_root_option.suboptions:
|
||||
if opt.name == "inventory":
|
||||
inventory_opt = opt
|
||||
break
|
||||
|
||||
# Make everything fixed length.
|
||||
remain = 10 - len(parts)
|
||||
parts.extend(["A"] * remain)
|
||||
category = parts[1]
|
||||
# Sort by category,
|
||||
# then by length of the option,
|
||||
# then by the rest of the options
|
||||
comparator = (category, -remain, parts[2:9])
|
||||
return comparator
|
||||
|
||||
seen_categories = set()
|
||||
for option_name, info in sorted(options.items(), key=by_cat):
|
||||
# Skip underscore options
|
||||
if option_name.startswith("_"):
|
||||
continue
|
||||
|
||||
# Skip non inventory sub options
|
||||
if not option_name.startswith("inventory."):
|
||||
continue
|
||||
|
||||
category = option_name.split(".")[1]
|
||||
|
||||
heading_level = 3
|
||||
if category not in seen_categories:
|
||||
heading_level = 2
|
||||
seen_categories.add(category)
|
||||
|
||||
parts = option_name.split(".")
|
||||
short_name = ""
|
||||
for part in parts[1:]:
|
||||
if "<" in part:
|
||||
continue
|
||||
short_name += ("." + part) if short_name else part
|
||||
|
||||
output += render_option(
|
||||
option_name, info, level=heading_level, short_head=short_name
|
||||
)
|
||||
if not inventory_opt:
|
||||
print("No inventory options found.")
|
||||
exit(1)
|
||||
# Render the inventory options
|
||||
# This for loop excludes the root node
|
||||
for option in inventory_opt.suboptions:
|
||||
output += options_docs_from_tree(option, init_level=2)
|
||||
|
||||
outfile = Path(OUT) / "nix-api/inventory.md"
|
||||
outfile.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -648,11 +717,117 @@ It can be set via the `inventory` attribute of the [`buildClan`](./buildclan.md#
|
||||
of.write(output)
|
||||
|
||||
|
||||
def option_short_name(option_name: str) -> str:
|
||||
parts = option_name.split(".")
|
||||
short_name = ""
|
||||
for part in parts[1:]:
|
||||
if "<" in part:
|
||||
continue
|
||||
short_name += ("." + part) if short_name else part
|
||||
return short_name
|
||||
|
||||
|
||||
def options_to_tree(options: dict[str, Any], debug: bool = False) -> Option:
|
||||
"""
|
||||
Convert the options dictionary to a tree structure.
|
||||
"""
|
||||
|
||||
# Helper function to create nested structure
|
||||
def add_to_tree(path_parts: list[str], info: Any, current_node: Option) -> None:
|
||||
if not path_parts:
|
||||
return
|
||||
|
||||
name = path_parts[0]
|
||||
remaining_path = path_parts[1:]
|
||||
|
||||
# Look for an existing suboption
|
||||
for sub in current_node.suboptions:
|
||||
if sub.name == name:
|
||||
add_to_tree(remaining_path, info, sub)
|
||||
return
|
||||
|
||||
# If no existing suboption is found, create a new one
|
||||
new_option = Option(
|
||||
name=name,
|
||||
path=[*current_node.path, name],
|
||||
info={}, # Populate info only at the final leaf
|
||||
)
|
||||
current_node.suboptions.append(new_option)
|
||||
|
||||
# If it's a leaf node, populate info
|
||||
if not remaining_path:
|
||||
new_option.info = info
|
||||
else:
|
||||
add_to_tree(remaining_path, info, new_option)
|
||||
|
||||
# Create the root option
|
||||
root = Option(name="<root>", path=[], info={})
|
||||
|
||||
# Process each key-value pair in the dictionary
|
||||
for key, value in options.items():
|
||||
path_parts = key.split(".")
|
||||
add_to_tree(path_parts, value, root)
|
||||
|
||||
def print_tree(option: Option, level: int = 0) -> None:
|
||||
print(" " * level + option.name + ":", option.path)
|
||||
for sub in option.suboptions:
|
||||
print_tree(sub, level + 1)
|
||||
|
||||
# Example usage
|
||||
if debug:
|
||||
print("Options tree:")
|
||||
print_tree(root)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def options_docs_from_tree(
|
||||
root: Option, init_level: int = 1, prefix: list[str] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Render the options from the tree structure.
|
||||
|
||||
Args:
|
||||
root (Option): The root option node.
|
||||
init_level (int): The initial level of indentation.
|
||||
prefix (list str): Will be printed as common prefix of all attribute names.
|
||||
"""
|
||||
|
||||
def render_tree(option: Option, level: int = init_level) -> str:
|
||||
output = ""
|
||||
|
||||
should_render = not option.name.startswith("<") and not option.name.startswith(
|
||||
"_"
|
||||
)
|
||||
if should_render:
|
||||
# short_name = option_short_name(option.name)
|
||||
path = ".".join(prefix + option.path) if prefix else ".".join(option.path)
|
||||
output += render_option(
|
||||
path,
|
||||
option.info,
|
||||
level=level,
|
||||
short_head=option.name,
|
||||
)
|
||||
|
||||
for sub in option.suboptions:
|
||||
h_increment = 1 if should_render else 0
|
||||
|
||||
if "_module" in sub.path:
|
||||
continue
|
||||
output += render_tree(sub, level + h_increment)
|
||||
|
||||
return output
|
||||
|
||||
md = render_tree(root)
|
||||
return md
|
||||
|
||||
|
||||
if __name__ == "__main__": #
|
||||
produce_clan_core_docs()
|
||||
|
||||
produce_build_clan_docs()
|
||||
produce_inventory_docs()
|
||||
|
||||
produce_clan_core_docs()
|
||||
produce_clan_modules_docs()
|
||||
|
||||
produce_clan_modules_frontmatter_docs()
|
||||
|
||||
Reference in New Issue
Block a user