Skip to content

Commit a8a8eca

Browse files
committed
✨ Schema validation
1 parent 6ec1f9c commit a8a8eca

File tree

25 files changed

+4349
-6
lines changed

25 files changed

+4349
-6
lines changed

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ Contents
208208
services/index
209209
layout_styles
210210
api
211+
schema
211212
utils
212213

213214
.. toctree::

docs/schema.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Schema validation
2+
=================
3+

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ test = [
4949
"lxml>=4.6.5,<6.0",
5050
"responses~=0.22.0",
5151
"pytest-xprocess~=1.0",
52+
"tomli ~= 2.2.1",
53+
"tomli-w ~= 1.2.0",
5254
]
5355
test-parallel = ["pytest-xdist"]
5456
benchmark = [
@@ -73,6 +75,7 @@ dev = ["pre-commit~=3.0", "tox~=4.23", "tox-uv~=1.15"]
7375
[tool.pytest.ini_options]
7476
markers = [
7577
"jstest: marks tests as JavaScript test (deselect with '-m \"not jstest\"')",
78+
"benchmark: marks tests as expensive benchmark test (deselect with '-m \"not benchmark\"')",
7679
]
7780
filterwarnings = [
7881
"ignore:.*removed in Python 3.14.*:DeprecationWarning",

sphinx_needs/builder.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ def write(
4141
updated_docnames: Sequence[str],
4242
method: str = "update",
4343
) -> None:
44-
return
44+
# make sure schema validation is done
45+
self.events.emit("write-started", self)
4546

4647
def finish(self) -> None:
4748
from sphinx_needs.filter_common import filter_needs_view
@@ -257,3 +258,39 @@ def build_needumls_pumls(app: Sphinx, _exception: Exception) -> None:
257258
needs_builder.outdir = os.path.join(needs_builder.outdir, config.build_needumls) # type: ignore[assignment]
258259

259260
needs_builder.finish()
261+
262+
263+
class SchemaBuilder(Builder):
264+
"""Only validate needs schema, no output is generated."""
265+
266+
name = "schema"
267+
268+
def write(
269+
self,
270+
build_docnames: Iterable[str] | None,
271+
updated_docnames: Sequence[str],
272+
method: str = "update",
273+
) -> None:
274+
# make sure schema validation is done
275+
self.events.emit("write-started", self)
276+
277+
def write_doc(self, docname: str, doctree: nodes.document) -> None:
278+
pass
279+
280+
def finish(self) -> None:
281+
pass
282+
283+
def get_outdated_docs(self) -> Iterable[str]:
284+
return []
285+
286+
def prepare_writing(self, _docnames: set[str]) -> None:
287+
pass
288+
289+
def write_doc_serialized(self, _docname: str, _doctree: nodes.document) -> None:
290+
pass
291+
292+
def cleanup(self) -> None:
293+
pass
294+
295+
def get_target_uri(self, _docname: str, _typ: str | None = None) -> str:
296+
return ""

sphinx_needs/config.py

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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/environment.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def _add_js_file(app: Sphinx, rel_path: Path) -> None:
4040
def install_styles_static_files(app: Sphinx, env: BuildEnvironment) -> None:
4141
builder = app.builder
4242
# Do not copy static_files for our "needs" builder
43-
if builder.name == "needs":
43+
if builder.name in ["needs", "schema"]:
4444
return
4545

4646
logger.info("Copying static style files for sphinx-needs")
@@ -87,7 +87,7 @@ def install_lib_static_files(app: Sphinx, env: BuildEnvironment) -> None:
8787
"""
8888
builder = app.builder
8989
# Do not copy static_files for our "needs" builder
90-
if builder.name == "needs":
90+
if builder.name in ["needs", "schema"]:
9191
return
9292

9393
logger.info("Copying static files for sphinx-needs datatables support")
@@ -116,7 +116,7 @@ def install_permalink_file(app: Sphinx, env: BuildEnvironment) -> None:
116116
"""
117117
builder = app.builder
118118
# Do not copy static_files for our "needs" builder
119-
if builder.name == "needs":
119+
if builder.name in ["needs", "schema"]:
120120
return
121121

122122
# load jinja template

0 commit comments

Comments
 (0)