Skip to content

Commit 4ec0bc7

Browse files
authored
⚠️ Separate reduced vs full need representation for schema validation (#1652)
## Changes - **Rename** `validate_option_fields` → `validate_fields` and `validate_link_options` → `validate_links` - **Add `reduce` parameter** to `get_ontology_warnings()`: - `True` (type-specific `local`/`network` schemas): uses `reduce_need()` — strips empty link lists and defaulted core fields. Required for `unevaluatedProperties` (extra fields don't cause false failures) and `required` (fields at their default are absent, so `required` enforces explicit setting) to work correctly. - `False` (global field/link constraint validation): uses `NeedItem.iter_schema_items()` — returns a curated subset of fields (core: `id`, `type`, `title`, `status`, `tags`; source: `docname`, `is_import`, `is_external`; all extra fields; all links), filtering only `None` values. This retains empty `[]` values for links and `tags`, so constraints are evaluated against every need. ## Behavioral change Previously, fields with default `[]` values (link fields like `links`, and core fields like `tags`) were stripped before validation, silently bypassing constraints like `minItems`, `contains`, and `minContains`. Now with `reduce=False`, these fields are retained as `[]`, causing them to **fail** those constraints. This applies to both: - **Link fields** (e.g. `links`, `implements`) — always default to `[]` - **Core fields** with `[]` defaults (e.g. `tags`) — previously stripped when matching the default **Example**: A schema with `schema.minItems = 1` on `links` will now warn for *every* need that doesn't set any links, not just those that explicitly set links but provided too few. ## Decision needed **Is this the correct strictness?** Two reasonable interpretations: 1. ✅ **"All needs must satisfy field/link constraints"** — the new behavior. If you declare `minItems: 1` on a link type or `tags`, every need must have at least one value. Users who want optional fields should not set `minItems`. 2. ❌ **"Only needs that explicitly set a field must satisfy its constraints"** — the old behavior. Unset/default fields are treated as absent, not as empty arrays. If (1) is intended, this should be noted in the changelog as a behavior change. If (2) is preferred, the fix would be to keep `reduce_need=True` for field/link validation, or to conditionally skip default-valued fields when `reduce_need=False`. ### Known limitation of `reduce_need=True` The reduction logic strips fields whose value matches the default — but it cannot distinguish between "never set (defaulted)" and "explicitly set to the default value." For example, if a user actively sets `:tags:` to `[]` and the schema has `minItems: 1`, the field is stripped because `[]` matches the default, and the violation is **silently ignored**. This is a fundamental limitation of comparing values to defaults at validation time, since the provenance of the value (explicit vs implicit) is not tracked.
1 parent 1f9070a commit 4ec0bc7

5 files changed

Lines changed: 610 additions & 31 deletions

File tree

‎sphinx_needs/need_item.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,25 @@ def items(self) -> Iterable[tuple[str, Any]]:
669669
self._computed.items(),
670670
)
671671

