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
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
("py:class", "sphinx_needs.views._LazyIndexes"),
("py:class", "sphinx_needs.config.NeedsSphinxConfig"),
("py:class", "AllowedTypes"),
("py:class", "ExtraOptionSchemaTypes"),
]

rst_epilog = """
Expand Down
53 changes: 18 additions & 35 deletions sphinx_needs/needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import contextlib
import json
from collections.abc import Callable
from copy import deepcopy
from pathlib import Path
from timeit import default_timer as timer # Used for timing measurements
from typing import Any, Literal, cast
from typing import Any, cast

from docutils import nodes
from sphinx.application import Sphinx
Expand Down Expand Up @@ -774,13 +775,15 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N
assert type_[1] == "null", "Only nullable types supported as list"
type_ = type_[0]
nullable = True
_schema = {"type": type_}
if type_ == "array":
_schema["items"] = data["schema"].get("items", {"type": "string"})
default = data["schema"].get("default", None)
field = FieldSchema(
name=name,
description=data["description"],
nullable=nullable,
type=type_,
item_type=data["schema"].get("items", {}).get("type", None),
schema=_schema, # type: ignore[arg-type]
default=None if default is None else FieldLiteralValue(default),
allow_defaults=data.get("allow_default", False),
allow_extend=data.get("allow_extend", False),
Expand All @@ -792,27 +795,19 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N
)
try:
schema.add_core_field(field)
except ValueError as exc:
log_warning(
LOGGER,
f"Could not add core field {field.name!r} to schema: {exc}",
"config",
None,
)
continue
except Exception as exc:
raise NeedsConfigException(f"Invalid core option {name!r}: {exc}") from exc
for name, extra in needs_config.extra_options.items():
try:
type: Literal["string", "boolean", "integer", "number", "array"] = "string"
item_type: None | Literal["string", "boolean", "integer", "number"] = None
if extra.schema:
type = extra.schema.get("type", "string")
if type == "array":
item_type = extra.schema.get("items", {}).get("type", "string") # type: ignore[attr-defined]
_schema = (
deepcopy(extra.schema) # type: ignore[arg-type]
if extra.schema is not None
else {"type": "string"}
)
field = FieldSchema(
name=name,
description=extra.description,
type=type,
item_type=item_type,
schema=_schema, # type: ignore[arg-type]
# TODO for nullable and default, currently if there is no schema,
# we configure so that the behaviour follows that of legacy (pre-schema) extra option,
# i.e. non-nullable and default of empty string (that can be overriden by needs_global_options).
Expand All @@ -825,14 +820,8 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N
directive_option=True,
)
schema.add_extra_field(field)
except ValueError as exc:
log_warning(
LOGGER,
f"Could not add extra option {name!r} to schema: {exc}",
"config",
None,
)
continue
except Exception as exc:
raise NeedsConfigException(f"Invalid extra option {name!r}: {exc}") from exc

for link in needs_config.extra_links:
name = link["option"]
Expand All @@ -848,14 +837,8 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N
directive_option=True,
)
schema.add_link_field(link_field)
except ValueError as exc:
log_warning(
LOGGER,
f"Could not add extra link option {name!r} to schema: {exc}",
"config",
None,
)
continue
except Exception as exc:
raise NeedsConfigException(f"Invalid extra link {name!r}: {exc}") from exc

for name, default_config in needs_config._global_options.items():
if (field_for_default := schema.get_any_field(name)) is None:
Expand Down
43 changes: 24 additions & 19 deletions sphinx_needs/needs_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
from functools import lru_cache, partial
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, TypeVar

import jsonschema_rs

from sphinx_needs.exceptions import VariantParsingException
from sphinx_needs.schema.config import validate_extra_option_schema
from sphinx_needs.schema.core import validate_object_schema_compiles
from sphinx_needs.variants import VariantFunctionParsed

if TYPE_CHECKING:
from sphinx_needs.functions.functions import DynamicFunctionParsed
from sphinx_needs.schema.config import ExtraOptionSchemaTypes


@dataclass(frozen=True, kw_only=True, slots=True)
Expand All @@ -22,8 +27,7 @@ class FieldSchema:

name: str
description: str = ""
type: Literal["string", "boolean", "integer", "number", "array"]
item_type: None | Literal["string", "boolean", "integer", "number"] = None
schema: ExtraOptionSchemaTypes
nullable: bool = False
directive_option: bool = False
allow_dynamic_functions: bool = False
Expand Down Expand Up @@ -51,19 +55,14 @@ def __post_init__(self) -> None:
raise ValueError("name must be a non-empty string.")
if not isinstance(self.description, str):
raise ValueError("description must be a string.")
if self.type not in ("string", "boolean", "integer", "number", "array"):
raise ValueError(
"type must be one of 'string', 'boolean', 'integer', 'number', 'array'."
)
if self.item_type is not None and self.item_type not in (
"string",
"boolean",
"integer",
"number",
):
raise ValueError(
"item_type must be one of 'string', 'boolean', 'integer', 'number'."
)
try:
validate_extra_option_schema(self.schema)
except TypeError as exc:
raise ValueError(f"Invalid schema: {exc}") from exc
try:
validate_object_schema_compiles({"properties": {self.name: self.schema}})
except jsonschema_rs.ValidationError as exc:
raise ValueError(f"Invalid schema: {exc}") from exc
if not isinstance(self.nullable, bool):
raise ValueError("nullable must be a boolean.")
if not isinstance(self.allow_dynamic_functions, bool):
Expand All @@ -74,10 +73,6 @@ def __post_init__(self) -> None:
raise ValueError("allow_variant must be a boolean.")
if not isinstance(self.allow_defaults, bool):
raise ValueError("allow_defaults must be a boolean.")
if self.type != "array" and self.item_type is not None:
raise ValueError("item_type can only be set for array fields.")
if self.type == "array" and self.item_type is None:
raise ValueError("item_type must be set for array fields.")
if not isinstance(self.directive_option, bool):
raise ValueError("directive_option must be a boolean.")
if not isinstance(self.predicate_defaults, tuple) or not all(
Expand All @@ -99,6 +94,16 @@ def __post_init__(self) -> None:
if self.default is not None and not self.allow_defaults:
raise ValueError("Defaults are not allowed for this field.")

@property
def type(self) -> Literal["string", "boolean", "integer", "number", "array"]:
return self.schema["type"]

@property
def item_type(self) -> None | Literal["string", "boolean", "integer", "number"]:
if self.schema["type"] == "array":
return self.schema["items"]["type"]
return None

def _set_default(self, value: Any, *, allow_coercion: bool) -> None:
"""Set the default value for this field.

