Skip to content

Commit a89ce9b

Browse files
committed
✨ Schema validation
1 parent c25df13 commit a89ce9b

File tree

18 files changed

+1715
-18
lines changed

18 files changed

+1715
-18
lines changed

.github/workflows/ci.yaml

+17-10
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,25 @@ jobs:
2323
fail-fast: false # Set on "false" to get the results of ALL builds
2424
matrix:
2525
os: ["ubuntu-latest"]
26-
python-version: ["3.10", "3.12"]
27-
sphinx-version: ["7.0", "8.0"]
26+
python-version: ["3.9", "3.12", "3.13"]
27+
sphinx-version: ["7.4", "8.2"]
2828
include:
29-
- os: "ubuntu-latest"
30-
python-version: "3.9"
31-
sphinx-version: "7.0"
29+
# corner cases for Windows
3230
- os: "windows-latest"
3331
python-version: "3.9"
34-
sphinx-version: "7.0"
32+
sphinx-version: "7.4"
3533
- os: "windows-latest"
3634
python-version: "3.12"
37-
sphinx-version: "8.0"
35+
sphinx-version: "8.2"
36+
- os: "windows-latest"
37+
python-version: "3.13"
38+
sphinx-version: "8.2"
39+
exclude:
40+
# Sphinx 8.2 only supports py3.11+
41+
- os: "ubuntu-latest"
42+
python-version: "3.9"
43+
sphinx-version: "8.2"
44+
3845
steps:
3946
- uses: actions/checkout@v4
4047
- name: Install graphviz (linux)
@@ -75,10 +82,10 @@ jobs:
7582
include:
7683
- os: "ubuntu-latest"
7784
python-version: "3.9"
78-
sphinx-version: "7.0"
85+
sphinx-version: "7.4"
7986
- os: "ubuntu-latest"
80-
python-version: "3.12"
81-
sphinx-version: "8.0"
87+
python-version: "3.13"
88+
sphinx-version: "8.2"
8289
steps:
8390
- uses: actions/checkout@v4
8491
- name: Use Node.js

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ classifiers = [
2121
'Programming Language :: Python :: 3.10',
2222
'Programming Language :: Python :: 3.11',
2323
'Programming Language :: Python :: 3.12',
24+
'Programming Language :: Python :: 3.13',
2425
'Topic :: Documentation',
2526
'Topic :: Utilities',
2627
'Framework :: Sphinx :: Extension',
2728
]
2829
requires-python = ">=3.9,<4"
2930
dependencies = [
30-
"sphinx>=7.0,<9",
31+
"sphinx>=7.4,<9",
3132
"requests-file~=2.1", # external links
3233
"requests~=2.32", # external links
3334
"jsonschema>=3.2.0", # needsimport schema validation

sphinx_needs/config.py

+182-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import annotations
22

3-
from collections.abc import Mapping
3+
from collections.abc import Callable, Mapping
44
from dataclasses import MISSING, dataclass, field, fields
5-
from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict
5+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
66

77
from docutils.parsers.rst import directives
88
from sphinx.application import Sphinx
@@ -11,6 +11,12 @@
1111
from sphinx_needs.data import GraphvizStyleType, NeedsCoreFields
1212
from sphinx_needs.defaults import DEFAULT_DIAGRAM_TEMPLATE
1313
from sphinx_needs.logging import get_logger, log_warning
14+
from sphinx_needs.schema.config import (
15+
USER_CONFIG_SCHEMA_SEVERITIES,
16+
MessageRuleEnum,
17+
SchemaType,
18+
SeverityEnum,
19+
)
1420

1521
if TYPE_CHECKING:
1622
from sphinx.util.logging import SphinxLoggerAdapter
@@ -23,6 +29,10 @@
2329
LOGGER = get_logger(__name__)
2430

2531

32+
# TODO: checking any schema against the meta model
33+
# TODO: constrain the schema to disallow certain keys
34+
35+
2636
@dataclass
2737
class ExtraOptionParams:
2838
"""Defines a single extra option for needs"""
@@ -31,6 +41,25 @@ class ExtraOptionParams:
3141
"""A description of the option."""
3242
validator: Callable[[str | None], str]
3343
"""A function to validate the directive option value."""
44+
schema: dict[str, Any] | None
45+
"""A JSON schema for the option."""
46+
47+
48+
class ExtraLinkSchemaItemsType(TypedDict):
49+
type: Literal["string"]
50+
51+
52+
class ExtraLinkSchemaType(TypedDict):
53+
"""Defines a schema for a need extra link."""
54+
55+
type: Literal["array"]
56+
"""Type for extra links, can only be array."""
57+
items: ExtraLinkSchemaItemsType
58+
"""Schema constraints for link strings."""
59+
minItems: NotRequired[int]
60+
"""Minimum number of items in the array."""
61+
maxItems: NotRequired[int]
62+
"""Maximum number of items in the array."""
3463

3564

3665
class FieldDefault(TypedDict):
@@ -91,6 +120,7 @@ def add_extra_option(
91120
name: str,
92121
description: str,
93122
*,
123+
schema: dict[str, Any] | None = None,
94124
validator: Callable[[str | None], str] | None = None,
95125
override: bool = False,
96126
) -> None:
@@ -110,7 +140,9 @@ def add_extra_option(
110140

111141
raise NeedsApiConfigWarning(f"Option {name} already registered.")
112142
self._extra_options[name] = ExtraOptionParams(
113-
description, directives.unchanged if validator is None else validator
143+
description,
144+
directives.unchanged if validator is None else validator,
145+
schema,
114146
)
115147

116148
@property
@@ -240,6 +272,13 @@ class LinkOptionsType(TypedDict, total=False):
240272
"""Used for needflow. Default: '->'"""
241273
allow_dead_links: bool
242274
"""If True, add a 'forbidden' class to dead links"""
275+
schema: ExtraLinkSchemaType
276+
"""
277+
A JSON schema for the link option.
278+
279+
If given, the schema will apply to all needs that use this link option.
280+
For more granular control and graph traversal, use the `needs_schemas` configuration.
281+
"""
243282

244283

245284
class NeedType(TypedDict):
@@ -263,6 +302,32 @@ class NeedExtraOption(TypedDict):
263302
name: str
264303
description: NotRequired[str]
265304
"""A description of the option."""
305+
type: NotRequired[Literal["string", "integer", "number", "boolean"]]
306+
"""
307+
The data type for schema validation. The option will still be stored as a string,
308+
but during schema validation, the value will be coerced to the given type.
309+
310+
The type semantics are align with JSON Schema, see
311+
https://json-schema.org/understanding-json-schema/reference/type.
312+
313+
For booleans, a predefined set of truthy/falsy strings are accepted:
314+
- truthy = {"true", "yes", "y", "on", "1"}
315+
- falsy = {"false", "no", "n", "off", "0"}
316+
317+
``null`` is not a valid value as Sphinx options cannot actively be set to ``null``.
318+
Sphinx-Needs does not distinguish between extra options being not given and given as empty string.
319+
Both cases are coerced to an empty string value ``''``.
320+
"""
321+
schema: NotRequired[dict[str, Any]]
322+
"""
323+
A JSON schema for the option.
324+
325+
If given, the schema will apply to all needs that use this option.
326+
For more granular control, use the `needs_schemas` configuration.
327+
"""
328+
# TODO check schema on config-inited, disallow certain keys such as
329+
# [if, then, else, dependentSchemas, dependentRequired, anyOf, oneOf, not]
330+
# only allow those once usecases are requested
266331

267332

268333
class NeedStatusesOption(TypedDict):
@@ -365,6 +430,120 @@ def get_default(cls, name: str) -> Any:
365430
default_factory=list, metadata={"rebuild": "env", "types": (list,)}
366431
)
367432
"""Path to the root table in the toml file to load configuration from."""
433+
schemas: list[SchemaType] = field(
434+
default_factory=list,
435+
metadata={
436+
"rebuild": "env",
437+
"types": (list,),
438+
"schema": {
439+
"type": "array",
440+
"items": {
441+
"type": "object",
442+
"properties": {
443+
"id": {"type": "string", "pattern": "^[a-zA-Z0-9_-]+$"},
444+
"severity": {
445+
"type": "string",
446+
"enum": [
447+
e.name
448+
for e in SeverityEnum
449+
if e in USER_CONFIG_SCHEMA_SEVERITIES
450+
],
451+
},
452+
"message": {"type": "string"},
453+
"types": {
454+
"type": "array",
455+
"items": {"type": "string", "minLength": 1},
456+
},
457+
"local_schema": {"type": "object"},
458+
"trigger_schema": {"type": "object"},
459+
"trigger_schema_id": {
460+
"type": "string",
461+
"pattern": "^[a-zA-Z0-9_-]+$",
462+
},
463+
"link_schema": {
464+
"type": "object",
465+
"patternProperties": {
466+
"^.*$": {
467+
"type": "object",
468+
"properties": {
469+
"schema_id": {"type": "string"},
470+
"minItems": {"type": "integer"},
471+
"maxItems": {"type": "integer"},
472+
"unevaluatedItems": {"type": "boolean"},
473+
},
474+
"additionalProperties": False,
475+
}
476+
},
477+
"additionalProperties": False,
478+
},
479+
"dependency": {"type": "boolean"},
480+
},
481+
"additionalProperties": False,
482+
},
483+
},
484+
},
485+
)
486+
schemas_from_json: str | None = field(
487+
default=None, metadata={"rebuild": "env", "types": (str, type(None))}
488+
)
489+
"""Path to a JSON file to load the schemas from."""
490+
491+
schemas_severity: str = field(
492+
default=SeverityEnum.info.name,
493+
metadata={
494+
"rebuild": "env",
495+
"types": (str,),
496+
"schema": {
497+
"type": "string",
498+
"enum": [
499+
e.name for e in SeverityEnum if e in USER_CONFIG_SCHEMA_SEVERITIES
500+
],
501+
},
502+
},
503+
)
504+
"""Severity level for the schema validation reporting."""
505+
506+
schemas_debug_active: bool = field(
507+
default=False,
508+
metadata={"rebuild": "env", "types": (bool,), "schema": {"type": "boolean"}},
509+
)
510+
"""Activate the debug mode for schema validation to dump JSON/schema files and messages."""
511+
512+
schemas_debug_path: str = field(
513+
default="schema_debug",
514+
metadata={
515+
"rebuild": "env",
516+
"types": (str,),
517+
"schema": {"type": "string", "minLength": 1},
518+
},
519+
)
520+
"""
521+
Path to the directory where the debug files are stored.
522+
523+
If the path is relative, the caller needs to make sure
524+
it gets converted to a use case specific absolute path, e.g.
525+
with confdir for Sphinx.
526+
"""
527+
528+
schemas_debug_ignore: list[str] = field(
529+
default_factory=lambda: [
530+
"skipped_dependency",
531+
"skipped_wrong_type",
532+
"validation_success",
533+
],
534+
metadata={
535+
"rebuild": "env",
536+
"types": (list,),
537+
"schema": {
538+
"type": "array",
539+
"items": {
540+
"type": "string",
541+
"enum": [e.value for e in MessageRuleEnum],
542+
},
543+
},
544+
},
545+
)
546+
"""List of scenarios that are ignored for dumping debug information."""
368547

369548
types: list[NeedType] = field(
370549
default_factory=lambda: [

sphinx_needs/directives/need.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,15 @@ class NeedDirective(SphinxDirective):
5050

5151
required_arguments = 1
5252
optional_arguments = 0
53-
option_spec = NEED_DEFAULT_OPTIONS
53+
option_spec = NEED_DEFAULT_OPTIONS.copy()
5454

5555
final_argument_whitespace = True
5656

57+
@classmethod
58+
def reset(cls) -> None:
59+
"""Reset the directive to its initial state."""
60+
cls.option_spec = NEED_DEFAULT_OPTIONS
61+
5762
@measure_time("need")
5863
def run(self) -> Sequence[nodes.Node]:
5964
if self.options.get("delete"):

0 commit comments

Comments
 (0)