Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ Each configured link can define:
Default: ``False``.
- ``parse_dynamic_functions``: If set to ``True``, the field will support :ref:`dynamic_functions`.
Default: the value of :ref:`needs_parse_dynamic_functions` (``True``).
- ``parse_conditions``: If set to ``False``, the ``[condition]`` bracket syntax will not be parsed for this link type.
Default: ``True``.
- ``incoming`` (optional): Incoming text, to use for incoming links. E.g. "is blocked by". Default: "<name> incoming".
- ``outgoing`` (optional): Outgoing text, to use for outgoing links. E.g. "blocks". Default: "<name>".
- ``copy`` (optional): True/False. If True, the links will be copied also to the common link-list (link type ``links``).
Expand Down Expand Up @@ -2830,6 +2832,8 @@ Each configured link should define:
Default: ``False``.
- ``parse_dynamic_functions``: If set to ``True``, the field will support :ref:`dynamic_functions`.
Default: the value of :ref:`needs_parse_dynamic_functions` (``True``).
- ``parse_conditions``: If set to ``False``, the ``[condition]`` bracket syntax will not be parsed for this link type.
Default: ``True``.
- ``incoming`` (optional): Incoming text, to use for incoming links. E.g. "is blocked by".
- ``outgoing`` (optional): Outgoing text, to use for outgoing links. E.g. "blocks".
- ``copy`` (optional): True/False. If True, the links will be copied also to the common link-list (link type ``links``).
Expand Down
2 changes: 2 additions & 0 deletions sphinx_needs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ class NeedLinksConfig(TypedDict, total=False):
"""Whether variants are parsed in this field."""
parse_dynamic_functions: NotRequired[bool]
"""Whether dynamic functions are parsed in this field."""
parse_conditions: NotRequired[bool]
"""Whether conditions (bracket syntax) are parsed in this field."""


class LinkOptionsType(NeedLinksConfig):
Expand Down
16 changes: 13 additions & 3 deletions sphinx_needs/need_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ class NeedLink:
condition: str | None = None

@staticmethod
def from_string(link_str: str) -> NeedLink:
def from_string(link_str: str, *, parse_conditions: bool = True) -> NeedLink:
"""Parse a link from a string (infallible, best-effort).

Supports formats: ``ID``, ``ID.part``, ``ID[condition]``,
Expand All @@ -256,20 +256,30 @@ def from_string(link_str: str) -> NeedLink:
On malformed brackets (unclosed, trailing text), falls back to
parsing without a condition. Use :meth:`from_string_with_warnings`
if you need to detect malformed input.

:param parse_conditions: Whether to parse ``[condition]`` brackets.
"""
return NeedLink.from_string_with_warnings(link_str)[0]
return NeedLink.from_string_with_warnings(
link_str, parse_conditions=parse_conditions
)[0]

@staticmethod
def from_string_with_warnings(link_str: str) -> tuple[NeedLink, list[str]]:
def from_string_with_warnings(
link_str: str, *, parse_conditions: bool = True
) -> tuple[NeedLink, list[str]]:
"""Parse a link from a string, returning warnings for malformed input.

Same parsing as :meth:`from_string`, but returns a list of warning
messages instead of silently ignoring malformed brackets.

:param parse_conditions: Whether to parse ``[condition]`` brackets.
:returns: A tuple of ``(NeedLink, warnings)``.
"""
warnings: list[str] = []

if not parse_conditions:
return NeedLink.parse_address(link_str), warnings

