Skip to content
Merged

wip #253

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
103 changes: 51 additions & 52 deletions src/extensions/score_metamodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@
import importlib
import os
import pkgutil
import re
from collections.abc import Callable
from pathlib import Path
from typing import Any

from sphinx.application import Sphinx
from sphinx_needs import logging
Expand Down Expand Up @@ -102,6 +100,13 @@ def _run_checks(app: Sphinx, exception: Exception | None) -> None:
if exception:
return

# First of all postprocess the need links to convert
# type names into actual need types.
# This must be done before any checks are run.
# And it must be done after config was hashed, otherwise
# the config hash would include recusive linking between types.
postprocess_need_links(app.config.needs_types)

# Filter out external needs, as checks are only intended to be run
# on internal needs.
needs_all_needs = SphinxNeedsData(app.env).get_needs_view()
Expand Down Expand Up @@ -171,59 +176,53 @@ def _get_need_type_for_need(app: Sphinx, need: NeedsInfoType) -> ScoreNeedType:
raise ValueError(f"Need type {need['type']} not found in needs_types")


def _validate_external_need_opt_links(
need: NeedsInfoType,
opt_links: dict[str, str],
allowed_prefixes: list[str],
log: CheckLogger,
) -> None:
for link_field, pattern in opt_links.items():
raw_value: str | list[str] | None = need.get(link_field, None)
if raw_value in [None, [], ""]:
continue

values: list[str | Any] = (
raw_value if isinstance(raw_value, list) else [raw_value]
)
for value in values:
v: str | Any
if isinstance(value, str):
v = _remove_prefix(value, allowed_prefixes)
else:
v = value

try:
if not isinstance(v, str) or not re.match(pattern, v):
log.warning_for_option(
need,
link_field,
f"does not follow pattern `{pattern}`.",
is_new_check=True,
)
except TypeError:
log.warning_for_option(
need,
link_field,
f"pattern `{pattern}` is not a valid regex pattern.",
is_new_check=True,
)


def _check_external_optional_link_patterns(app: Sphinx, log: CheckLogger) -> None:
"""Validate optional link patterns on external needs and log as info-only.

Mirrors the original inline logic from ``_run_checks`` without changing behavior.
def _resolve_linkable_types(
link_name: str,
link_value: str,
current_need_type: ScoreNeedType,
needs_types: list[ScoreNeedType],
) -> list[ScoreNeedType]:
needs_types_dict = {nt["directive"]: nt for nt in needs_types}
link_values = [v.strip() for v in link_value.split(",")]
linkable_types: list[ScoreNeedType] = []
for v in link_values:
target_need_type = needs_types_dict.get(v)
if target_need_type is None:
logger.error(
f"In metamodel.yaml: {current_need_type['directive']}, "
f"link '{link_name}' references unknown type '{v}'."
)
else:
linkable_types.append(target_need_type)
return linkable_types


def postprocess_need_links(needs_types_list: list[ScoreNeedType]):
"""Convert link option strings into lists of target need types.

If a link value starts with '^' it is treated as a regex and left
unchanged. Otherwise it is a comma-separated list of type names which
are resolved to the corresponding ScoreNeedTypes.
"""
needs_external_needs = (
SphinxNeedsData(app.env).get_needs_view().filter_is_external(True)
)
for need_type in needs_types_list:
try:
link_dicts = (
need_type["mandatory_links"],
need_type["optional_links"],
)
except KeyError:
# TODO: remove the Sphinx-Needs defaults from our metamodel
# Example: {'directive': 'issue', 'title': 'Issue', 'prefix': 'IS_'}
continue

for need in needs_external_needs.values():
need_type = _get_need_type_for_need(app, need)
for link_dict in link_dicts:
for link_name, link_value in link_dict.items():
assert isinstance(link_value, str) # so far all of them are strings

if opt_links := need_type["optional_links"]:
allowed_prefixes = app.config.allowed_external_prefixes
_validate_external_need_opt_links(need, opt_links, allowed_prefixes, log)
if not link_value.startswith("^"):
link_dict[link_name] = _resolve_linkable_types( # pyright: ignore[reportArgumentType]
link_name, link_value, need_type, needs_types_list
)


def setup(app: Sphinx) -> dict[str, str | bool]:
Expand Down
75 changes: 67 additions & 8 deletions src/extensions/score_metamodel/checks/check_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,49 @@ def _validate_value_pattern(
) from e


def _log_option_warning(
need: NeedsInfoType,
log: CheckLogger,
field_type: str,
allowed_directives: list[ScoreNeedType] | None,
field: str,
value: str | list[str],
allowed_value: str | list[str],
required: bool,
):
if field_type == "link":
if allowed_directives:
dirs = " or ".join(
f"{d['title']} ({d['directive']})" for d in allowed_directives
)
msg = f"but it must reference {dirs}."
else:
msg = f"which does not follow pattern `{allowed_value}`."

# warning_for_option will print all the values. This way the specific
# problematic value is highlighted in the message.
# This is especially useful if multiple values are given.
msg = f"references '{value}' as '{field}', {msg}"
log.warning_for_need(
need,
msg,
# TODO: Errors in optional links are non fatal for now
is_new_check=not required,
)
else:
msg = f"does not follow pattern `{allowed_value}`."
log.warning_for_option(
need,
field,
msg,
is_new_check=False,
)


def validate_fields(
need: NeedsInfoType,
log: CheckLogger,
fields: dict[str, str],
fields: dict[str, str] | dict[str, list[ScoreNeedType]],
required: bool,
field_type: str,
allowed_prefixes: list[str],
Expand All @@ -83,8 +122,6 @@ def remove_prefix(word: str, prefixes: list[str]) -> str:
# Removes any prefix allowed by configuration, if prefix is there.
return [word.removeprefix(prefix) for prefix in prefixes][0]

optional_link_as_info = (not required) and (field_type == "link")

for field, allowed_value in fields.items():
raw_value: str | list[str] | None = need.get(field, None)
if raw_value in [None, [], ""]:
Expand All @@ -96,17 +133,37 @@ def remove_prefix(word: str, prefixes: list[str]) -> str:

values = _normalize_values(raw_value)

# Links can be configured to reference other need types instead of regex.
# However, in order to not "load" the other need, we'll check the regex as
# it does encode the need type (at least in S-CORE metamodel).
# Therefore this can remain a @local_check!
# TypedDicts cannot be used with isinstance, so check for dict and required keys
if isinstance(allowed_value, list):
assert field_type == "link" # sanity check
# patterns holds a list of allowed need types
allowed_directives = allowed_value
allowed_value = (
"("
+ "|".join(d["mandatory_options"]["id"] for d in allowed_directives)
+ ")"
)
else:
allowed_directives = None

# regex based validation
for value in values:
if allowed_prefixes:
value = remove_prefix(value, allowed_prefixes)
if not _validate_value_pattern(value, allowed_value, need, field):
msg = f"does not follow pattern `{allowed_value}`."
log.warning_for_option(
_log_option_warning(
need,
log,
field_type,
allowed_directives,
field,
msg,
is_new_check=optional_link_as_info,
value,
allowed_value,
required,
)


Expand All @@ -132,7 +189,9 @@ def check_options(
allowed_prefixes = app.config.allowed_external_prefixes

# Validate Options and Links
field_validations = [
field_validations: list[
tuple[str, dict[str, str] | dict[str, list[ScoreNeedType]], bool]
] = [
("option", need_type["mandatory_options"], True),
("option", need_type["optional_options"], False),
("link", need_type["mandatory_links"], True),
Expand Down
Loading
Loading