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
4 changes: 4 additions & 0 deletions .bazelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Claude Code creates .claude directory, e.g. for worktrees.
# As it contains multiple copies of the entire repository, this totally tripps bazel,
# which then tries to build all the files in there.
.claude
70 changes: 70 additions & 0 deletions docs/internals/extensions/rst_filebased_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,73 @@ a new line between the EXPECT/-NOT and the need that it refers to

This will error and let you know that an offset of '1' is not allowed and you
need to add a new line beneath the Warning Statement

## Graph check tests

Graph checks (defined in `metamodel.yaml` under `graph_checks:`) are all executed
by a single Python function `check_metamodel_graph`. Use that function name in `#CHECK:`:

#CHECK: check_metamodel_graph

Graph check test files live in `tests/rst/graph/`.

### Warning message format

`check_metamodel_graph` emits warnings in this shape:

{need_id}: Parent need `{parent_id}` does not fulfill condition `{condition}`. Explanation: {explanation}

You only need to match a unique substring, not the full message. For example:

#EXPECT[+2]: wp__bad: Parent need `std_req__aspice_40__bp`

### Setup needs

Prerequisite needs (link targets, shared fixtures) belong at the top of the file
with no `EXPECT`/`EXPECT-NOT` annotation. The framework only checks annotated lines,
so unannotated needs are invisible to the assertion logic.

### The "exempt" case

A need that does not meet the `condition:` in the YAML check definition is not
selected by the check at all — no warning is emitted. Test this with `EXPECT-NOT`:

#EXPECT-NOT[+2]: <something from the explanation>

.. workproduct:: No link at all
:id: wp__no_link

### Full example (graph check)

#CHECK: check_metamodel_graph

.. std_req:: ASPICE 40 IIC requirement
:id: std_req__aspice_40__iic_1
:status: valid

.. std_req:: ASPICE 40 non-IIC requirement
:id: std_req__aspice_40__bp_1
:status: valid

.. Positive: links to allowed target — no warning.

#EXPECT-NOT[+2]: ASPICE 40 IIC

.. workproduct:: Valid workproduct
:id: wp__valid
:complies: std_req__aspice_40__iic_1

.. Exempt: no complies link — condition not met, check skipped.

#EXPECT-NOT[+2]: ASPICE 40 IIC

.. workproduct:: Workproduct without link
:id: wp__no_link

.. Negative: links to disallowed target — warning expected.

#EXPECT[+2]: wp__bad: Parent need `std_req__aspice_40__bp_1`

.. workproduct:: Invalid workproduct
:id: wp__bad
:complies: std_req__aspice_40__bp_1
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ junit_family = "xunit1"

filterwarnings = [
"ignore::pytest.PytestExperimentalApiWarning",

# We'll deal with these when it is time.
# No need to spam all the logs all the time.
"ignore:.*BuildEnvironment.app.*:sphinx.deprecation.RemovedInSphinx11Warning",
"ignore:.*frontend.OptionParser.*:DeprecationWarning",
"ignore:.*frontend.Option class.*:DeprecationWarning",
]

pythonpath = [
"src/extensions/",
]
12 changes: 6 additions & 6 deletions score_pytest/attribute_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ def decorator(func: TestFunction) -> TestFunction:
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]):
"""Attach file and line info to the report for use in junitxml output."""

outcome = yield
report = outcome.get_result()
outcome = yield # pyright: ignore[reportUnknownVariableType]
report = outcome.get_result() # pyright: ignore[reportUnknownVariableType]

if report.when != "call":
return
Expand All @@ -115,7 +115,7 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]):
)

if isinstance(marker.args[0], dict):
for k, v in marker.args[0].items():
for k, v in marker.args[0].items(): # pyright: ignore[reportUnknownVariableType]
item.user_properties.append((k, str(v)))


