diff --git a/requirements.txt b/requirements.txt index ea4259d5..b7e605b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -dessert>=1.4,<2.0 -rich>=10.0,<14.0 cabina>=0.7,<1.0 +dessert>=1.4,<2.0 filelock>=3.5,<4.0 niltype>=0.3,<2.0 +pyparsing>=3.0,<4.0 +rich>=11.0,<14.0 diff --git a/tests/plugins/tagger/_utils.py b/tests/plugins/tagger/_utils.py index ee22c12a..ceb08a73 100644 --- a/tests/plugins/tagger/_utils.py +++ b/tests/plugins/tagger/_utils.py @@ -6,8 +6,10 @@ import pytest from vedro import Scenario -from vedro.core import Dispatcher, VirtualScenario -from vedro.events import ArgParsedEvent, ArgParseEvent +from vedro.core import Dispatcher +from vedro.core import MonotonicScenarioScheduler as Scheduler +from vedro.core import VirtualScenario +from vedro.events import ArgParsedEvent, ArgParseEvent, StartupEvent from vedro.plugins.tagger import Tagger, TaggerPlugin @@ -43,3 +45,8 @@ async def fire_arg_parsed_event(dispatcher: Dispatcher, *, tags: Optional[str] = arg_parsed_event = ArgParsedEvent(Namespace(tags=tags)) await dispatcher.fire(arg_parsed_event) + + +async def fire_startup_event(dispatcher: Dispatcher, scheduler: Scheduler) -> None: + startup_event = StartupEvent(scheduler) + await dispatcher.fire(startup_event) diff --git a/tests/plugins/tagger/test_logic_ops.py b/tests/plugins/tagger/test_logic_ops.py new file mode 100644 index 00000000..de73cbf5 --- /dev/null +++ b/tests/plugins/tagger/test_logic_ops.py @@ -0,0 +1,139 @@ +from typing import Set + +import pytest +from baby_steps import given, then, when +from pytest import raises + +from vedro.plugins.tagger.logic_tag_matcher._logic_ops import And, Expr, Not, Operand, Operator, Or + + +def test_tag_expr(): + with when, raises(BaseException) as exc: + Expr() + + with then: + assert exc.type is TypeError + + +def test_tag_operator(): + with when, raises(BaseException) as exc: + Operator() + + with then: + assert exc.type is TypeError + + +def test_tag_operand(): + with when: + operand = Operand("API") + + with then: + assert isinstance(operand, Expr) + assert repr(operand) == "Tag(API)" + + +@pytest.mark.parametrize(("tags", "result"), [ + ({"API"}, True), + ({"P0"}, False), +]) +def test_tag_operand_expr(tags: Set[str], result: bool): + with given: + operand = Operand("API") + + with when: + res = operand(tags) + + with then: + assert res is result + + +def test_not_operator(): + with given: + operand = Operand("API") + + with when: + operator = Not(operand) + + with then: + assert isinstance(operator, Expr) + assert repr(operator) == "Not(Tag(API))" + + +@pytest.mark.parametrize(("tags", "result"), [ + ({"API"}, False), + ({"P0"}, True), +]) +def test_not_operator_expr(tags: Set[str], result: bool): + with given: + operand = Operand("API") + operator = Not(operand) + + with when: + res = operator(tags) + + with then: + assert res is result + + +def test_and_operator(): + with given: + left_operand = Operand("API") + right_operand = Operand("P0") + + with when: + operator = And(left_operand, right_operand) + + with then: + assert isinstance(operator, Expr) + assert repr(operator) == "And(Tag(API), Tag(P0))" + + +@pytest.mark.parametrize(("tags", "result"), [ + ({"API", "P0"}, True), + ({"P0"}, False), + ({"API"}, False), + ({"CLI"}, False), +]) +def test_and_operator_expr(tags: Set[str], result: bool): + with given: + left_operand = Operand("API") + right_operand = Operand("P0") + operator = And(left_operand, right_operand) + + with when: + res = operator(tags) + + with then: + assert res is result + + +def test_or_operator(): + with given: + left_operand = Operand("API") + right_operand = Operand("P0") + + with when: + operator = Or(left_operand, right_operand) + + with then: + assert isinstance(operator, Expr) + assert repr(operator) == "Or(Tag(API), Tag(P0))" + + +@pytest.mark.parametrize(("tags", "result"), [ + ({"API", "P0"}, True), + ({"P0"}, True), + ({"API"}, True), + ({"CLI"}, False), +]) +def test_or_operator_expr(tags: Set[str], result: bool): + with given: + left_operand = Operand("API") + right_operand = Operand("P0") + operator = Or(left_operand, right_operand) + + with when: + res = operator(tags) + + with then: + assert res is result diff --git a/tests/plugins/tagger/test_logic_tag_matcher.py b/tests/plugins/tagger/test_logic_tag_matcher.py new file mode 100644 index 00000000..fd8aac8a --- /dev/null +++ b/tests/plugins/tagger/test_logic_tag_matcher.py @@ -0,0 +1,50 @@ +import pytest +from baby_steps import given, then, when +from pytest import raises + +from vedro.plugins.tagger.logic_tag_matcher import LogicTagMatcher + + +def test_match(): + with given: + matcher = LogicTagMatcher("P0") + + with when: + res = matcher.match({"P0"}) + + with then: + assert res is True + + +def test_not_match(): + with given: + matcher = LogicTagMatcher("P0") + + with when: + res = matcher.match({"API"}) + + with then: + assert res is False + + +def test_invalid_expr(): + with when, raises(ValueError) as exc: + LogicTagMatcher("P0 and") + + with then: + assert exc.type is ValueError + + +@pytest.mark.parametrize("expr", [ + "1TAG", + "-TAG", + "and", + "or", + "not", +]) +def test_invalid_tag(expr: str): + with when, raises(ValueError) as exc: + LogicTagMatcher(expr) + + with then: + assert exc.type is ValueError diff --git a/tests/plugins/tagger/test_tag_matcher.py b/tests/plugins/tagger/test_tag_matcher.py new file mode 100644 index 00000000..5780a014 --- /dev/null +++ b/tests/plugins/tagger/test_tag_matcher.py @@ -0,0 +1,12 @@ +from baby_steps import then, when +from pytest import raises + +from vedro.plugins.tagger import TagMatcher + + +def test_tag_matcher(): + with when, raises(BaseException) as exc: + TagMatcher("expr") + + with then: + assert exc.type is TypeError diff --git a/tests/plugins/tagger/test_tagger_plugin.py b/tests/plugins/tagger/test_tagger_plugin.py index a5a35cc7..f9f6e071 100644 --- a/tests/plugins/tagger/test_tagger_plugin.py +++ b/tests/plugins/tagger/test_tagger_plugin.py @@ -5,7 +5,6 @@ from vedro.core import Dispatcher from vedro.core import MonotonicScenarioScheduler as Scheduler from vedro.events import StartupEvent -from vedro.plugins.tagger import TaggerPlugin from ._utils import dispatcher, fire_arg_parsed_event, make_vscenario, tagger @@ -14,7 +13,7 @@ @pytest.mark.asyncio @pytest.mark.usefixtures(tagger.__name__) -async def test_no_tags(*, tagger: TaggerPlugin, dispatcher: Dispatcher): +async def test_no_tags(*, dispatcher: Dispatcher): with given: await fire_arg_parsed_event(dispatcher, tags=None) @@ -30,13 +29,13 @@ async def test_no_tags(*, tagger: TaggerPlugin, dispatcher: Dispatcher): @pytest.mark.asyncio -async def test_nonexisting_tag(*, tagger: TaggerPlugin, dispatcher: Dispatcher): +@pytest.mark.usefixtures(tagger.__name__) +async def test_nonexisting_tag(*, dispatcher: Dispatcher): with given: await fire_arg_parsed_event(dispatcher, tags="SMOKE") scenarios = [make_vscenario(), make_vscenario()] scheduler = Scheduler(scenarios) - startup_event = StartupEvent(scheduler) with when: @@ -47,7 +46,8 @@ async def test_nonexisting_tag(*, tagger: TaggerPlugin, dispatcher: Dispatcher): @pytest.mark.asyncio -async def test_tags(*, tagger: TaggerPlugin, dispatcher: Dispatcher): +@pytest.mark.usefixtures(tagger.__name__) +async def test_tag(*, dispatcher: Dispatcher): with given: await fire_arg_parsed_event(dispatcher, tags="SMOKE") @@ -57,7 +57,70 @@ async def test_tags(*, tagger: TaggerPlugin, dispatcher: Dispatcher): make_vscenario(tags=["SMOKE", "P0"]) ] scheduler = Scheduler(scenarios) + startup_event = StartupEvent(scheduler) + + with when: + await dispatcher.fire(startup_event) + + with then: + assert list(scheduler.scheduled) == [scenarios[0], scenarios[2]] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures(tagger.__name__) +async def test_tag_not_operator(*, dispatcher: Dispatcher): + with given: + await fire_arg_parsed_event(dispatcher, tags="not SMOKE") + + scenarios = [ + make_vscenario(tags=["SMOKE"]), + make_vscenario(), + make_vscenario(tags=["SMOKE", "P0"]) + ] + scheduler = Scheduler(scenarios) + startup_event = StartupEvent(scheduler) + + with when: + await dispatcher.fire(startup_event) + + with then: + assert list(scheduler.scheduled) == [scenarios[1]] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures(tagger.__name__) +async def test_tag_and_operator(*, dispatcher: Dispatcher): + with given: + await fire_arg_parsed_event(dispatcher, tags="SMOKE and P0") + + scenarios = [ + make_vscenario(tags=["SMOKE"]), + make_vscenario(), + make_vscenario(tags=["SMOKE", "P0"]), + make_vscenario(tags=["P0"]) + ] + scheduler = Scheduler(scenarios) + startup_event = StartupEvent(scheduler) + + with when: + await dispatcher.fire(startup_event) + + with then: + assert list(scheduler.scheduled) == [scenarios[2]] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures(tagger.__name__) +async def test_tag_or_operator(*, dispatcher: Dispatcher): + with given: + await fire_arg_parsed_event(dispatcher, tags="SMOKE or P0") + scenarios = [ + make_vscenario(tags=["SMOKE"]), + make_vscenario(), + make_vscenario(tags=["SMOKE", "P0"]) + ] + scheduler = Scheduler(scenarios) startup_event = StartupEvent(scheduler) with when: @@ -68,7 +131,29 @@ async def test_tags(*, tagger: TaggerPlugin, dispatcher: Dispatcher): @pytest.mark.asyncio -async def test_tags_skipped(*, tagger: TaggerPlugin, dispatcher: Dispatcher): +@pytest.mark.usefixtures(tagger.__name__) +async def test_tags_expr(*, dispatcher: Dispatcher): + with given: + await fire_arg_parsed_event(dispatcher, tags="(not SMOKE) and (not P0)") + + scenarios = [ + make_vscenario(tags=["SMOKE"]), + make_vscenario(), + make_vscenario(tags=["SMOKE", "P0"]) + ] + scheduler = Scheduler(scenarios) + startup_event = StartupEvent(scheduler) + + with when: + await dispatcher.fire(startup_event) + + with then: + assert list(scheduler.scheduled) == [scenarios[1]] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures(tagger.__name__) +async def test_tags_skipped(*, dispatcher: Dispatcher): with given: await fire_arg_parsed_event(dispatcher, tags="SMOKE") @@ -78,7 +163,6 @@ async def test_tags_skipped(*, tagger: TaggerPlugin, dispatcher: Dispatcher): make_vscenario(), ] scheduler = Scheduler(scenarios) - startup_event = StartupEvent(scheduler) with when: @@ -89,11 +173,12 @@ async def test_tags_skipped(*, tagger: TaggerPlugin, dispatcher: Dispatcher): @pytest.mark.asyncio -async def test_tags_type_validation(*, tagger: TaggerPlugin, dispatcher: Dispatcher): +@pytest.mark.usefixtures(tagger.__name__) +async def test_tags_type_validation(*, dispatcher: Dispatcher): with given: await fire_arg_parsed_event(dispatcher, tags="SMOKE") - scenario = make_vscenario(tags="SMOKE") + scenario = make_vscenario(tags={"SMOKE": "SMOKE"}) # type: ignore scheduler = Scheduler([scenario]) startup_event = StartupEvent(scheduler) @@ -104,12 +189,13 @@ async def test_tags_type_validation(*, tagger: TaggerPlugin, dispatcher: Dispatc with then: assert exc.type is TypeError assert str(exc.value) == ( - f"Scenario '{scenario.rel_path}' tags must be a list, tuple or set, got " + f"Scenario '{scenario.rel_path}' tags must be a list, tuple or set, got " ) @pytest.mark.asyncio -async def test_tags_value_validation(*, tagger: TaggerPlugin, dispatcher: Dispatcher): +@pytest.mark.usefixtures(tagger.__name__) +async def test_tags_value_validation(*, dispatcher: Dispatcher): with given: await fire_arg_parsed_event(dispatcher, tags="SMOKE") @@ -124,5 +210,5 @@ async def test_tags_value_validation(*, tagger: TaggerPlugin, dispatcher: Dispat with then: assert exc.type is ValueError assert str(exc.value) == ( - f"Scenario '{scenario.rel_path}' tag '-SMOKE' is not a valid identifier" + f"Scenario '{scenario.rel_path}' tag '-SMOKE' is not valid" ) diff --git a/vedro/plugins/tagger/__init__.py b/vedro/plugins/tagger/__init__.py index aed2cbc2..852debd2 100644 --- a/vedro/plugins/tagger/__init__.py +++ b/vedro/plugins/tagger/__init__.py @@ -1,3 +1,4 @@ +from ._tag_matcher import TagMatcher from ._tagger import Tagger, TaggerPlugin -__all__ = ("Tagger", "TaggerPlugin",) +__all__ = ("Tagger", "TaggerPlugin", "TagMatcher",) diff --git a/vedro/plugins/tagger/_tag_matcher.py b/vedro/plugins/tagger/_tag_matcher.py new file mode 100644 index 00000000..a93301f8 --- /dev/null +++ b/vedro/plugins/tagger/_tag_matcher.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod +from typing import Set + +__all__ = ("TagMatcher",) + + +class TagMatcher(ABC): + def __init__(self, expr: str) -> None: + self._expr = expr + + @abstractmethod + def match(self, tags: Set[str]) -> bool: + pass + + @abstractmethod + def validate(self, tag: str) -> bool: + pass diff --git a/vedro/plugins/tagger/_tagger.py b/vedro/plugins/tagger/_tagger.py index b27b66a0..f60b6c8a 100644 --- a/vedro/plugins/tagger/_tagger.py +++ b/vedro/plugins/tagger/_tagger.py @@ -1,47 +1,54 @@ -from typing import Any, Type, Union +from typing import Any, Callable, Type, Union from vedro.core import Dispatcher, Plugin, PluginConfig, VirtualScenario from vedro.events import ArgParsedEvent, ArgParseEvent, StartupEvent +from ._tag_matcher import TagMatcher +from .logic_tag_matcher import LogicTagMatcher + __all__ = ("Tagger", "TaggerPlugin",) class TaggerPlugin(Plugin): - def __init__(self, config: Type["Tagger"]) -> None: + def __init__(self, config: Type["Tagger"], *, + tag_matcher_factory: Callable[[str], TagMatcher] = LogicTagMatcher) -> None: super().__init__(config) - self._tags: Union[str, None] = None + self._matcher_factory = tag_matcher_factory + self._matcher: Union[TagMatcher, None] = None + self._tags_expr: Union[str, None] = None def subscribe(self, dispatcher: Dispatcher) -> None: dispatcher.listen(ArgParseEvent, self.on_arg_parse) \ - .listen(ArgParsedEvent, self.on_arg_parsed) \ - .listen(StartupEvent, self.on_startup) + .listen(ArgParsedEvent, self.on_arg_parsed) \ + .listen(StartupEvent, self.on_startup) def on_arg_parse(self, event: ArgParseEvent) -> None: - event.arg_parser.add_argument("-t", "--tags", nargs="?", help="Set tag") + event.arg_parser.add_argument("-t", "--tags", + help="Specify tags to selectively run scenarios") def on_arg_parsed(self, event: ArgParsedEvent) -> None: - self._tags = event.args.tags + self._tags_expr = event.args.tags - def _validate_tags(self, scenario: VirtualScenario, tags: Any) -> bool: + def _get_tags(self, scenario: VirtualScenario) -> Any: + tags = getattr(scenario._orig_scenario, "tags", ()) if not isinstance(tags, (list, tuple, set)): raise TypeError(f"Scenario '{scenario.rel_path}' tags must be a list, tuple or set, " f"got {type(tags)}") - - for tag in tags: - if not tag.isidentifier(): - raise ValueError( - f"Scenario '{scenario.rel_path}' tag '{tag}' is not a valid identifier") - - return True + return tags async def on_startup(self, event: StartupEvent) -> None: - if self._tags is None: + if self._tags_expr is None: return + + self._matcher = self._matcher_factory(self._tags_expr) async for scenario in event.scheduler: - tags = getattr(scenario._orig_scenario, "tags", ()) - assert self._validate_tags(scenario, tags) + tags = self._get_tags(scenario) + + for tag in tags: + if not self._matcher.validate(tag): + raise ValueError(f"Scenario '{scenario.rel_path}' tag '{tag}' is not valid") - if self._tags not in tags: + if not self._matcher.match(set(tags)): event.scheduler.ignore(scenario) diff --git a/vedro/plugins/tagger/logic_tag_matcher/__init__.py b/vedro/plugins/tagger/logic_tag_matcher/__init__.py new file mode 100644 index 00000000..1c864307 --- /dev/null +++ b/vedro/plugins/tagger/logic_tag_matcher/__init__.py @@ -0,0 +1,4 @@ +from ._logic_ops import And, Expr, Not, Operand, Operator, Or +from ._logic_tag_matcher import LogicTagMatcher + +__all__ = ("LogicTagMatcher", "And", "Expr", "Not", "Operand", "Or", "Operator",) diff --git a/vedro/plugins/tagger/logic_tag_matcher/_logic_ops.py b/vedro/plugins/tagger/logic_tag_matcher/_logic_ops.py new file mode 100644 index 00000000..f64737e4 --- /dev/null +++ b/vedro/plugins/tagger/logic_tag_matcher/_logic_ops.py @@ -0,0 +1,64 @@ +from abc import ABC, abstractmethod +from typing import Set + +__all__ = ("And", "Or", "Not", "Operand", "Operator", "Expr",) + + +class Expr(ABC): + @abstractmethod + def __call__(self, tags: Set[str]) -> bool: + pass + + @abstractmethod + def __repr__(self) -> str: + pass + + +class Operator(Expr, ABC): + pass + + +class Operand(Expr): + def __init__(self, tag: str) -> None: + self._tag = tag + + def __call__(self, tags: Set[str]) -> bool: + return self._tag in tags + + def __repr__(self) -> str: + return f"Tag({self._tag})" + + +class And(Operator): + def __init__(self, left: Operand, right: Operand) -> None: + self._left = left + self._right = right + + def __call__(self, tags: Set[str]) -> bool: + return self._left(tags) and self._right(tags) + + def __repr__(self) -> str: + return f"And({self._left}, {self._right})" + + +class Or(Operator): + def __init__(self, left: Operand, right: Operand) -> None: + self._left = left + self._right = right + + def __call__(self, tags: Set[str]) -> bool: + return self._left(tags) or self._right(tags) + + def __repr__(self) -> str: + return f"Or({self._left}, {self._right})" + + +class Not(Operator): + def __init__(self, operand: Operand) -> None: + self._operand = operand + + def __call__(self, tags: Set[str]) -> bool: + return not self._operand(tags) + + def __repr__(self) -> str: + return f"Not({self._operand})" diff --git a/vedro/plugins/tagger/logic_tag_matcher/_logic_tag_matcher.py b/vedro/plugins/tagger/logic_tag_matcher/_logic_tag_matcher.py new file mode 100644 index 00000000..6fc83d44 --- /dev/null +++ b/vedro/plugins/tagger/logic_tag_matcher/_logic_tag_matcher.py @@ -0,0 +1,60 @@ +from re import fullmatch +from typing import Set, cast + +from pyparsing import Literal +from pyparsing import ParserElement as Parser +from pyparsing import ParseResults, Regex, infixNotation, opAssoc +from pyparsing.exceptions import ParseException + +from .._tag_matcher import TagMatcher +from ._logic_ops import And, Expr, Not, Operand, Or + +__all__ = ("LogicTagMatcher",) + + +class LogicTagMatcher(TagMatcher): + tag_pattern = r'(?!and$|or$|not$)[A-Za-z_][A-Za-z0-9_]*' + + def __init__(self, expr: str) -> None: + super().__init__(expr) + operand = Regex(self.tag_pattern).setParseAction(self._create_tag) + self._parser = infixNotation(operand, [ + (Literal("not"), 1, opAssoc.RIGHT, self._create_not), + (Literal("and"), 2, opAssoc.LEFT, self._create_and), + (Literal("or"), 2, opAssoc.LEFT, self._create_or), + ]) + self._grammar = self._parse(self._parser, expr) + + def match(self, tags: Set[str]) -> bool: + return self._grammar(tags) + + def validate(self, tag: str) -> bool: + return fullmatch(self.tag_pattern, tag) is not None + + def _create_tag(self, orig: str, location: int, tokens: ParseResults) -> Expr: + tag = tokens[0] + return Operand(tag) + + def _create_not(self, orig: str, location: int, tokens: ParseResults) -> Expr: + operand = tokens[0][-1] + return Not(operand) + + def _create_and(self, orig: str, location: int, tokens: ParseResults) -> Expr: + left = tokens[0][0] + right = tokens[0][-1] + return And(left, right) + + def _create_or(self, orig: str, location: int, tokens: ParseResults) -> Expr: + left = tokens[0][0] + right = tokens[0][-1] + return Or(left, right) + + def _parse(self, grammar: Parser, expr: str) -> Expr: + try: + results = grammar.parse_string(expr, parse_all=True) + except ParseException as e: + raise ValueError(f"Invalid tag expr {expr!r}. Error: {str(e)}") from None + + if len(results) == 0: + raise ValueError(f"Invalid tag expr {expr!r}") + return cast(Expr, results[0])