672+
def iter_schema_items(self) -> Iterable[tuple[str, Any]]:
673+
"""Return the items of the need item that are relevant for schema validation."""
674+
# TODO - this should be reworked to be more robust;
675+
# e.g. probably all core fields should be included
676+
return chain(
677+
(
678+
(k, v)
679+
for k, v in self._core.items()
680+
if k in ("id", "type", "title", "status", "tags")
681+
),
682+
(
683+
(k, v)
684+
for k, v in self._source.dict_repr.items()
685+
if k in ("docname", "is_import", "is_external")
686+
),
687+
self._extras.items(),
688+
self._links.items(),
689+
)
690+
672691
def iter_core_items(self) -> Iterable[tuple[str, Any]]:
673692
"""Return the core items of the need item."""
674693
return chain(

‎sphinx_needs/schema/core.py‎

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"""
4343

4444

45-
def validate_option_fields(
45+
def validate_fields(
4646
config: NeedsSphinxConfig,
4747
schema: NeedFieldsSchemaType,
4848
field_properties: Mapping[str, NeedFieldProperties],
@@ -56,6 +56,7 @@ def validate_option_fields(
5656
need,
5757
field_properties,
5858
validator,
59+
reduce=False,
5960
fail_rule=MessageRuleEnum.field_fail,
6061
success_rule=MessageRuleEnum.field_success,
6162
schema_path=["fields", "schema"],
@@ -67,7 +68,7 @@ def validate_option_fields(
6768
return need_2_warnings
6869

6970

70-
def validate_link_options(
71+
def validate_links(
7172
config: NeedsSphinxConfig,
7273
schema: NeedFieldsSchemaType,
7374
field_properties: Mapping[str, NeedFieldProperties],
@@ -81,6 +82,7 @@ def validate_link_options(
8182
need,
8283
field_properties,
8384
validator,
85+
reduce=False,
8486
fail_rule=MessageRuleEnum.extra_link_fail,
8587
success_rule=MessageRuleEnum.extra_link_success,
8688
schema_path=["extra_links", "schema"],
@@ -123,6 +125,7 @@ def validate_type_schema(
123125
need,
124126
field_properties,
125127
validator,
128+
reduce=False,
126129
fail_rule=MessageRuleEnum.select_fail,
127130
success_rule=MessageRuleEnum.select_success,
128131
schema_path=[schema_name, "select"],
@@ -211,6 +214,7 @@ def recurse_validate_schemas(
211214
validator,
212215
rule_fail,
213216
rule_success,
217+
reduce=True,
214218
schema_path=[*schema_path, "local"],
215219
need_path=need_path,
216220
user_message=user_message if recurse_level == 0 else None,
@@ -440,24 +444,34 @@ def reduce_need(
440444
schema_properties: set[str],
441445
) -> dict[str, Any]:
442446
"""
443-
Reduce a need to its relevant fields for validation in a specific schema context.
447+
Reduce a need to only actively-set fields for type-specific schema validation.
444448
445-
The reduction is required to separated actively set fields from defaults.
446-
Also internal fields shall be removed, if they are not actively used in the schema.
447-
This is required to make unevaluatedProperties work as expected which disallows
448-
additional fields.
449+
This is used for ``local`` and ``network`` schemas, where the validator needs to
450+
distinguish between fields that were explicitly set and those at their defaults.
451+
It strips out:
449452
450-
Needs can be reduced in multiple contexts as the need can be primary target of validation
451-
or it can be a link target which might mean only a single field shall be checked for a
452-
specific value.
453+
- Extra fields that are ``None`` (not provided)
454+
- Link fields that are empty (``[]``)
455+
- Core fields that are ``None``, not referenced in the schema, or match their default
453456
454-
Fields are kept
455-
- if they are extra fields and differ from their default value
456-
- if they are links and the list is not empty
457-
- if they are part of the user provided schema
457+
This is required for:
458+
459+
- ``unevaluatedProperties``: without reduction, internal/unrelated fields would
460+
cause false "additional property" failures.
461+
- ``required``: without reduction, core fields at their default value would still
462+
be present, so ``required`` would always pass even if the user never explicitly
463+
set the field.
464+
465+
.. note::
466+
467+
This cannot distinguish "explicitly set to the default value" from "never set."
468+
For example, if a user sets ``:tags:`` to ``[]`` and the schema has ``minItems: 1``,
469+
the field is stripped (since ``[]`` matches the default) and the violation is silently
470+
ignored.
458471
459472
:param need: The need to reduce.
460-
:param json_schema: The user provided and merged JSON merge.
473+
:param field_properties: Mapping of field names to their schema properties (type, default).
474+
:param schema_properties: Set of field names referenced in the user-provided schema.
461475
"""
462476
reduced_need: dict[str, Any] = {}
463477

@@ -559,15 +573,33 @@ def get_ontology_warnings(
559573
success_rule: MessageRuleEnum,
560574
schema_path: list[str],
561575
need_path: list[str],
576+
reduce: bool,
562577
user_message: str | None = None,
563578
user_severity: SeverityEnum | None = None,
564579
) -> list[OntologyWarning]:
565-
reduced_need = reduce_need(need, field_properties, validator.properties)
580+
"""Validate a need against a compiled schema and return a list of warnings.
581+
582+
:param need: The need to validate.
583+
:param field_properties: The properties of the need fields.
584+
:param validator: The compiled schema validator to use for validation.
585+
:param fail_rule: The MessageRuleEnum to use for validation failure warnings.
586+
:param success_rule: The MessageRuleEnum to use for validation success warnings.
587+
:param schema_path: The path to the schema in the configuration for reporting purposes.
588+
:param need_path: The path to the need for reporting purposes.
589+
:param reduce: Whether to reduce the need to relevant fields before validation.
590+
:param user_message: An optional user message to include in the warnings.
591+
:param user_severity: An optional user severity to override the default severity for the rules
592+
"""
593+
if reduce:
594+
needs_json = reduce_need(need, field_properties, validator.properties)
595+
else:
596+
# we always remove null values, since we currently do not allow for `{"string", "null"}` type definitions and so null values would cause validation errors
597+
needs_json = {k: v for k, v in need.iter_schema_items() if v is not None}
566598
warnings: list[OntologyWarning] = []
567599
warning: OntologyWarning
568600
try:
569601
validation_errors: list[ValidationError] = list(
570-
validator.compiled.iter_errors(instance=reduced_need)
602+
validator.compiled.iter_errors(instance=needs_json)
571603
)
572604
except ValidationError as exc:
573605
warning = {
@@ -590,7 +622,7 @@ def get_ontology_warnings(
590622
"severity": get_severity(fail_rule, user_severity),
591623
"validation_message": err.message,
592624
"need": need,
593-
"reduced_need": reduced_need,
625+
"reduced_need": needs_json,
594626
"final_schema": validator.raw,
595627
"schema_path": [*schema_path, *(str(item) for item in err.schema_path)],
596628
"need_path": need_path,
@@ -605,7 +637,7 @@ def get_ontology_warnings(
605637
"rule": success_rule,
606638
"severity": get_severity(success_rule),
607639
"need": need,
608-
"reduced_need": reduced_need,
640+
"reduced_need": needs_json,
609641
"final_schema": validator.raw,
610642
"schema_path": schema_path,
611643
"need_path": need_path,

‎sphinx_needs/schema/process.py‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from sphinx_needs.schema.config import NeedFieldsSchemaType, SchemasRootType
1515
from sphinx_needs.schema.core import (
1616
NeedFieldProperties,
17-
validate_link_options,
18-
validate_option_fields,
17+
validate_fields,
18+
validate_links,
1919
validate_type_schema,
2020
)
2121
from sphinx_needs.schema.reporting import (
@@ -69,16 +69,12 @@ def process_schemas(app: Sphinx, builder: Builder) -> None:
6969
need_2_warnings: dict[str, list[OntologyWarning]] = {}
7070

7171
if fields_schema["properties"]:
72-
extra_warnings = validate_option_fields(
73-
config, fields_schema, field_properties, needs
74-
)
72+
extra_warnings = validate_fields(config, fields_schema, field_properties, needs)
7573
for key, warnings in extra_warnings.items():
7674
need_2_warnings.setdefault(key, []).extend(warnings)
7775

7876
if links_schema["properties"]:
79-
link_warnings = validate_link_options(
80-
config, links_schema, field_properties, needs
81-
)
77+
link_warnings = validate_links(config, links_schema, field_properties, needs)
8278
for key, warnings in link_warnings.items():
8379
need_2_warnings.setdefault(key, []).extend(warnings)
8480

0 commit comments

Comments
 (0)