Expand All @@ -124,11 +124,11 @@ def add_file_and_line_attr(
record_xml_attribute: Callable[[str, str], None], request: pytest.FixtureRequest
) -> None:
"""Adding line & file to the <testcase> attribute in the XML"""
node = request.node
raw_file_path, line_number, _ = node.location
node = request.node # pyright: ignore[reportUnknownVariableType]
raw_file_path, line_number, _ = node.location # pyright: ignore[reportUnknownVariableType]

# turning `../../../_main/<file_path>` into => <filepath>
clean_file_path = raw_file_path.split("_main/")[-1]
clean_file_path = raw_file_path.split("_main/")[-1] # pyright: ignore[reportUnknownVariableType]
record_xml_attribute("file", str(clean_file_path))
# Convert pytest's 0-based source line number to 1-based numbering for XML output.
record_xml_attribute("line", str(line_number + 1))
2 changes: 1 addition & 1 deletion score_pytest/tests/test_rules_are_working_correctly.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
def test_local_fixture_has_correct_value(fixture42):
def test_score_pytest_loads_conftest(fixture42): # pyright: ignore[reportMissingParameterType]
assert fixture42 == 42
14 changes: 7 additions & 7 deletions scripts_bazel/tests/traceability_gate_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def test_gate_fail_on_broken_test_refs(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
metrics_by_type = {
metrics_by_type: dict[str, object] = {
"tool_req": {
"include_not_implemented": False,
"include_external": False,
Expand All @@ -223,7 +223,7 @@ def test_gate_fail_on_broken_test_refs(
"fully_linked_pct": 100.0,
}
}
tests = {
tests: dict[str, object] = {
"total": 2,
"linked_to_requirements": 2,
"linked_to_requirements_pct": 100.0,
Expand All @@ -248,7 +248,7 @@ def test_gate_specific_need_type_only(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
metrics_by_type = {
metrics_by_type: dict[str, object] = {
"tool_req": {
"include_not_implemented": False,
"include_external": False,
Expand All @@ -272,7 +272,7 @@ def test_gate_specific_need_type_only(
"fully_linked_pct": 0.0,
},
}
tests = {
tests: dict[str, object] = {
"total": 1,
"linked_to_requirements": 1,
"linked_to_requirements_pct": 100.0,
Expand Down Expand Up @@ -349,7 +349,7 @@ def test_gate_missing_metrics_by_type_returns_error(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
payload = {
payload: dict[str, object] = {
"schema_version": "2",
"generated_by": "sphinx_build",
"overall_metrics": {
Expand Down Expand Up @@ -383,7 +383,7 @@ def test_gate_missing_tests_section_returns_error(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
metrics_by_type = {
metrics_by_type: dict[str, object] = {
"tool_req": {
"include_not_implemented": False,
"include_external": False,
Expand All @@ -396,7 +396,7 @@ def test_gate_missing_tests_section_returns_error(
"fully_linked_pct": 100.0,
}
}
payload = {
payload: dict[str, object] = {
"schema_version": "2",
"generated_by": "sphinx_build",
"overall_metrics": _derive_overall_metrics(metrics_by_type),
Expand Down
69 changes: 35 additions & 34 deletions src/extensions/score_metamodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,51 +165,52 @@ def _resolve_linkable_types(
link_name: str,
link_value: str,
current_need_type: ScoreNeedType,
needs_types: list[ScoreNeedType],
) -> list[ScoreNeedType | str]:
needs_types_dict = {nt["directive"]: nt for nt in needs_types}
needs_types: dict[str, ScoreNeedType],
) -> list[ScoreNeedType]:
# Anything is allowed if the value is "ANY". This allows to bypass the link type validation for specific links.
if link_value == "ANY":
return list(needs_types.values())

link_values = [v.strip() for v in link_value.split(",")]
linkable_types: list[ScoreNeedType | str] = []
linkable_types: list[ScoreNeedType] = []
for v in link_values:
if v.startswith("^"):
linkable_types.append(v) # keep regex as-is
target_need_type = needs_types.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:
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)
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.
Parses comma-separated list of type names which are resolved to the corresponding
ScoreNeedTypes.
"""
for need_type in needs_types_list:
try:
link_dicts = (
need_type["mandatory_links"],
need_type["optional_links"],
# "mandatory_links_str" is used to keep only metamodel sourced types. That key is so
# specific, that noone but our metamodel should be using it.
all_need_types = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this all_need_types? It reads like a filtered version already.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's "all our types" aka "all types from metamodel"

nt["directive"]: nt for nt in needs_types_list if "mandatory_links_str" in nt
}

for need_type in all_need_types.values():
need_type["mandatory_links"] = {
link_name: _resolve_linkable_types(
link_name, link_value, need_type, all_need_types
)
for link_name, link_value in need_type["mandatory_links_str"].items()
}

need_type["optional_links"] = {
link_name: _resolve_linkable_types(
link_name, link_value, need_type, all_need_types
)
except KeyError:
# TODO: remove the Sphinx-Needs defaults from our metamodel
# Example: {'directive': 'issue', 'title': 'Issue', 'prefix': 'IS_'}
continue

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

link_dict[link_name] = _resolve_linkable_types( # pyright: ignore[reportArgumentType]
link_name, link_value, need_type, needs_types_list
)
for link_name, link_value in need_type["optional_links_str"].items()
}


def _clear_needs_defaults(app: Sphinx):
Expand Down
35 changes: 21 additions & 14 deletions src/extensions/score_metamodel/checks/check_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
default_options,
local_check,
)
from score_metamodel.metamodel_types import AllowedLinksType
from sphinx.application import Sphinx
from sphinx_needs.need_item import NeedItem

Expand Down Expand Up @@ -134,19 +133,23 @@ def _validate(attributes_to_allowed_values: dict[str, str], mandatory: bool):
# )


def _to_link_pattern(value: "str | ScoreNeedType") -> str:
"""Convert a link constraint to a regex pattern.
def _to_link_pattern(value: ScoreNeedType) -> str:
"""
Convert a link constraint to a regex pattern.

- A plain type name like ``"stkh_req"`` becomes ``^stkh_req__``
(matching IDs that start with the type name followed by ``__``).
- A string already starting with ``^`` is treated as an explicit regex
and returned unchanged.
- A ScoreNeedType dict uses its ``mandatory_options.id`` pattern.
Note: the pattern is already stored in "id" attribute, this is just a helper
function to retrieve it safely.
"""
if isinstance(value, str):
if value.startswith("^"):
return value
return f"^{value}__"
assert isinstance(value, dict), f"Expected dict for ScoreNeedType, got {value}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you not do is instance to make sure it's actually a ScoreNeedType?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, isinstance does not work with TypedDict. As the type info is not a real type AFAIK.

assert "mandatory_options" in value, (
f"ScoreNeedType dict must have 'mandatory_options', got {value}"
)
assert isinstance(value["mandatory_options"], dict), (
f"'mandatory_options' must be a dict, got {value['mandatory_options']}"
)
assert "id" in value["mandatory_options"], (
f"'mandatory_options' must contain 'id', got {value['mandatory_options']}"
)
return value["mandatory_options"]["id"]


Expand All @@ -160,10 +163,12 @@ def validate_links(
"""

def _validate(
attributes_to_allowed_values: AllowedLinksType,
attributes_to_allowed_values: dict[str, list[ScoreNeedType]] | None,
mandatory: bool,
treat_as_info: bool = False,
):
assert attributes_to_allowed_values is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda an antipattern no?
If the typehint is allowed to be None, but here you assert that it should never happen?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah... its not pretty. This avoid checking the argument on all call sites, which would be even uglier.


for attribute, allowed_values in attributes_to_allowed_values.items():
values = _get_normalized(need, attribute)
if mandatory and not values:
Expand Down Expand Up @@ -238,7 +243,9 @@ def check_extra_options(
"mandatory_links",
"optional_links",
):
allowed_options.update(need_options[o].keys())
val = need_options[o]
assert val is not None
allowed_options.update(val.keys())

extra_options = [
option
Expand Down
Loading
Loading