# Find the first '[' that could start a condition
bracket_start = link_str.find("[")
if bracket_start <= 0:
Expand Down
1 change: 1 addition & 0 deletions sphinx_needs/needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,7 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N
"parse_dynamic_functions", needs_config._parse_dynamic_functions
),
parse_variants=link.get("parse_variants", False),
parse_conditions=link.get("parse_conditions", True),
directive_option=True,
display=display_config,
copy=link.get("copy", False),
Expand Down
37 changes: 32 additions & 5 deletions sphinx_needs/needs_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ class LinkSchema:
allow_extend: bool = False
parse_dynamic_functions: bool = False
parse_variants: bool = False
parse_conditions: bool = True
allow_defaults: bool = False
predicate_defaults: tuple[
tuple[str, LinksLiteralValue | LinksFunctionArray],
Expand Down Expand Up @@ -589,6 +590,8 @@ def __post_init__(self) -> None:
raise ValueError("parse_dynamic_functions must be a boolean.")
if not isinstance(self.parse_variants, bool):
raise ValueError("parse_variants must be a boolean.")
if not isinstance(self.parse_conditions, bool):
raise ValueError("parse_conditions must be a boolean.")
if not isinstance(self.allow_defaults, bool):
raise ValueError("allow_defaults must be a boolean.")
if not isinstance(self.allow_extend, bool):
Expand Down Expand Up @@ -725,7 +728,10 @@ def convert_directive_option(
has_df_or_vf = False
array: list[NeedLink | DynamicFunctionParsed | VariantFunctionParsed] = []
for item in _split_link_list(
value, self.parse_dynamic_functions, self.parse_variants
value,
self.parse_dynamic_functions,
self.parse_variants,
parse_conditions=self.parse_conditions,
):
if isinstance(item, LinkSplitWarning):
# TODO bubble up as warning?
Expand Down Expand Up @@ -789,13 +795,29 @@ def convert_or_type_check(
VariantFunctionParsed.from_string(item.strip()[2:-2])
)
else:
new_value.append(NeedLink.from_string(item))
new_value.append(
NeedLink.from_string(
item, parse_conditions=self.parse_conditions
)
)
if has_function:
return LinksFunctionArray(tuple(new_value))
else:
return LinksLiteralValue([NeedLink.from_string(v) for v in value])
return LinksLiteralValue(
[
NeedLink.from_string(
v, parse_conditions=self.parse_conditions
)
for v in value
]
)
else:
return LinksLiteralValue([NeedLink.from_string(v) for v in value])
return LinksLiteralValue(
[
NeedLink.from_string(v, parse_conditions=self.parse_conditions)
for v in value
]
)


class FieldsSchema:
Expand Down Expand Up @@ -1007,6 +1029,8 @@ def _split_link_list(
text: str,
parse_dynamic_functions: bool,
parse_variants: bool,
*,
parse_conditions: bool = True,
) -> Iterator[
NeedLink | DynamicFunctionParsed | VariantFunctionParsed | LinkSplitWarning
]:
Expand All @@ -1030,6 +1054,7 @@ def _split_link_list(
:param text: The string to split.
:param parse_dynamic_functions: Whether to parse ``[[...]]`` dynamic functions.
:param parse_variants: Whether to parse ``<<...>>`` variant functions.
:param parse_conditions: Whether to parse ``[condition]`` brackets.
:yields: Parsed link items, or ``LinkSplitWarning`` for non-fatal issues
(e.g. text adjacent to a dynamic/variant function, unclosed brackets).
"""
Expand All @@ -1045,7 +1070,9 @@ def _flush_current() -> NeedLink | LinkSplitWarning | None:
_current = ""
if not stripped:
return None
link, warnings = NeedLink.from_string_with_warnings(stripped)
link, warnings = NeedLink.from_string_with_warnings(
stripped, parse_conditions=parse_conditions
)
if warnings:
return LinkSplitWarning(warnings[0])
return link
Expand Down
4 changes: 2 additions & 2 deletions tests/__snapshots__/test_basic_doc.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -651,11 +651,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=True, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=None),
'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=[]), display=LinkDisplayConfig(incoming='links incoming', outgoing='links outgoing', color='#000000', style='', style_part='dotted', style_start='-', style_end='->'), copy=False, allow_dead_links=False),
'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, parse_conditions=True, 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=True, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=None),
'max_content_lines': FieldSchema(name='max_content_lines', description='Added by service github-issues', 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),
'params': FieldSchema(name='params', description='Added by service open-needs', 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),
'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),
'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, parse_conditions=True, 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=True, directive_option=True, parse_dynamic_functions=True, parse_variants=False, allow_defaults=True, allow_extend=True, predicate_defaults=(), default=None),
Expand Down
90 changes: 88 additions & 2 deletions tests/__snapshots__/test_link_conditions.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,31 @@
'type': 'spec',
'type_name': 'Specification',
}),
'SPEC_RAW_001': dict({
'content': '''
The ``raw_links`` link type has ``parse_conditions: False``,
so the brackets are treated as literal ID text, not as a condition.
''',
'docname': 'index',
'external_css': 'external_link',
'has_dead_links': True,
'has_forbidden_dead_links': True,
'id': 'SPEC_RAW_001',
'lineno': 66,
'raw_links': list([
'REQ_001[status=="open"]',
]),
'section_name': 'Parse Conditions Disabled',
'sections': list([
'Parse Conditions Disabled',
'LINK CONDITIONS TEST',
]),
'title': 'Spec with raw link containing brackets',
'type': 'spec',
'type_name': 'Specification',
}),
}),
'needs_amount': 12,
'needs_amount': 13,
'needs_defaults_removed': True,
'needs_schema': dict({
'$schema': 'http://json-schema.org/draft-07/schema#',
Expand Down Expand Up @@ -584,6 +607,26 @@
'null',
]),
}),
'raw_links': dict({
'default': list([
]),
'description': 'Link field',
'field_type': 'links',
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
'raw_links_back': dict({
'default': list([
]),
'description': 'Backlink field',
'field_type': 'backlinks',
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
'section_name': dict({
'default': None,
'description': 'Simply the first section.',
Expand Down Expand Up @@ -960,8 +1003,31 @@
'type': 'spec',
'type_name': 'Specification',
}),
'SPEC_RAW_001': dict({
'content': '''
The ``raw_links`` link type has ``parse_conditions: False``,
so the brackets are treated as literal ID text, not as a condition.
''',
'docname': 'index',
'external_css': 'external_link',
'has_dead_links': True,
'has_forbidden_dead_links': True,
'id': 'SPEC_RAW_001',
'lineno': 66,
'raw_links': list([
'REQ_001[status=="open"]',
]),
'section_name': 'Parse Conditions Disabled',
'sections': list([
'Parse Conditions Disabled',
'LINK CONDITIONS TEST',
]),
'title': 'Spec with raw link containing brackets',
'type': 'spec',
'type_name': 'Specification',
}),
}),
'needs_amount': 12,
'needs_amount': 13,
'needs_defaults_removed': True,
'needs_schema': dict({
'$schema': 'http://json-schema.org/draft-07/schema#',
Expand Down Expand Up @@ -1312,6 +1378,26 @@
'null',
]),
}),
'raw_links': dict({
'default': list([
]),
'description': 'Link field',
'field_type': 'links',
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
'raw_links_back': dict({
'default': list([
]),
'description': 'Backlink field',
'field_type': 'backlinks',
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
'section_name': dict({
'default': None,
'description': 'Simply the first section.',
Expand Down
8 changes: 8 additions & 0 deletions tests/doc_test/doc_link_conditions/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
},
]

needs_links = {
"raw_links": {
"outgoing": "raw links outgoing",
"incoming": "raw links incoming",
"parse_conditions": False,
},
}

needs_types = [
{
"directive": "req",
Expand Down
10 changes: 10 additions & 0 deletions tests/doc_test/doc_link_conditions/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,13 @@ Imported Needs
--------------

.. needimport:: needs_test_conditions.json

Parse Conditions Disabled
-------------------------

.. spec:: Spec with raw link containing brackets
:id: SPEC_RAW_001
:raw_links: REQ_001[status=="open"]

The ``raw_links`` link type has ``parse_conditions: False``,
so the brackets are treated as literal ID text, not as a condition.
32 changes: 32 additions & 0 deletions tests/test_link_conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ def test_link_conditions(test_app: Sphinx, snapshot):
"srcdir/index.rst:52: WARNING: Need 'SPEC_006' link 'REQ_001' in field 'links': condition '\"open\" in tags' not satisfied by target need 'REQ_001' [needs.link_condition_failed]",
# IMP_COND_FAIL imported via needimport, links to REQ_002[status=="open"] which fails
"srcdir/index.rst:61: WARNING: Need 'IMP_COND_FAIL' link 'REQ_002' in field 'links': condition 'status==\"open\"' not satisfied by target need 'REQ_002' [needs.link_condition_failed]",
# SPEC_RAW_001 uses raw_links (parse_conditions=False), so brackets are literal ID text.
# The link target 'REQ_001[status=="open"]' doesn't exist as a need, so it's a dead link.
"srcdir/index.rst:66: WARNING: Need 'SPEC_RAW_001' has unknown outgoing link 'REQ_001[status==\"open\"]' in field 'raw_links' [needs.link_outgoing]",
]

needs_data = json.loads(Path(app.outdir, "needs.json").read_text())
Expand All @@ -66,3 +69,32 @@ def test_json_excludes_link_conditions_when_disabled(test_app: Sphinx, snapshot)

needs_data = json.loads(Path(app.outdir, "needs.json").read_text())
assert needs_data == snapshot(exclude=props("created", "project", "creator"))


@pytest.mark.parametrize(
"test_app",
[
{
"buildername": "html",
"srcdir": "doc_test/doc_link_conditions",
"no_plantuml": True,
}
],
indirect=True,
)
def test_parse_conditions_disabled(test_app: Sphinx):
"""Test that parse_conditions=False treats brackets as literal ID text."""
app = test_app
app.build()

needs_data = json.loads(Path(app.outdir, "needs.json").read_text())
spec_raw = needs_data["versions"][""]["needs"]["SPEC_RAW_001"]

# With parse_conditions=False, the brackets are NOT parsed as a condition.
# The entire string 'REQ_001[status=="open"]' is treated as a literal link ID.
assert spec_raw["raw_links"] == ['REQ_001[status=="open"]']

# Verify no condition was extracted (the link has no condition field)
# The raw_links_back on REQ_001 should be empty since the literal ID doesn't match
req_001 = needs_data["versions"][""]["needs"]["REQ_001"]
assert req_001.get("raw_links_back", []) == []
Loading
Loading