Skip to content

Commit

Permalink
👌 Add ast analysis for NeedsView filters
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisjsewell committed Sep 27, 2024
1 parent 582ce9d commit 1c2533f
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 3 deletions.
79 changes: 78 additions & 1 deletion sphinx_needs/filter_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import ast
import re
from timeit import default_timer as timer
from types import CodeType
Expand Down Expand Up @@ -257,6 +258,63 @@ def filter_needs_mutable(
)


def _analyze_expr(needs: NeedsView, expr: ast.expr) -> tuple[NeedsView, bool]:
"""Analyze the expr for known filter patterns.
:returns: the needs view (potentially filtered),
and a boolean denoting if it still requires python eval filtering
"""
if isinstance((name := expr), ast.Name):
# x
if name.id == "is_external":
return needs.filter_is_external(True), False

Check warning on line 270 in sphinx_needs/filter_common.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/filter_common.py#L269-L270

Added lines #L269 - L270 were not covered by tests

elif isinstance((compare := expr), ast.Compare):
# <expr1> <comp> <expr2>
if len(compare.ops) == 1 and isinstance(compare.ops[0], ast.Eq):
# x == y
if (
isinstance(compare.left, ast.Name)
and len(compare.comparators) == 1
and isinstance(compare.comparators[0], (ast.Str, ast.Constant))
):
# x == "value"
field = compare.left.id
value = compare.comparators[0].s
elif (

Check warning on line 284 in sphinx_needs/filter_common.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/filter_common.py#L284

Added line #L284 was not covered by tests
isinstance(compare.left, (ast.Str, ast.Constant))
and len(compare.comparators) == 1
and isinstance(compare.comparators[0], ast.Name)
):
# "value" == x
field = compare.comparators[0].id
value = compare.left.s

Check warning on line 291 in sphinx_needs/filter_common.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/filter_common.py#L290-L291

Added lines #L290 - L291 were not covered by tests
else:
return needs, True

Check warning on line 293 in sphinx_needs/filter_common.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/filter_common.py#L293

Added line #L293 was not covered by tests

if field == "id":
return needs.filter_ids([value]), False
elif field == "type":
return needs.filter_types([value]), False
elif field == "status":
return needs.filter_statuses([value]), False
elif field == "tags":
return needs.filter_tags([value]), False

Check warning on line 302 in sphinx_needs/filter_common.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/filter_common.py#L302

Added line #L302 was not covered by tests

# TODO
# - ``field in ["a", "b", ...]``

elif isinstance((and_op := expr), ast.BoolOp) and isinstance(and_op.op, ast.And):
# x and y and ...
requires_eval = False
for operand in and_op.values:
needs, _requires_eval = _analyze_expr(needs, operand)
requires_eval |= _requires_eval
return needs, requires_eval

return needs, True


