From 713689ae461b5f354035275a9244fc4af947f997 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 2 Feb 2026 13:50:39 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Migrate=20`extra=5Flin?= =?UTF-8?q?ks`=20to=20Schema-Based=20Access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # PR: Migrate `extra_links` to Schema-Based Access ## Summary This PR migrates all post-config-resolution uses of `needs_config.extra_links` to use `needs_schema` methods, centralizing link type configuration in the schema system. ## Key Changes ### Schema Enhancements (`needs_schema.py`) - **Added `LinkDisplayConfig`** dataclass for link rendering configuration: - `incoming`, `outgoing` (required): Display titles for link directions - `color`, `style`, `style_part`, `style_start`, `style_end`: Diagram styling with sensible defaults - **Extended `LinkSchema`** with new attributes: - `display: LinkDisplayConfig` - Rendering configuration (required) - `copy: bool` - Whether to copy links to common `links` field - `allow_dead_links: bool` - Whether to allow dead links without warning ### Configuration Changes (`config.py`, `needs.py`) - Renamed `extra_links` → `_extra_links` (internal use only, for config resolution phase) - Schema creation in `needs.py` now populates `LinkDisplayConfig` from link config, using dataclass defaults when values aren't explicitly set ### Updated Modules Migrated from dict-based `needs_config.extra_links` access to schema methods: | Module | Change | |--------|--------| | `layout.py` | Use `schema.iter_link_fields()` and `link.display.*` | | `api/need.py` | Use `schema.iter_link_fields()` and `link.copy` | | `directives/need.py` | Use schema for `allow_dead_links` lookup | | `directives/needtable.py` | Use `LinkSchema` objects instead of dicts | | `directives/needflow/_plantuml.py` | Use schema for link types and display config | | `directives/needflow/_graphviz.py` | Use schema for link types and display config | | `directives/needgantt.py` | Use schema for link type validation | | `directives/needsequence.py` | Use schema for link type names | | `directives/needreport.py` | Convert schema to dict for template compatibility | | `directives/list2need.py` | Use schema for link type list | | `roles/need_outgoing.py` | Use schema for `allow_dead_links` check | | `utils.py` | Use schema for link field iteration | ## Migration Pattern **Before:** ```python for link_type in needs_config.extra_links: name = link_type["option"] outgoing = link_type["outgoing"] ``` **After:** ```python for link in needs_schema.iter_link_fields(): name = link.name outgoing = link.display.outgoing ``` ## Benefits - **Single source of truth**: Link configuration is centralized in the schema after config resolution - **Type safety**: `LinkSchema` and `LinkDisplayConfig` provide typed access to link properties - **Cleaner separation**: `_extra_links` is internal for config merging; schema is the public API - **Consistent defaults**: `LinkDisplayConfig` dataclass defaults are used consistently --- sphinx_needs/api/need.py | 10 +-- sphinx_needs/config.py | 4 +- sphinx_needs/directives/list2need.py | 4 +- sphinx_needs/directives/need.py | 10 ++- .../directives/needflow/_directive.py | 4 +- sphinx_needs/directives/needflow/_graphviz.py | 37 +++++----- sphinx_needs/directives/needflow/_plantuml.py | 56 ++++++--------- sphinx_needs/directives/needflow/_shared.py | 7 +- sphinx_needs/directives/needgantt.py | 4 +- sphinx_needs/directives/needreport.py | 13 +++- sphinx_needs/directives/needsequence.py | 3 +- sphinx_needs/directives/needtable.py | 70 +++++++++++-------- sphinx_needs/layout.py | 33 ++++----- sphinx_needs/needs.py | 30 ++++++-- sphinx_needs/needs_schema.py | 26 +++++++ sphinx_needs/roles/need_outgoing.py | 5 +- sphinx_needs/utils.py | 7 +- tests/__snapshots__/test_basic_doc.ambr | 4 +- 18 files changed, 191 insertions(+), 136 deletions(-) diff --git a/sphinx_needs/api/need.py b/sphinx_needs/api/need.py index 4d93ca340..d99a6065b 100644 --- a/sphinx_needs/api/need.py +++ b/sphinx_needs/api/need.py @@ -394,7 +394,7 @@ def generate_need( else v for k, v in links_no_defaults.items() } - _copy_links(links, needs_config) + _copy_links(links, needs_schema) title, title_func = _convert_to_str_func("title", title_converted) status, status_func = _convert_to_none_str_func( @@ -1156,15 +1156,15 @@ def _make_hashed_id( def _copy_links( links: dict[str, LinksLiteralValue | LinksFunctionArray | None], - config: NeedsSphinxConfig, + schema: FieldsSchema, ) -> None: """Implement 'copy' logic for links.""" if "links" not in links: return # should not happen, but be defensive copy_links: list[str | DynamicFunctionParsed | VariantFunctionParsed] = [] - for link_type in config.extra_links: - if link_type.get("copy", False) and (name := link_type["option"]) != "links": - other = links[name] + for link_field in schema.iter_link_fields(): + if link_field.copy and link_field.name != "links": + other = links[link_field.name] if isinstance(other, LinksLiteralValue | LinksFunctionArray): copy_links.extend(other.value) if any( diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index a1ca86d6f..6f433f6ad 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -688,10 +688,10 @@ def functions(self) -> Mapping[str, NeedFunctionsType]: default="→\xa0", metadata={"rebuild": "html", "types": (str,)} ) """Prefix for need_part output in tables""" - extra_links: list[LinkOptionsType] = field( + _extra_links: list[LinkOptionsType] = field( default_factory=list, metadata={"rebuild": "html", "types": ()} ) - """List of additional link types between needs""" + """List of additional link types between needs (internal config, use schema for access after config resolution)""" report_dead_links: bool = field( default=True, metadata={"rebuild": "html", "types": (bool,)} ) diff --git a/sphinx_needs/directives/list2need.py b/sphinx_needs/directives/list2need.py index 439db9951..16a9dfb34 100644 --- a/sphinx_needs/directives/list2need.py +++ b/sphinx_needs/directives/list2need.py @@ -13,6 +13,7 @@ from sphinx.util.docutils import SphinxDirective from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import SphinxNeedsData NEED_TEMPLATE = """.. {{type}}:: {{title}} {% if need_id is not none %}:id: {{need_id}}{%endif%} @@ -100,7 +101,8 @@ def run(self) -> Sequence[nodes.Node]: down_links_raw_list = [] else: down_links_raw_list = [x.strip() for x in down_links_raw.split(",")] - link_types = [x["option"] for x in needs_config.extra_links] + needs_schema = SphinxNeedsData(self.env).get_schema() + link_types = [link.name for link in needs_schema.iter_link_fields()] for i, down_link_raw in enumerate(down_links_raw_list): down_links_types[i] = down_link_raw if down_link_raw not in link_types: diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index 2d5cacd9a..60637be52 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -24,6 +24,7 @@ from sphinx_needs.logging import WarningSubTypes, get_logger, log_warning from sphinx_needs.need_constraints import process_constraints from sphinx_needs.need_item import NeedItem, NeedItemSourceDirective +from sphinx_needs.needs_schema import FieldsSchema from sphinx_needs.nodes import Need from sphinx_needs.utils import ( DummyOptionSpec, @@ -356,11 +357,12 @@ def post_process_needs_data(app: Sphinx) -> None: needs_data = SphinxNeedsData(app.env) if not needs_data.needs_is_post_processed: needs_config = NeedsSphinxConfig(app.config) + needs_schema = needs_data.get_schema() needs = needs_data.get_needs_mutable() app.emit("needs-before-post-processing", needs) extend_needs_data(needs, needs_data.get_or_create_extends(), needs_config) resolve_functions(app, needs, needs_config) - update_back_links(needs, needs_config) + update_back_links(needs, needs_config, needs_schema) process_constraints(needs, needs_config) app.emit("needs-before-sealing", needs) # run a last check to ensure all needs are of the correct type @@ -430,7 +432,9 @@ def format_need_nodes( node_need.parent.replace(node_need, rendered_node) -def update_back_links(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: +def update_back_links( + needs: NeedsMutable, config: NeedsSphinxConfig, schema: FieldsSchema +) -> None: """Update needs with back-links, i.e. for each need A that links to need B,""" for need in needs.values(): need.reset_backlinks() @@ -455,7 +459,7 @@ def update_back_links(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: need["has_dead_links"] = bool(dead_links) allow_dead_links = { - li["option"]: li.get("allow_dead_links", False) for li in config.extra_links + link.name: link.allow_dead_links for link in schema.iter_link_fields() } need["has_forbidden_dead_links"] = bool( any(not allow_dead_links.get(lt, False) for lt, _ in dead_links) diff --git a/sphinx_needs/directives/needflow/_directive.py b/sphinx_needs/directives/needflow/_directive.py index 0c5f26526..ebab51247 100644 --- a/sphinx_needs/directives/needflow/_directive.py +++ b/sphinx_needs/directives/needflow/_directive.py @@ -13,6 +13,7 @@ from sphinx_needs.data import ( GraphvizStyleType, NeedsFlowType, + SphinxNeedsData, ) from sphinx_needs.debug import measure_time from sphinx_needs.filter_common import FilterBase @@ -73,7 +74,8 @@ def run(self) -> Sequence[nodes.Node]: id = self.env.new_serialno("needflow") targetid = f"needflow-{self.env.docname}-{id}" - all_link_types = ",".join(x["option"] for x in needs_config.extra_links) + needs_schema = SphinxNeedsData(self.env).get_schema() + all_link_types = ",".join(link.name for link in needs_schema.iter_link_fields()) link_types = split_link_types( self.options.get("link_types", all_link_types), location ) diff --git a/sphinx_needs/directives/needflow/_graphviz.py b/sphinx_needs/directives/needflow/_graphviz.py index 8ffba810f..46dd59285 100644 --- a/sphinx_needs/directives/needflow/_graphviz.py +++ b/sphinx_needs/directives/needflow/_graphviz.py @@ -16,7 +16,7 @@ ) from sphinx.util.logging import getLogger -from sphinx_needs.config import LinkOptionsType, NeedsSphinxConfig +from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData from sphinx_needs.debug import measure_time from sphinx_needs.directives.needflow._directive import NeedflowGraphiz @@ -28,6 +28,7 @@ ) from sphinx_needs.logging import log_warning from sphinx_needs.need_item import NeedItem, NeedPartItem +from sphinx_needs.needs_schema import LinkSchema from sphinx_needs.utils import remove_node_from_tree from sphinx_needs.variants import match_variants from sphinx_needs.views import NeedsView @@ -50,10 +51,11 @@ def process_needflow_graphviz( found_nodes: list[nodes.Element], ) -> None: needs_config = NeedsSphinxConfig(app.config) + needs_schema = SphinxNeedsData(app.env).get_schema() env_data = SphinxNeedsData(app.env) needs_view = env_data.get_needs_view() - link_type_names = [link["option"].upper() for link in needs_config.extra_links] + link_type_names = [name.upper() for name in needs_schema.iter_link_field_names()] allowed_link_types_options = [link.upper() for link in needs_config.flow_link_types] node: NeedflowGraphiz @@ -93,29 +95,28 @@ def process_needflow_graphviz( None, ) - # compute the allowed link names - allowed_link_types: list[LinkOptionsType] = [] - for link_type in needs_config.extra_links: + # compute the allowed link types + allowed_link_types: list[LinkSchema] = [] + for link in needs_schema.iter_link_fields(): # Skip link-type handling, if it is not part of a specified list of allowed link_types or # if not part of the overall configuration of needs_flow_link_types if ( - attributes["link_types"] - and link_type["option"].upper() not in option_link_types + attributes["link_types"] and link.name.upper() not in option_link_types ) or ( not attributes["link_types"] - and link_type["option"].upper() not in allowed_link_types_options + and link.name.upper() not in allowed_link_types_options ): continue # skip creating links from child needs to their own parent need - if link_type["option"] == "parent_needs": + if link.name == "parent_needs": continue - allowed_link_types.append(link_type) + allowed_link_types.append(link) init_filtered_needs = ( filter_by_tree( needs_view, root_id, - allowed_link_types, + [lt.name for lt in allowed_link_types], attributes["root_direction"], attributes["root_depth"], ) @@ -169,7 +170,7 @@ def process_needflow_graphviz( content += "\n// edge definitions\n" for need in filtered_needs: for link_type in allowed_link_types: - for link in need[link_type["option"]]: + for link in need[link_type.name]: content += _render_edge( need, link, link_type, node, needs_config, rendered_nodes ) @@ -440,7 +441,7 @@ def _label( def _render_edge( need: NeedItem | NeedPartItem, link: str, - link_type: LinkOptionsType, + link_type: LinkSchema, node: NeedflowGraphiz, config: NeedsSphinxConfig, rendered_nodes: dict[str, _RenderedNode], @@ -455,16 +456,14 @@ def _render_edge( params: list[tuple[str, str]] = [] if show_links: - params.append(("label", _quote(link_type["outgoing"]))) + params.append(("label", _quote(link_type.display.outgoing))) is_part = "." in link or "." in need["id_complete"] params.extend( _style_params_from_link_type( - link_type.get("style_part", "dotted") - if is_part - else link_type.get("style", ""), - link_type.get("style_start", "-"), - link_type.get("style_end", "->"), + link_type.display.style_part if is_part else link_type.display.style, + link_type.display.style_start, + link_type.display.style_end, ) ) diff --git a/sphinx_needs/directives/needflow/_plantuml.py b/sphinx_needs/directives/needflow/_plantuml.py index 0166f4c98..9745bdce2 100644 --- a/sphinx_needs/directives/needflow/_plantuml.py +++ b/sphinx_needs/directives/needflow/_plantuml.py @@ -8,7 +8,7 @@ from jinja2 import Template from sphinx.application import Sphinx -from sphinx_needs.config import LinkOptionsType, NeedsSphinxConfig +from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import NeedsFlowType, SphinxNeedsData from sphinx_needs.debug import measure_time from sphinx_needs.diagrams_common import calculate_link, create_legend @@ -17,6 +17,7 @@ from sphinx_needs.filter_common import filter_single_need, process_filters from sphinx_needs.logging import get_logger, log_warning from sphinx_needs.need_item import NeedItem, NeedPartItem +from sphinx_needs.needs_schema import LinkSchema from sphinx_needs.utils import remove_node_from_tree from sphinx_needs.variants import match_variants from sphinx_needs.views import NeedsView @@ -193,8 +194,9 @@ def process_needflow_plantuml( needs_config = NeedsSphinxConfig(app.config) env_data = SphinxNeedsData(env) needs_view = env_data.get_needs_view() + needs_schema = env_data.get_schema() - link_type_names = [link["option"].upper() for link in needs_config.extra_links] + link_type_names = [link.name.upper() for link in needs_schema.iter_link_fields()] allowed_link_types_options = [link.upper() for link in needs_config.flow_link_types] node: NeedflowPlantuml @@ -219,23 +221,23 @@ def process_needflow_plantuml( location=node, ) - # compute the allowed link names - allowed_link_types: list[LinkOptionsType] = [] - for link_type in needs_config.extra_links: + # compute the allowed link types + allowed_link_types: list[LinkSchema] = [] + for link_field in needs_schema.iter_link_fields(): # Skip link-type handling, if it is not part of a specified list of allowed link_types or # if not part of the overall configuration of needs_flow_link_types if ( current_needflow["link_types"] - and link_type["option"].upper() not in option_link_types + and link_field.name.upper() not in option_link_types ) or ( not current_needflow["link_types"] - and link_type["option"].upper() not in allowed_link_types_options + and link_field.name.upper() not in allowed_link_types_options ): continue # skip creating links from child needs to their own parent need - if link_type["option"] == "parent_needs": + if link_field.name == "parent_needs": continue - allowed_link_types.append(link_type) + allowed_link_types.append(link_field) try: if "sphinxcontrib.plantuml" not in app.extensions: @@ -256,7 +258,7 @@ def process_needflow_plantuml( filter_by_tree( needs_view, root_id, - allowed_link_types, + [lt.name for lt in allowed_link_types], current_needflow["root_direction"], current_needflow["root_depth"], ) @@ -386,7 +388,7 @@ def process_needflow_plantuml( def render_connections( found_needs: list[NeedItem | NeedPartItem], - allowed_link_types: list[LinkOptionsType], + allowed_link_types: list[LinkSchema], show_links: bool, ) -> str: """ @@ -395,7 +397,7 @@ def render_connections( puml_connections = "" for need_info in found_needs: for link_type in allowed_link_types: - for link in need_info[link_type["option"]]: + for link in need_info[link_type.name]: # Do not create an links, if the link target is not part of the search result. if link not in [ x["id"] for x in found_needs if x["is_need"] @@ -405,40 +407,28 @@ def render_connections( continue if show_links: - desc = link_type["outgoing"] + "\\n" + desc = link_type.display.outgoing + "\\n" comment = f": {desc}" else: comment = "" # If source or target of link is a need_part, a specific style is needed if "." in link or "." in need_info["id_complete"]: - if _style_part := link_type.get("style_part"): - link_style = f"[{_style_part}]" - else: - link_style = "[dotted]" - else: - if _style := link_type.get("style"): - link_style = f"[{_style}]" - else: - link_style = "" - - if _style_start := link_type.get("style_start"): - style_start = _style_start - else: - style_start = "-" - - if _style_end := link_type.get("style_end"): - style_end = _style_end + link_style = f"[{link_type.display.style_part}]" else: - style_end = "->" + link_style = ( + f"[{link_type.display.style}]" + if link_type.display.style + else "" + ) puml_connections += "{id} {style_start}{link_style}{style_end} {link}{comment}\n".format( id=make_entity_name(need_info["id_complete"]), link=make_entity_name(link), comment=comment, link_style=link_style, - style_start=style_start, - style_end=style_end, + style_start=link_type.display.style_start, + style_end=link_type.display.style_end, ) return puml_connections diff --git a/sphinx_needs/directives/needflow/_shared.py b/sphinx_needs/directives/needflow/_shared.py index 6bce766b1..7ed781729 100644 --- a/sphinx_needs/directives/needflow/_shared.py +++ b/sphinx_needs/directives/needflow/_shared.py @@ -4,7 +4,6 @@ from docutils import nodes -from sphinx_needs.config import LinkOptionsType from sphinx_needs.data import NeedsFlowType from sphinx_needs.logging import get_logger from sphinx_needs.need_item import NeedItem, NeedPartItem @@ -16,7 +15,7 @@ def filter_by_tree( needs_view: NeedsView, root_id: str, - link_types: list[LinkOptionsType], + link_names: list[str], direction: Literal["both", "incoming", "outgoing"], depth: int | None, ) -> NeedsView: @@ -32,9 +31,7 @@ def filter_by_tree( if direction == "outgoing" else ("", "_back") ) - links_to_process = [ - link["option"] + d for link in link_types for d in link_prefixes - ] + links_to_process = [link + d for link in link_names for d in link_prefixes] need_ids: list[str] = [] while roots: diff --git a/sphinx_needs/directives/needgantt.py b/sphinx_needs/directives/needgantt.py index 8d2e9fd10..3c7c14c6d 100644 --- a/sphinx_needs/directives/needgantt.py +++ b/sphinx_needs/directives/needgantt.py @@ -149,8 +149,8 @@ def get_link_type_option(self, name: str, default: str = "") -> list[str]: link_types = [ x.strip() for x in re.split(";|,", self.options.get(name, default)) ] - conf_link_types = NeedsSphinxConfig(self.env.config).extra_links - conf_link_types_name = [x["option"] for x in conf_link_types] + needs_schema = SphinxNeedsData(self.env).get_schema() + conf_link_types_name = [link.name for link in needs_schema.iter_link_fields()] final_link_types = [] for link_type in link_types: diff --git a/sphinx_needs/directives/needreport.py b/sphinx_needs/directives/needreport.py index 0d5590b2f..4048553a2 100644 --- a/sphinx_needs/directives/needreport.py +++ b/sphinx_needs/directives/needreport.py @@ -46,7 +46,18 @@ def run(self) -> Sequence[nodes.raw]: "options": list(needs_schema.iter_extra_field_names()) if "options" in self.options else [], - "links": needs_config.extra_links if "links" in self.options else [], + "links": [ + { + "option": link.name, + "incoming": link.display.incoming, + "outgoing": link.display.outgoing, + "copy": link.copy, + "allow_dead_links": link.allow_dead_links, + } + for link in needs_schema.iter_link_fields() + ] + if "links" in self.options + else [], # note the usage dict format here is just to keep backwards compatibility, # but actually this is now post-processed so we only really need the need types "usage": { diff --git a/sphinx_needs/directives/needsequence.py b/sphinx_needs/directives/needsequence.py index c0e06a7c4..3dd425894 100644 --- a/sphinx_needs/directives/needsequence.py +++ b/sphinx_needs/directives/needsequence.py @@ -86,11 +86,12 @@ def process_needsequence( # Replace all needsequence nodes with a list of the collected needs. env = app.env needs_data = SphinxNeedsData(env) + needs_schema = needs_data.get_schema() all_needs_dict = needs_data.get_needs_view() needs_config = NeedsSphinxConfig(env.config) include_needs = needs_config.include_needs - link_type_names = [link["option"].upper() for link in needs_config.extra_links] + link_type_names = [name.upper() for name in needs_schema.iter_link_field_names()] needs_types = needs_config.types # NEEDSEQUENCE diff --git a/sphinx_needs/directives/needtable.py b/sphinx_needs/directives/needtable.py index 4cf807589..2108652fe 100644 --- a/sphinx_needs/directives/needtable.py +++ b/sphinx_needs/directives/needtable.py @@ -21,6 +21,7 @@ from sphinx_needs.filter_common import FilterBase, process_filters from sphinx_needs.functions.functions import check_and_get_content from sphinx_needs.need_item import NeedItem, NeedPartItem +from sphinx_needs.needs_schema import LinkSchema from sphinx_needs.utils import add_doc, profile, remove_node_from_tree, row_col_maker @@ -133,20 +134,21 @@ def process_needtables( env = app.env needs_config = NeedsSphinxConfig(app.config) needs_data = SphinxNeedsData(env) + needs_schema = needs_data.get_schema() # Create a link_type dictionary, which keys-list can be easily used to find columns - link_type_list = {} - for link_type in needs_config.extra_links: - link_type_list[link_type["option"].upper()] = link_type - link_type_list[link_type["option"].upper() + "_BACK"] = link_type - link_type_list[link_type["incoming"].upper()] = link_type - link_type_list[link_type["outgoing"].upper()] = link_type + link_type_list: dict[str, LinkSchema] = {} + for link in needs_schema.iter_link_fields(): + link_type_list[link.name.upper()] = link + link_type_list[link.name.upper() + "_BACK"] = link + link_type_list[link.display.incoming.upper()] = link + link_type_list[link.display.outgoing.upper()] = link # Extra handling for backward compatibility, as INCOMING and OUTGOING are # known und used column names for incoming/outgoing links - if link_type["option"] == "links": - link_type_list["OUTGOING"] = link_type - link_type_list["INCOMING"] = link_type + if link.name == "links": + link_type_list["OUTGOING"] = link + link_type_list["INCOMING"] = link all_needs = needs_data.get_needs_view() @@ -276,18 +278,19 @@ def sort(need: NeedItem | NeedPartItem) -> Any: app, fromdocname, all_needs, temp_need, "title", prefix=prefix ) elif option in link_type_list: - link_type = link_type_list[option] + link = link_type_list[option] + incoming_title = link.display.incoming.upper() if option in [ "INCOMING", - link_type["option"].upper() + "_BACK", - link_type["incoming"].upper(), + link.name.upper() + "_BACK", + incoming_title, ]: row += row_col_maker( app, fromdocname, all_needs, temp_need, - link_type["option"] + "_back", + link.name + "_back", ref_lookup=True, ) else: @@ -296,7 +299,7 @@ def sort(need: NeedItem | NeedPartItem) -> Any: fromdocname, all_needs, temp_need, - link_type["option"], + link.name, ref_lookup=True, ) else: @@ -334,22 +337,31 @@ def sort(need: NeedItem | NeedPartItem) -> Any: "content", prefix=needs_config.part_prefix, ) - elif option in link_type_list and ( - option - in [ + elif option in link_type_list: + link = link_type_list[option] + incoming_title = link.display.incoming.upper() + if option in [ "INCOMING", - link_type_list[option]["option"].upper() + "_BACK", - link_type_list[option]["incoming"].upper(), - ] - ): - row += row_col_maker( - app, - fromdocname, - all_needs, - temp_part, - link_type_list[option]["option"] + "_back", - ref_lookup=True, - ) + link.name.upper() + "_BACK", + incoming_title, + ]: + row += row_col_maker( + app, + fromdocname, + all_needs, + temp_part, + link.name + "_back", + ref_lookup=True, + ) + else: + row += row_col_maker( + app, + fromdocname, + all_needs, + temp_part, + link.name, + ref_lookup=True, + ) else: row += row_col_maker( app, fromdocname, all_needs, temp_part, option.lower() diff --git a/sphinx_needs/layout.py b/sphinx_needs/layout.py index 8e7098630..2ddd03cb4 100644 --- a/sphinx_needs/layout.py +++ b/sphinx_needs/layout.py @@ -27,7 +27,7 @@ from sphinx.util.logging import getLogger from sphinx_needs.config import NeedsSphinxConfig -from sphinx_needs.data import NeedsCoreFields +from sphinx_needs.data import NeedsCoreFields, SphinxNeedsData from sphinx_needs.debug import measure_time from sphinx_needs.logging import log_warning from sphinx_needs.need_item import NeedItem @@ -111,6 +111,7 @@ def __init__( self.app = app self.need = need self.needs_config = NeedsSphinxConfig(app.config) + self.needs_schema = SphinxNeedsData(app.env).get_schema() self.layout_name = ( layout or self.need["layout"] or self.needs_config.default_layout @@ -642,8 +643,9 @@ def meta_all( exclude += default_excludes if no_links: - link_names = [x["option"] for x in self.needs_config.extra_links] - link_names += [x["option"] + "_back" for x in self.needs_config.extra_links] + link_names = list(self.needs_schema.iter_link_field_names()) + [ + f"{x}_back" for x in self.needs_schema.iter_link_field_names() + ] exclude += link_names data_container = nodes.inline() for data in self.need: @@ -674,14 +676,9 @@ def meta_links(self, name: str, incoming: bool = False) -> nodes.inline: :return: docutils nodes """ data_container = nodes.inline(classes=[name]) - if name not in [x["option"] for x in self.needs_config.extra_links]: + if self.needs_schema.get_link_field(name) is None: raise SphinxNeedLayoutException(f"Invalid link name {name} for link-type") - # if incoming: - # link_name = self.config.extra_links[name]['incoming'] - # else: - # link_name = self.config.extra_links[name]['outgoing'] - from sphinx_needs.roles.need_incoming import NeedIncoming from sphinx_needs.roles.need_outgoing import NeedOutgoing @@ -707,25 +704,21 @@ def meta_links_all( """ exclude = exclude or [] data_container = [] - for link_type in self.needs_config.extra_links: - type_key = link_type["option"] + for link in self.needs_schema.iter_link_fields(): + type_key = link.name if self.need[type_key] and type_key not in exclude: outgoing_line = nodes.line() - outgoing_label = ( - prefix + "{}:".format(link_type["outgoing"]) + postfix + " " - ) + outgoing_label = prefix + f"{link.display.outgoing}:" + postfix + " " outgoing_line += self._parse(outgoing_label) - outgoing_line += self.meta_links(link_type["option"], incoming=False) + outgoing_line += self.meta_links(link.name, incoming=False) data_container.append(outgoing_line) - type_key = link_type["option"] + "_back" + type_key = link.name + "_back" if self.need[type_key] and type_key not in exclude: incoming_line = nodes.line() - incoming_label = ( - prefix + "{}:".format(link_type["incoming"]) + postfix + " " - ) + incoming_label = prefix + f"{link.display.incoming}:" + postfix + " " incoming_line += self._parse(incoming_label) - incoming_line += self.meta_links(link_type["option"], incoming=True) + incoming_line += self.meta_links(link.name, incoming=True) data_container.append(incoming_line) return data_container diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index d83124c21..59b77b2d4 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -113,6 +113,7 @@ FieldLiteralValue, FieldSchema, FieldsSchema, + LinkDisplayConfig, LinkSchema, LinksLiteralValue, create_inherited_field, @@ -691,7 +692,7 @@ def merge_default_configs(_app: Sphinx, config: Config) -> None: # The default link name. Must exist in all configurations. Therefore we set it here # for the user. common_links: list[LinkOptionsType] = [] - link_types = needs_config.extra_links + link_types = needs_config._extra_links basic_link_type_found = False parent_needs_link_type_found = False for link_type in link_types: @@ -722,9 +723,9 @@ def merge_default_configs(_app: Sphinx, config: Config) -> None: } ) - needs_config.extra_links = common_links + needs_config.extra_links + needs_config._extra_links = common_links + needs_config._extra_links - for link in needs_config.extra_links: + for link in needs_config._extra_links: if "outgoing" not in link: link["outgoing"] = link["option"] if "incoming" not in link: @@ -738,7 +739,7 @@ def check_configuration(app: Sphinx, config: Config) -> None: """ needs_config = NeedsSphinxConfig(config) extra_options = _NEEDS_CONFIG.extra_options - link_types = [x["option"] for x in needs_config.extra_links] + link_types = [x["option"] for x in needs_config._extra_links] external_filter = needs_config.filter_data for extern_filter, value in external_filter.items(): @@ -926,7 +927,7 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N except Exception as exc: raise NeedsConfigException(f"Invalid extra option {name!r}: {exc}") from exc - for link in needs_config.extra_links: + for link in needs_config._extra_links: name = link["option"] try: # create link schema, with defaults if not defined @@ -943,9 +944,21 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N _schema["items"]["type"] = "string" if "contains" in _schema and "type" not in _schema["contains"]: _schema["contains"]["type"] = "string" + # Build display config from link options + # Only pass explicitly set values; let LinkDisplayConfig use its defaults + display_kwargs: dict[str, str] = { + # These are required fields with no defaults in LinkDisplayConfig + "incoming": link.get("incoming", f"{name} incoming"), + "outgoing": link.get("outgoing", name), + } + # Only override optional fields if explicitly set in config + for key in ("color", "style", "style_part", "style_start", "style_end"): + if key in link: + display_kwargs[key] = link[key] + display_config = LinkDisplayConfig(**display_kwargs) link_field = LinkSchema( name=name, - description="Link field", + description="Link field", # TODO allow this to be set by the user schema=_schema, # type: ignore[arg-type] default=LinksLiteralValue([]), allow_defaults=True, @@ -953,6 +966,9 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N parse_dynamic_functions=True, parse_variants=link.get("parse_variants", False), directive_option=True, + display=display_config, + copy=link.get("copy", False), + allow_dead_links=link.get("allow_dead_links", False), ) schema.add_link_field(link_field) except Exception as exc: @@ -961,7 +977,7 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N for field_name, field_config in needs_config._fields.items(): _set_default(schema, "needs_fields", field_name, field_config) - for link_config in needs_config.extra_links: + for link_config in needs_config._extra_links: _set_default(schema, "needs_extra_links", link_config["option"], link_config) if needs_config._global_options: diff --git a/sphinx_needs/needs_schema.py b/sphinx_needs/needs_schema.py index 9f6e5dd1e..2256b8a51 100644 --- a/sphinx_needs/needs_schema.py +++ b/sphinx_needs/needs_schema.py @@ -517,6 +517,26 @@ def _from_string_item( raise RuntimeError(f"Unknown item type {item_type!r}{prefix}") +@dataclass(frozen=True, kw_only=True, slots=True) +class LinkDisplayConfig: + """Display/rendering configuration for a link type.""" + + incoming: str + """Title for incoming links (e.g., 'links incoming').""" + outgoing: str + """Title for outgoing links (e.g., 'links outgoing').""" + color: str = "#000000" + """Color used for needflow diagrams.""" + style: str = "" + """Line style used for needflow diagrams.""" + style_part: str = "dotted" + """Line style used for need parts in needflow diagrams.""" + style_start: str = "-" + """Arrow start style for needflow diagrams.""" + style_end: str = "->" + """Arrow end style for needflow diagrams.""" + + @dataclass(frozen=True, kw_only=True, slots=True) class LinkSchema: """Schema for a single link field.""" @@ -544,6 +564,12 @@ class LinkSchema: Used if the field has not been specifically set, and no predicate matches. """ + display: LinkDisplayConfig + """Display/rendering configuration for this link type.""" + copy: bool = False + """If True, copy links to the common 'links' field.""" + allow_dead_links: bool = False + """If True, add a 'forbidden' class to dead links instead of warning.""" def __post_init__(self) -> None: if not isinstance(self.name, str) or not self.name: diff --git a/sphinx_needs/roles/need_outgoing.py b/sphinx_needs/roles/need_outgoing.py index 072d92f87..0286ddc5c 100644 --- a/sphinx_needs/roles/need_outgoing.py +++ b/sphinx_needs/roles/need_outgoing.py @@ -26,7 +26,7 @@ def process_need_outgoing( builder = app.builder env = app.env needs_config = NeedsSphinxConfig(app.config) - link_lookup = {link["option"]: link for link in needs_config.extra_links} + needs_schema = SphinxNeedsData(env).get_schema() # for node_need_ref in doctree.findall(NeedOutgoing): for node_need_ref in found_nodes: @@ -124,7 +124,8 @@ def process_need_outgoing( # add a CSS class for disallowed unknown links # note a warning is already emitted when validating the needs list # so we don't need to do it here - if not link_lookup.get(link_type, {}).get("allow_dead_links", False): # type: ignore + link_field = needs_schema.get_link_field(link_type) + if not (link_field and link_field.allow_dead_links): dead_link_para.attributes["classes"].append("forbidden") # If we have several links, we add an empty text between them diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py index b31d1a79e..0072a2057 100644 --- a/sphinx_needs/utils.py +++ b/sphinx_needs/utils.py @@ -140,10 +140,11 @@ def row_col_maker( link_id = datum link_part = None + needs_schema = SphinxNeedsData(env).get_schema() link_list = [] - for link_type in needs_config.extra_links: - link_list.append(link_type["option"]) - link_list.append(link_type["option"] + "_back") + for link_field in needs_schema.iter_link_fields(): + link_list.append(link_field.name) + link_list.append(link_field.name + "_back") # For needs_string_links link_string_list = {} diff --git a/tests/__snapshots__/test_basic_doc.ambr b/tests/__snapshots__/test_basic_doc.ambr index 2a86a884c..b534c122d 100644 --- a/tests/__snapshots__/test_basic_doc.ambr +++ b/tests/__snapshots__/test_basic_doc.ambr @@ -606,11 +606,11 @@ 'hide': FieldSchema(name='hide', description='If true, the need is not rendered.', schema={'type': 'boolean'}, nullable=False, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=FieldLiteralValue(value=False)), 'id_prefix': FieldSchema(name='id_prefix', description='Added by service github-issues', schema={'type': 'string'}, nullable=False, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=FieldLiteralValue(value='')), 'layout': FieldSchema(name='layout', description='Key of the layout, which is used to render the need.', schema={'type': 'string'}, nullable=True, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=None), - 'links': LinkSchema(name='links', description='Link field', schema={'type': 'array', 'items': {'type': 'string'}}, directive_option=True, allow_extend=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, predicate_defaults=(), default=LinksLiteralValue(value=[])), + 'links': LinkSchema(name='links', description='Link field', schema={'type': 'array', 'items': {'type': 'string'}}, directive_option=True, allow_extend=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, predicate_defaults=(), default=LinksLiteralValue(value=[]), display=LinkDisplayConfig(incoming='links incoming', outgoing='links outgoing', color='#000000', style='', style_part='dotted', style_start='-', style_end='->'), copy=False, allow_dead_links=False), 'max_amount': FieldSchema(name='max_amount', description='Added by service github-issues', schema={'type': 'string'}, nullable=False, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=FieldLiteralValue(value='')), 'max_content_lines': FieldSchema(name='max_content_lines', description='Added by service github-issues', schema={'type': 'string'}, nullable=False, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=FieldLiteralValue(value='')), 'params': FieldSchema(name='params', description='Added by service open-needs', schema={'type': 'string'}, nullable=False, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=FieldLiteralValue(value='')), - 'parent_needs': LinkSchema(name='parent_needs', description='Link field', schema={'type': 'array', 'items': {'type': 'string'}}, directive_option=True, allow_extend=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, predicate_defaults=(), default=LinksLiteralValue(value=[])), + 'parent_needs': LinkSchema(name='parent_needs', description='Link field', schema={'type': 'array', 'items': {'type': 'string'}}, directive_option=True, allow_extend=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, predicate_defaults=(), default=LinksLiteralValue(value=[]), display=LinkDisplayConfig(incoming='child needs', outgoing='parent needs', color='#333333', style='', style_part='dotted', style_start='-', style_end='->'), copy=False, allow_dead_links=False), 'post_template': FieldSchema(name='post_template', description='The template key, if the post_content was created from a jinja template.', schema={'type': 'string'}, nullable=True, directive_option=True, parse_dynamic_functions=False, parse_variants=False, allow_defaults=True, allow_extend=False, predicate_defaults=(), default=None), 'pre_template': FieldSchema(name='pre_template', description='The template key, if the pre_content was created from a jinja template.', schema={'type': 'string'}, nullable=True, directive_option=True, parse_dynamic_functions=False, parse_variants=False, allow_defaults=True, allow_extend=False, predicate_defaults=(), default=None), 'prefix': FieldSchema(name='prefix', description='Added by service open-needs', schema={'type': 'string'}, nullable=False, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=FieldLiteralValue(value='')), From 806c219e6ad65edaf20d60b46d321998ccc26c38 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 2 Feb 2026 13:57:03 +0100 Subject: [PATCH 2/5] fixes --- docs/api.rst | 2 +- sphinx_needs/directives/needtable.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 6732b820c..132e7afd9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -59,7 +59,7 @@ Schema .. automodule:: sphinx_needs.needs_schema :members: FieldsSchema, FieldSchema, FieldFunctionArray, LinksFunctionArray, - FieldLiteralValue, LinkSchema, LinksLiteralValue, AllowedTypes + FieldLiteralValue, LinkSchema, LinkDisplayConfig, LinksLiteralValue, AllowedTypes .. automodule:: sphinx_needs.schema.config :members: ExtraOptionStringSchemaType, ExtraOptionBooleanSchemaType, diff --git a/sphinx_needs/directives/needtable.py b/sphinx_needs/directives/needtable.py index 2108652fe..8320d6baa 100644 --- a/sphinx_needs/directives/needtable.py +++ b/sphinx_needs/directives/needtable.py @@ -339,11 +339,10 @@ def sort(need: NeedItem | NeedPartItem) -> Any: ) elif option in link_type_list: link = link_type_list[option] - incoming_title = link.display.incoming.upper() if option in [ "INCOMING", link.name.upper() + "_BACK", - incoming_title, + link.display.incoming.upper(), ]: row += row_col_maker( app, From 67c28f7e6e7bbf5023280deaa670e3bf1e2fd602 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 2 Feb 2026 13:58:03 +0100 Subject: [PATCH 3/5] Update needtable.py --- sphinx_needs/directives/needtable.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sphinx_needs/directives/needtable.py b/sphinx_needs/directives/needtable.py index 8320d6baa..49bd3949a 100644 --- a/sphinx_needs/directives/needtable.py +++ b/sphinx_needs/directives/needtable.py @@ -279,11 +279,10 @@ def sort(need: NeedItem | NeedPartItem) -> Any: ) elif option in link_type_list: link = link_type_list[option] - incoming_title = link.display.incoming.upper() if option in [ "INCOMING", link.name.upper() + "_BACK", - incoming_title, + link.display.incoming.upper(), ]: row += row_col_maker( app, From b620a75916145f8cdfe63e7a5872629ed34b5178 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 2 Feb 2026 14:03:13 +0100 Subject: [PATCH 4/5] Update needtable.py --- sphinx_needs/directives/needtable.py | 39 +++++++++++----------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/sphinx_needs/directives/needtable.py b/sphinx_needs/directives/needtable.py index 49bd3949a..d59fe7488 100644 --- a/sphinx_needs/directives/needtable.py +++ b/sphinx_needs/directives/needtable.py @@ -336,30 +336,21 @@ def sort(need: NeedItem | NeedPartItem) -> Any: "content", prefix=needs_config.part_prefix, ) - elif option in link_type_list: - link = link_type_list[option] - if option in [ - "INCOMING", - link.name.upper() + "_BACK", - link.display.incoming.upper(), - ]: - row += row_col_maker( - app, - fromdocname, - all_needs, - temp_part, - link.name + "_back", - ref_lookup=True, - ) - else: - row += row_col_maker( - app, - fromdocname, - all_needs, - temp_part, - link.name, - ref_lookup=True, - ) + elif ( + link_ := link_type_list.get(option) + ) is not None and option in [ + "INCOMING", + link_.name.upper() + "_BACK", + link_.display.incoming.upper(), + ]: + row += row_col_maker( + app, + fromdocname, + all_needs, + temp_part, + link_.name + "_back", + ref_lookup=True, + ) else: row += row_col_maker( app, fromdocname, all_needs, temp_part, option.lower() From 8ff20522e0a648dfe240bd1183a9339a4e50bee2 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 2 Feb 2026 14:18:38 +0100 Subject: [PATCH 5/5] add todos --- sphinx_needs/directives/needflow/_graphviz.py | 1 + sphinx_needs/directives/needflow/_plantuml.py | 1 + 2 files changed, 2 insertions(+) diff --git a/sphinx_needs/directives/needflow/_graphviz.py b/sphinx_needs/directives/needflow/_graphviz.py index 46dd59285..cf6fd5228 100644 --- a/sphinx_needs/directives/needflow/_graphviz.py +++ b/sphinx_needs/directives/needflow/_graphviz.py @@ -460,6 +460,7 @@ def _render_edge( is_part = "." in link or "." in need["id_complete"] params.extend( + # TODO also use link_type.display.color? _style_params_from_link_type( link_type.display.style_part if is_part else link_type.display.style, link_type.display.style_start, diff --git a/sphinx_needs/directives/needflow/_plantuml.py b/sphinx_needs/directives/needflow/_plantuml.py index 9745bdce2..2d18d5ed4 100644 --- a/sphinx_needs/directives/needflow/_plantuml.py +++ b/sphinx_needs/directives/needflow/_plantuml.py @@ -422,6 +422,7 @@ def render_connections( else "" ) + # TODO also use link_type.display.color? puml_connections += "{id} {style_start}{link_style}{style_end} {link}{comment}\n".format( id=make_entity_name(need_info["id_complete"]), link=make_entity_name(link),