Expand Down
51 changes: 0 additions & 51 deletions sphinx_needs/schema/config_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,13 @@
ValidateSchemaType,
get_schema_name,
validate_extra_link_schema_type,
validate_extra_option_schema,
validate_schemas_root_type,
)
from sphinx_needs.schema.core import validate_object_schema_compiles

log = get_logger(__name__)


def has_any_global_extra_schema_defined(needs_config: NeedsSphinxConfig) -> bool:
"""
Check if any extra option or extra link has a schema defined.

:return: True if any extra option or extra link has a schema set, False otherwise.
"""
# Check extra options
for option_value in needs_config.extra_options.values():
if option_value.schema is not None:
return True

# Check extra links
for extra_link in needs_config.extra_links:
if "schema" in extra_link and extra_link["schema"] is not None:
return True

return False


def validate_schemas_config(app: Sphinx, needs_config: NeedsSphinxConfig) -> None:
"""Check basics in extra option and extra link schemas."""

Expand All @@ -61,7 +41,6 @@ def validate_schemas_config(app: Sphinx, needs_config: NeedsSphinxConfig) -> Non
(Path(app.confdir) / orig_debug_path).resolve()
)

validate_extra_option_schemas(needs_config)
validate_extra_link_schemas(needs_config)

if not needs_config.schema_definitions:
Expand Down Expand Up @@ -242,36 +221,6 @@ def validate_extra_link_schemas(needs_config: NeedsSphinxConfig) -> None:
) from exc


def validate_extra_option_schemas(
needs_config: NeedsSphinxConfig,
) -> None:
"""
Check user provided extra options.

:return: Map of extra option names to their types as strings.
"""
# iterate over all extra options from config and API;
# API needs to make sure to run earlier (see priority) if options are added
for option, value in needs_config.extra_options.items():
if value.schema is None:
# nothing to check, leave it at None so it is explicitly unset
continue

try:
schema = validate_extra_option_schema(value.schema)
except TypeError as exc:
raise NeedsConfigException(
f"Schema for extra option '{option}' is not valid:\n{exc}"
) from exc

try:
validate_object_schema_compiles({"properties": {option: schema}})
except jsonschema_rs.ValidationError as exc:
raise NeedsConfigException(
f"Schema for extra option '{option}' is not valid:\n{exc}"
) from exc


def check_network_links_against_extra_links(
schemas: list[SchemasRootType], fields_schema: FieldsSchema
) -> None:
Expand Down
6 changes: 3 additions & 3 deletions sphinx_needs/schema/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ def process_schemas(app: Sphinx, builder: Builder) -> None:
if not config.schema_validation_enabled:
return

schema = SphinxNeedsData(app.env).get_schema()

extra_option_schema: NeedFieldsSchemaType = {
"type": "object",
"properties": {
name: option.schema
for name, option in config.extra_options.items()
if option.schema is not None
extra.name: extra.schema for extra in schema.iter_extra_fields()
},
}
extra_link_schema: NeedFieldsSchemaType = {
Expand Down
3 changes: 1 addition & 2 deletions tests/__snapshots__/test_api_usage.ambr
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# serializer version: 1
# name: test_api_add_extra_option_schema_wrong
'''
Schema for extra option 'my_extra_option' is not valid:
Additional properties are not allowed ('not_exist' was unexpected)
Invalid extra option 'my_extra_option': Invalid schema: Additional properties are not allowed ('not_exist' was unexpected)

Failed validating "additionalProperties" in schema

Expand Down
Loading