Repository: pytest-dev/pytest

Issue:
Support marker keyword arguments in marker expression (test selection via `-m`)  
<!--
Thanks for suggesting a feature!

Quick check-list while suggesting features:
-->

#### What's the problem this feature will solve?
<!-- What are you trying to do, that you are unable to achieve with pytest as it currently stands? -->

It's possible to create custom markers like so:
```py
@pytest.mark.my_device_under_test
```

This marker can the be used to de/select tests via marker expressions, e.g.:
`pytest -m "not my_device_under_test"`

It is also possible to define keyword arguments for custom markers, e.g.:

```py
@pytest.mark.my_device_under_test(with_serial="1234")
```

Now, it would be amazing to select tests further depending on their keyword arguments + values, e.g.:

`pytest -m "my_device_under_test(with_serial="1234)"`

So only tests with that specific marker + that specific keyword argument+value would be run.

#### Describe the solution you'd like
<!-- A clear and concise description of what you want to happen. -->

Marker keyword argument selection in pytest's marker expression.

<!-- Provide examples of real-world use cases that this would enable and how it solves the problem described above. -->

#### Alternative Solutions
<!-- Have you tried to workaround the problem using a pytest plugin or other tools? Or a different approach to solving this issue? Please elaborate here. -->

- There was a `nose` test plugin, which supported something similar [here](https://github.com/nose-devs/nose/blob/master/nose/plugins/attrib.py). This could be ported to pytest but comes with it's own `attr` decorator.

- A pytest plugin could probably do this in an extra step as well, but would somehow need to disable pytest's internal marker selection plugin [here](https://github.com/pytest-dev/pytest/blob/main/src/_pytest/mark/__init__.py#L261) as it would collide/fail otherwise.

#### Additional context
When skimming through the code I saw that @bluetech implemented a custom parsers some years ago. I'm not very familiar with the topic, as in how much effort it would be support such feature. I'd be interested to contribute though. 
<!-- Add any other context, links, etc. about the feature here. -->



Discussion:
The feature request makes sense to me, I think it would be great to allow matching on mark arguments (there are two kinds -- positional and keyword arguments), and something like the function call syntax seems natural for this.

For the match expression grammar, it shouldn't pose a big challenge to add. We would need to decide whether the argument values (the `1234` in your example) can be just idents (simple), or more complex, e.g. to support `my_device_under_test(with_serial=1234 or 5678)` etc., which would be more complex.

The bigger implementation issue would be the evaluation of the expression. Currently, to optimize the evaluation, we compile the expression to a python AST, then `eval` the AST with an environment ("locals") where every ident returns either True (if matches) or False. This will need to be extended to support the function-call syntax. As a quick thought, we could replace the `locals` hack with injecting some `$match` symbol into the `eval` environment which has the form `matcher(name, *args: str, **kwargs: str)` (assuming we only allow idents).
If we are to implement this, I propose we keep it simple, as it would be possible to also write `my_device_under_test(with_serial=1234) or my_device_under_test(with_serial=5678)`.


I can see the appeal of this, and I agree in theory it makes sense to keep things close to a Python-like syntax.

However, at the same time, I'm also worried this might be a bottomless pit. Where do we stop in terms of types of arguments we support? Allowing `my_device_under_test(with_serial=1234)` or `use_case("ABC-1234")` seems obvious (and would indeed cover things I've seen various companies implement custom arguments/filtering via plugin hooks for).

However, what about, say, enums? Surely there's an argument to be made for enums, as in `size(Size.end2end)`. But then where is `Size` coming from? I'm playing devil's advocate here, but if we allow enums, why wouldn't we also allow, I don't know, custom dataclass instances (in the sense of `device(Multimeter(...))`)? Etc. etc., and then at some point we're back to essentially reimplementing the `eval()` solution we had at some point.