def filter_needs_view(
needs: NeedsView,
config: NeedsSphinxConfig,
Expand All @@ -266,8 +324,22 @@ def filter_needs_view(
location: tuple[str, int | None] | nodes.Node | None = None,
append_warning: str = "",
) -> list[NeedsInfoType]:
if not filter_string:
return list(needs.values())

try:
body = ast.parse(filter_string).body
except Exception:

Check warning on line 332 in sphinx_needs/filter_common.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/filter_common.py#L332

Added line #L332 was not covered by tests
# TODO emit warning
pass

Check warning on line 334 in sphinx_needs/filter_common.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/filter_common.py#L334

Added line #L334 was not covered by tests
else:
if len(body) == 1 and isinstance((expr := body[0]), ast.Expr):
needs, requires_eval = _analyze_expr(needs, expr.value)
if not requires_eval:
return list(needs.values())

return filter_needs(
needs.to_list(),
needs.values(),
config,
filter_string,
current_need,
Expand All @@ -285,6 +357,11 @@ def filter_needs_parts(
location: tuple[str, int | None] | nodes.Node | None = None,
append_warning: str = "",
) -> list[NeedsInfoType]:
if not filter_string:
return list(needs)

# TODO analyze ast

return filter_needs(
needs,
config,
Expand Down
17 changes: 15 additions & 2 deletions sphinx_needs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@
class _Indexes:
"""Indexes of common fields for fast filtering of needs."""

__slots__ = ("is_external", "status", "tags", "types", "type_names")
__slots__ = ("has_parts", "is_external", "status", "tags", "types", "type_names")

def __init__(
self,
has_parts: dict[bool, list[str]],
is_external: dict[bool, list[str]],
types: dict[str, list[str]],
type_names: dict[str, list[str]],
status: dict[str | None, list[str]],
tags: dict[str, list[str]],
) -> None:
self.has_parts = has_parts
self.is_external = is_external
self.types = types
self.type_names = type_names
Expand Down Expand Up @@ -59,20 +61,27 @@ def __init__(
def _get_indexes(self) -> _Indexes:
"""Lazily compute the indexes for the needs, when first requested."""
if self._maybe_indexes is None:
_idx_has_parts: dict[bool, list[str]] = {}
_idx_is_external: dict[bool, list[str]] = {}
_idx_types: dict[str, list[str]] = {}
_idx_type_names: dict[str, list[str]] = {}
_idx_status: dict[str | None, list[str]] = {}
_idx_tags: dict[str, list[str]] = {}
for id, need in self._needs.items():
_idx_has_parts.setdefault(bool(need.get("parts")), []).append(id)
_idx_is_external.setdefault(need["is_external"], []).append(id)
_idx_types.setdefault(need["type"], []).append(id)
_idx_type_names.setdefault(need["type_name"], []).append(id)
_idx_status.setdefault(need["status"], []).append(id)
for tag in need["tags"]:
_idx_tags.setdefault(tag, []).append(id)
self._maybe_indexes = _Indexes(
_idx_is_external, _idx_types, _idx_type_names, _idx_status, _idx_tags
_idx_has_parts,
_idx_is_external,
_idx_types,
_idx_type_names,
_idx_status,
_idx_tags,
)

return self._maybe_indexes
Expand Down Expand Up @@ -117,6 +126,10 @@ def filter_ids(self, values: list[str]) -> NeedsView:
"""Create new view with needs filtered by the ``id`` field."""
return self._copy_filtered(values)

def filter_has_parts(self, value: bool = True) -> NeedsView:
"""Create new view with only needs that do/do not have one or more parts."""
return self._copy_filtered(self._get_indexes().has_parts.get(value, []))

Check warning on line 131 in sphinx_needs/views.py

View check run for this annotation

Codecov / codecov/patch

sphinx_needs/views.py#L131

Added line #L131 was not covered by tests

def filter_is_external(self, value: bool) -> NeedsView:
"""Create new view with needs filtered by the ``is_external`` field."""
return self._copy_filtered(self._get_indexes().is_external.get(value, []))
Expand Down
87 changes: 87 additions & 0 deletions tests/test_filter.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import os
from pathlib import Path
from unittest.mock import Mock

import pytest
from sphinx.util.console import strip_colors

from sphinx_needs.filter_common import filter_needs_view
from sphinx_needs.views import NeedsView


@pytest.mark.parametrize(
"test_app",
Expand Down Expand Up @@ -104,3 +108,86 @@ def test_filter_build_html(test_app):
assert '<img alt="_images/need_pie_' in html_6

assert html_6.count('<p class="needs_filter_warning"') == 18


def create_needs_view():
needs = [
{
"id": "req_a_1",
"type": "requirement",
"type_name": "Req",
"tags": ["a", "b"],
"status": "",
"is_external": False,
},
{
"id": "req_b_1",
"type": "requirement",
"type_name": "Req",
"tags": ["b", "c"],
"status": "",
"is_external": False,
},
{
"id": "req_c_1",
"type": "requirement",
"type_name": "Req",
"tags": ["c", "d"],
"status": "",
"is_external": False,
},
{
"id": "story_a_1",
"type": "story",
"type_name": "Story",
"tags": ["a", "b"],
"status": "",
"is_external": False,
},
{
"id": "story_b_1",
"type": "story",
"type_name": "Story",
"tags": ["b", "c"],
"status": "",
"is_external": False,
},
{
"id": "story_a_b_1",
"type": "story",
"type_name": "Story",
"tags": ["a", "b", "c"],
"status": "done",
"is_external": False,
},
]

return NeedsView(_needs={n["id"]: n for n in needs})


test_params = (
("", list(create_needs_view().keys())),
("True", list(create_needs_view().keys())),
("False", []),
("False and False", []),
("False and True", []),
("True and True", list(create_needs_view().keys())),
("True or False", list(create_needs_view().keys())),
("id == 'req_a_1'", ["req_a_1"]),
("id == 'unknown'", []),
("type == 'requirement'", ["req_a_1", "req_b_1", "req_c_1"]),
("type == 'unknown'", []),
("type == 'requirement' and True", ["req_a_1", "req_b_1", "req_c_1"]),
("type == 'requirement' and False", []),
("type == 'story' and status == 'done'", ["story_a_b_1"]),
)


@pytest.mark.parametrize(
"filter_string, expected_ids", test_params, ids=[s for s, _ in test_params]
)
def test_filter_needs_view(filter_string, expected_ids):
mock_config = Mock()
mock_config.filter_data = {}
result = filter_needs_view(create_needs_view(), mock_config, filter_string)
assert [n["id"] for n in result] == expected_ids

0 comments on commit 1c2533f

Please sign in to comment.