Maybe though, we could limit this to `int` / `str` / `bool` / `None` or somesuch, or perhaps what [`ast.literal_eval`](https://docs.python.org/3/library/ast.html#ast.literal_eval) supports. But still, while I can absolutely see the need and usefulness, it seems very tempting to take this too far over time.

Running into this thought myself, I was wondering if we could instead consider a bit of a tangent to avoid pitfalls mentioned by @The-Compiler. How about introducing a new interface called `pytest.mark.annotated(*args: str)`?

It would look like:
```python
@pytest.mark.annotated("annotation1", "annotation2", ...)
def test_my_function(...):
    pass
```
The selection can be with a slightly specialized syntax:
```
pytest -m [annotation1]
```
which can be combined with other non-annotated markers if needed:
```sh
pytest -m "awesome and [annotation1] and [annotation2]"
```
This is surely limiting, but maybe that's not a bad thing. For my use case, this would help with adding bug number/jira ticket annotations to test cases.



Patch:
diff --git a/AUTHORS b/AUTHORS
index 4d34d2ba9b4..5ae80eb02d3 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -245,6 +245,7 @@ Levon Saldamli
 Lewis Cowles
 Llandy Riveron Del Risco
 Loic Esteve
+lovetheguitar
 Lukas Bednar
 Luke Murphy
 Maciek Fijalkowski
diff --git a/changelog/12281.feature.rst b/changelog/12281.feature.rst
new file mode 100644
index 00000000000..c6e8e3b3098
--- /dev/null
+++ b/changelog/12281.feature.rst
@@ -0,0 +1,8 @@
+Added support for keyword matching in marker expressions.
+
+Now tests can be selected by marker keyword arguments.
+Supported values are :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None`.
+
+See :ref:`marker examples <marker_keyword_expression_example>` for more information.
+
+-- by :user:`lovetheguitar`
diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst
index c04d2a078dd..159ff2cd1d7 100644
--- a/doc/en/example/markers.rst
+++ b/doc/en/example/markers.rst
@@ -25,10 +25,12 @@ You can "mark" a test function with custom metadata like this:
         pass  # perform some webtest test for your app
 
 
+    @pytest.mark.device(serial="123")
     def test_something_quick():
         pass
 
 
+    @pytest.mark.device(serial="abc")
     def test_another():
         pass
 
@@ -71,6 +73,28 @@ Or the inverse, running all tests except the webtest ones:
 
     ===================== 3 passed, 1 deselected in 0.12s ======================
 
+.. _`marker_keyword_expression_example`:
+
+Additionally, you can restrict a test run to only run tests matching one or multiple marker
+keyword arguments, e.g. to run only tests marked with ``device`` and the specific ``serial="123"``:
+
+.. code-block:: pytest
+
+    $ pytest -v -m 'device(serial="123")'
+    =========================== test session starts ============================
+    platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
+    cachedir: .pytest_cache
+    rootdir: /home/sweet/project
+    collecting ... collected 4 items / 3 deselected / 1 selected
+
+    test_server.py::test_something_quick PASSED                          [100%]
+
+    ===================== 1 passed, 3 deselected in 0.12s ======================
+
+.. note:: Only keyword argument matching is supported in marker expressions.
+
+.. note:: Only :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None` values are supported in marker expressions.
+
 Selecting tests based on their node ID
 --------------------------------------
 
diff --git a/doc/en/how-to/usage.rst b/doc/en/how-to/usage.rst
index 705fa009ed5..05ee0460019 100644
--- a/doc/en/how-to/usage.rst
+++ b/doc/en/how-to/usage.rst
@@ -76,11 +76,19 @@ Specifying a specific parametrization of a test:
 
 **Run tests by marker expressions**
 
+To run all tests which are decorated with the ``@pytest.mark.slow`` decorator:
+
 .. code-block:: bash
 
     pytest -m slow
 
-Will run all tests which are decorated with the ``@pytest.mark.slow`` decorator.
+
+To run all tests which are decorated with the annotated ``@pytest.mark.slow(phase=1)`` decorator,
+with the ``phase`` keyword argument set to ``1``:
+
+.. code-block:: bash
+
+    pytest -m slow(phase=1)
 
 For more information see :ref:`marks <mark>`.
 
diff --git a/pyproject.toml b/pyproject.toml
index 14f69bc3422..e0ed2b90061 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -358,6 +358,9 @@ markers = [
     "foo",
     "bar",
     "baz",
+    "number_mark",
+    "builtin_matchers_mark",
+    "str_mark",
     # conftest.py reorders tests moving slow ones to the end of the list
     "slow",
     # experimental mark for all tests using pexpect


Test patch:
diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py
index b8a3092151f..a4f942c5ae3 100644
--- a/src/_pytest/mark/__init__.py
+++ b/src/_pytest/mark/__init__.py
@@ -2,9 +2,11 @@
 
 from __future__ import annotations
 
+import collections
 import dataclasses
 from typing import AbstractSet
 from typing import Collection
+from typing import Iterable
 from typing import Optional
 from typing import TYPE_CHECKING
 
@@ -21,6 +23,7 @@
 from _pytest.config import ExitCode
 from _pytest.config import hookimpl
 from _pytest.config import UsageError
+from _pytest.config.argparsing import NOT_SET
 from _pytest.config.argparsing import Parser
 from _pytest.stash import StashKey
 
@@ -181,7 +184,9 @@ def from_item(cls, item: Item) -> KeywordMatcher:
 
         return cls(mapped_names)
 
-    def __call__(self, subname: str) -> bool:
+    def __call__(self, subname: str, /, **kwargs: str | int | bool | None) -> bool:
+        if kwargs:
+            raise UsageError("Keyword expressions do not support call parameters.")
         subname = subname.lower()
         names = (name.lower() for name in self._names)
 
@@ -218,17 +223,26 @@ class MarkMatcher:
     Tries to match on any marker names, attached to the given colitem.
     """
 
-    __slots__ = ("own_mark_names",)
+    __slots__ = ("own_mark_name_mapping",)
 
-    own_mark_names: AbstractSet[str]
+    own_mark_name_mapping: dict[str, list[Mark]]
 
     @classmethod
-    def from_item(cls, item: Item) -> MarkMatcher:
-        mark_names = {mark.name for mark in item.iter_markers()}
-        return cls(mark_names)
+    def from_markers(cls, markers: Iterable[Mark]) -> MarkMatcher:
+        mark_name_mapping = collections.defaultdict(list)
+        for mark in markers:
+            mark_name_mapping[mark.name].append(mark)
+        return cls(mark_name_mapping)
+
+    def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool:
+        if not (matches := self.own_mark_name_mapping.get(name, [])):
+            return False
+
+        for mark in matches:
+            if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()):
+                return True
 
-    def __call__(self, name: str) -> bool:
-        return name in self.own_mark_names
+        return False
 
 
 def deselect_by_mark(items: list[Item], config: Config) -> None:
@@ -240,7 +254,7 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
     remaining: list[Item] = []
     deselected: list[Item] = []
     for item in items:
-        if expr.evaluate(MarkMatcher.from_item(item)):
+        if expr.evaluate(MarkMatcher.from_markers(item.iter_markers())):
             remaining.append(item)
         else:
             deselected.append(item)
diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py
index e65b028589b..3f4071dcef8 100644
--- a/src/_pytest/mark/expression.py
+++ b/src/_pytest/mark/expression.py
@@ -5,7 +5,8 @@
 expression: expr? EOF
 expr:       and_expr ('or' and_expr)*
 and_expr:   not_expr ('and' not_expr)*
-not_expr:   'not' not_expr | '(' expr ')' | ident
+not_expr:   'not' not_expr | '(' expr ')' | ident ( '(' name '=' value ( ', ' name '=' value )*  ')')*
+
 ident:      (\w|:|\+|-|\.|\[|\]|\\|/)+
 
 The semantics are:
@@ -20,12 +21,15 @@
 import ast
 import dataclasses
 import enum
+import keyword
 import re
 import types
-from typing import Callable
 from typing import Iterator
+from typing import Literal
 from typing import Mapping
 from typing import NoReturn
+from typing import overload
+from typing import Protocol
 from typing import Sequence
 
 
@@ -43,6 +47,9 @@ class TokenType(enum.Enum):
     NOT = "not"
     IDENT = "identifier"
     EOF = "end of input"
+    EQUAL = "="
+    STRING = "str"
+    COMMA = ","
 
 
 @dataclasses.dataclass(frozen=True)
@@ -86,6 +93,27 @@ def lex(self, input: str) -> Iterator[Token]:
             elif input[pos] == ")":
                 yield Token(TokenType.RPAREN, ")", pos)
                 pos += 1
+            elif input[pos] == "=":
+                yield Token(TokenType.EQUAL, "=", pos)
+                pos += 1
+            elif input[pos] == ",":
+                yield Token(TokenType.COMMA, ",", pos)
+                pos += 1
+            elif (quote_char := input[pos]) in ("'", '"'):
+                end_quote_pos = input.find(quote_char, pos + 1)
+                if end_quote_pos == -1:
+                    raise ParseError(
+                        pos + 1,
+                        f'closing quote "{quote_char}" is missing',
+                    )
+                value = input[pos : end_quote_pos + 1]
+                if (backslash_pos := input.find("\\")) != -1:
+                    raise ParseError(
+                        backslash_pos + 1,
+                        r'escaping with "\" not supported in marker expression',
+                    )
+                yield Token(TokenType.STRING, value, pos)
+                pos += len(value)
             else:
                 match = re.match(r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+", input[pos:])
                 if match:
@@ -106,6 +134,14 @@ def lex(self, input: str) -> Iterator[Token]:
                     )
         yield Token(TokenType.EOF, "", pos)
 
+    @overload
+    def accept(self, type: TokenType, *, reject: Literal[True]) -> Token: ...
+
+    @overload
+    def accept(
+        self, type: TokenType, *, reject: Literal[False] = False
+    ) -> Token | None: ...
+
     def accept(self, type: TokenType, *, reject: bool = False) -> Token | None:
         if self.current.type is type:
             token = self.current
@@ -166,18 +202,87 @@ def not_expr(s: Scanner) -> ast.expr:
         return ret
     ident = s.accept(TokenType.IDENT)
     if ident:
-        return ast.Name(IDENT_PREFIX + ident.value, ast.Load())
+        name = ast.Name(IDENT_PREFIX + ident.value, ast.Load())
+        if s.accept(TokenType.LPAREN):
+            ret = ast.Call(func=name, args=[], keywords=all_kwargs(s))
+            s.accept(TokenType.RPAREN, reject=True)
+        else:
+            ret = name
+        return ret
+
     s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT))
 
 
-class MatcherAdapter(Mapping[str, bool]):
+BUILTIN_MATCHERS = {"True": True, "False": False, "None": None}
+
+
+def single_kwarg(s: Scanner) -> ast.keyword:
+    keyword_name = s.accept(TokenType.IDENT, reject=True)
+    if not keyword_name.value.isidentifier():
+        raise ParseError(
+            keyword_name.pos + 1,
+            f"not a valid python identifier {keyword_name.value}",
+        )
+    if keyword.iskeyword(keyword_name.value):
+        raise ParseError(
+            keyword_name.pos + 1,
+            f"unexpected reserved python keyword `{keyword_name.value}`",
+        )
+    s.accept(TokenType.EQUAL, reject=True)
+
+    if value_token := s.accept(TokenType.STRING):
+        value: str | int | bool | None = value_token.value[1:-1]  # strip quotes
+    else:
+        value_token = s.accept(TokenType.IDENT, reject=True)
+        if (
+            (number := value_token.value).isdigit()
+            or number.startswith("-")
+            and number[1:].isdigit()
+        ):
+            value = int(number)
+        elif value_token.value in BUILTIN_MATCHERS:
+            value = BUILTIN_MATCHERS[value_token.value]
+        else:
+            raise ParseError(
+                value_token.pos + 1,
+                f'unexpected character/s "{value_token.value}"',
+            )
+
+    ret = ast.keyword(keyword_name.value, ast.Constant(value))
+    return ret
+
+
+def all_kwargs(s: Scanner) -> list[ast.keyword]:
+    ret = [single_kwarg(s)]
+    while s.accept(TokenType.COMMA):
+        ret.append(single_kwarg(s))
+    return ret
+
+
+class MatcherCall(Protocol):
+    def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: ...
+
+
+@dataclasses.dataclass
+class MatcherNameAdapter:
+    matcher: MatcherCall
+    name: str
+
+    def __bool__(self) -> bool:
+        return self.matcher(self.name)
+
+    def __call__(self, **kwargs: str | int | bool | None) -> bool:
+        return self.matcher(self.name, **kwargs)
+
+
+class MatcherAdapter(Mapping[str, MatcherNameAdapter]):
     """Adapts a matcher function to a locals mapping as required by eval()."""
 
-    def __init__(self, matcher: Callable[[str], bool]) -> None:
+    def __init__(self, matcher: MatcherCall) -> None:
         self.matcher = matcher
 
-    def __getitem__(self, key: str) -> bool:
-        return self.matcher(key[len(IDENT_PREFIX) :])
+    def __getitem__(self, key: str) -> MatcherNameAdapter:
+        return MatcherNameAdapter(matcher=self.matcher, name=key[len(IDENT_PREFIX) :])
 
     def __iter__(self) -> Iterator[str]:
         raise NotImplementedError()
@@ -211,7 +316,7 @@ def compile(self, input: str) -> Expression:
         )
         return Expression(code)
 
-    def evaluate(self, matcher: Callable[[str], bool]) -> bool:
+    def evaluate(self, matcher: MatcherCall) -> bool:
         """Evaluate the match expression.
 
         :param matcher:
@@ -220,5 +325,5 @@ def evaluate(self, matcher: Callable[[str], bool]) -> bool:
 
         :returns: Whether the expression matches or not.
         """
-        ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher))
+        ret: bool = bool(eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)))
         return ret
diff --git a/testing/test_mark.py b/testing/test_mark.py
index 090e10ee9c4..6a94cc9f7c2 100644
--- a/testing/test_mark.py
+++ b/testing/test_mark.py
@@ -233,6 +233,54 @@ def test_two():
     assert passed_str == expected_passed
 
 
+@pytest.mark.parametrize(
+    ("expr", "expected_passed"),
+    [  # TODO: improve/sort out
+        ("car(color='red')", ["test_one"]),
+        ("car(color='red') or car(color='blue')", ["test_one", "test_two"]),
+        ("car and not car(temp=5)", ["test_one", "test_three"]),
+        ("car(temp=4)", ["test_one"]),
+        ("car(temp=4) or car(temp=5)", ["test_one", "test_two"]),
+        ("car(temp=4) and car(temp=5)", []),
+        ("car(temp=-5)", ["test_three"]),
+        ("car(ac=True)", ["test_one"]),
+        ("car(ac=False)", ["test_two"]),
+        ("car(ac=None)", ["test_three"]),  # test NOT_NONE_SENTINEL
+    ],
+    ids=str,
+)
+def test_mark_option_with_kwargs(
+    expr: str, expected_passed: list[str | None], pytester: Pytester
+) -> None:
+    pytester.makepyfile(
+        """
+        import pytest
+        @pytest.mark.car
+        @pytest.mark.car(ac=True)
+        @pytest.mark.car(temp=4)
+        @pytest.mark.car(color="red")
+        def test_one():
+            pass
+        @pytest.mark.car
+        @pytest.mark.car(ac=False)
+        @pytest.mark.car(temp=5)
+        @pytest.mark.car(color="blue")
+        def test_two():
+            pass
+        @pytest.mark.car
+        @pytest.mark.car(ac=None)
+        @pytest.mark.car(temp=-5)
+        def test_three():
+            pass
+
+    """
+    )
+    rec = pytester.inline_run("-m", expr)
+    passed, skipped, fail = rec.listoutcomes()
+    passed_str = [x.nodeid.split("::")[-1] for x in passed]
+    assert passed_str == expected_passed
+
+
 @pytest.mark.parametrize(
     ("expr", "expected_passed"),
     [("interface", ["test_interface"]), ("not interface", ["test_nointer"])],
@@ -372,6 +420,10 @@ def test_func(arg):
             "not or",
             "at column 5: expected not OR left parenthesis OR identifier; got or",
         ),
+        (
+            "nonexistent_mark(non_supported='kwarg')",
+            "Keyword expressions do not support call parameters",
+        ),
     ],
 )
 def test_keyword_option_wrong_arguments(
diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py
index 5bce004cb1c..c31ab44704c 100644
--- a/testing/test_mark_expression.py
+++ b/testing/test_mark_expression.py
@@ -1,14 +1,17 @@
 from __future__ import annotations
 
 from typing import Callable
+from typing import cast
 
+from _pytest.mark import MarkMatcher
 from _pytest.mark.expression import Expression
+from _pytest.mark.expression import MatcherCall
 from _pytest.mark.expression import ParseError
 import pytest
 
 
 def evaluate(input: str, matcher: Callable[[str], bool]) -> bool:
-    return Expression.compile(input).evaluate(matcher)
+    return Expression.compile(input).evaluate(cast(MatcherCall, matcher))
 
 
 def test_empty_is_false() -> None:
@@ -153,6 +156,8 @@ def test_syntax_errors(expr: str, column: int, message: str) -> None:
         "1234",
         "1234abcd",
         "1234and",
+        "1234or",
+        "1234not",
         "notandor",
         "not_and_or",
         "not[and]or",
@@ -195,3 +200,119 @@ def test_valid_idents(ident: str) -> None:
 def test_invalid_idents(ident: str) -> None:
     with pytest.raises(ParseError):
         evaluate(ident, lambda ident: True)
+
+
+@pytest.mark.parametrize(
+    "expr, expected_error_msg",
+    (
+        ("mark(True=False)", "unexpected reserved python keyword `True`"),
+        ("mark(def=False)", "unexpected reserved python keyword `def`"),
+        ("mark(class=False)", "unexpected reserved python keyword `class`"),
+        ("mark(if=False)", "unexpected reserved python keyword `if`"),
+        ("mark(else=False)", "unexpected reserved python keyword `else`"),
+        ("mark(valid=False, def=1)", "unexpected reserved python keyword `def`"),
+        ("mark(1)", "not a valid python identifier 1"),
+        ("mark(var:=False", "not a valid python identifier var:"),
+        ("mark(1=2)", "not a valid python identifier 1"),
+        ("mark(/=2)", "not a valid python identifier /"),
+        ("mark(var==", "expected identifier; got ="),
+        ("mark(var)", "expected =; got right parenthesis"),
+        ("mark(var=none)", 'unexpected character/s "none"'),
+        ("mark(var=1.1)", 'unexpected character/s "1.1"'),
+        ("mark(var=')", """closing quote "'" is missing"""),
+        ('mark(var=")', 'closing quote """ is missing'),
+        ("""mark(var="')""", 'closing quote """ is missing'),
+        ("""mark(var='")""", """closing quote "'" is missing"""),
+        (
+            r"mark(var='\hugo')",
+            r'escaping with "\\" not supported in marker expression',
+        ),
+        ("mark(empty_list=[])", r'unexpected character/s "\[\]"'),
+    ),
+)
+def test_invalid_kwarg_name_or_value(  # TODO: move to `test_syntax_errors` ?
+    expr: str, expected_error_msg: str, mark_matcher: MarkMatcher
+) -> None:
+    with pytest.raises(ParseError, match=expected_error_msg):
+        assert evaluate(expr, mark_matcher)
+
+
+@pytest.fixture(scope="session")
+def mark_matcher() -> MarkMatcher:
+    markers = [
+        pytest.mark.number_mark(a=1, b=2, c=3, d=999_999).mark,
+        pytest.mark.builtin_matchers_mark(x=True, y=False, z=None).mark,
+        pytest.mark.str_mark(
+            m="M", space="with space", empty="", aaאבגדcc="aaאבגדcc", אבגד="אבגד"
+        ).mark,
+    ]
+
+    return MarkMatcher.from_markers(markers)
+
+
+@pytest.mark.parametrize(
+    "expr, expected",
+    (
+        # happy cases
+        ("number_mark(a=1)", True),
+        ("number_mark(b=2)", True),
+        ("number_mark(a=1,b=2)", True),
+        ("number_mark(a=1,     b=2)", True),
+        ("number_mark(d=999999)", True),
+        ("number_mark(a   =   1,b= 2,     c = 3)", True),
+        # sad cases
+        ("number_mark(a=6)", False),
+        ("number_mark(b=6)", False),
+        ("number_mark(a=1,b=6)", False),
+        ("number_mark(a=6,b=2)", False),
+        ("number_mark(a   =   1,b= 2,     c = 6)", False),
+        ("number_mark(a='1')", False),
+    ),
+)
+def test_keyword_expressions_with_numbers(
+    expr: str, expected: bool, mark_matcher: MarkMatcher
+) -> None:
+    assert evaluate(expr, mark_matcher) is expected
+
+
+@pytest.mark.parametrize(
+    "expr, expected",
+    (
+        ("builtin_matchers_mark(x=True)", True),
+        ("builtin_matchers_mark(x=False)", False),
+        ("builtin_matchers_mark(y=True)", False),
+        ("builtin_matchers_mark(y=False)", True),
+        ("builtin_matchers_mark(z=None)", True),
+        ("builtin_matchers_mark(z=False)", False),
+        ("builtin_matchers_mark(z=True)", False),
+        ("builtin_matchers_mark(z=0)", False),
+        ("builtin_matchers_mark(z=1)", False),
+    ),
+)
+def test_builtin_matchers_keyword_expressions(  # TODO: naming when decided
+    expr: str, expected: bool, mark_matcher: MarkMatcher
+) -> None:
+    assert evaluate(expr, mark_matcher) is expected
+
+
+@pytest.mark.parametrize(
+    "expr, expected",
+    (
+        ("str_mark(m='M')", True),
+        ('str_mark(m="M")', True),
+        ("str_mark(aaאבגדcc='aaאבגדcc')", True),
+        ("str_mark(אבגד='אבגד')", True),
+        ("str_mark(space='with space')", True),
+        ("str_mark(empty='')", True),
+        ('str_mark(empty="")', True),
+        ("str_mark(m='wrong')", False),
+        ("str_mark(aaאבגדcc='wrong')", False),
+        ("str_mark(אבגד='wrong')", False),
+        ("str_mark(m='')", False),
+        ('str_mark(m="")', False),
+    ),
+)
+def test_str_keyword_expressions(
+    expr: str, expected: bool, mark_matcher: MarkMatcher
+) -> None:
+    assert evaluate(expr, mark_matcher) is expected