I think a decorator is a nice and expressive way to do this.
I'm sure my implementation could be improved upon, but it works, and I think it makes the usage very readable:
class MutuallyExclusiveArgsError(Exception):
    def __init__(self, groups):
        err = f"These groups or arguments are mutually exclusive: {','.join(str(tuple(g)) for g in groups)}"
        super().__init__(err)
def exclusive_args(*args):
    import attr
    import functools
    from typing import Callable,Set,Union,Iterable
    @attr.s
    class _inner:
        _arg_groups_conv = lambda val: {arg: group for group in {frozenset([s]) if isinstance(s, str) else s for s in val} for arg in group}
        func : Callable = attr.ib()
        arg_groups : Set[Union[str,Iterable]] = attr.ib(converter=_arg_groups_conv, kw_only=True)
        def __attrs_post_init_(self):
           functools.update_wrapper(self, self.func)
        def __call__(self, *args, **kwargs):
            groups = {self.arg_groups[kw] for kw in kwargs}
            if len(groups) > 1:
                raise MutuallyExclusiveArgsError(groups)
            self.func(*args, **kwargs)
    return functools.partial(_inner, arg_groups=args)
The usage then looks like this:
@exclusive_args("one", "two")
def ex(*, one=None, two=None):
    print(one or two)
ex(one=1, two=2)
---------------------------------------------------------------------------
MutuallyExclusiveArgsError                Traceback (most recent call last)
<ipython-input-38-0f1d142483d2> in <module>
----> 1 ex(one=1, two=2)
<ipython-input-36-c2ff5f47260f> in __call__(self, *args, **kwargs)
     21             groups = {self.arg_groups[kw] for kw in kwargs}
     22             if len(groups) > 1:
---> 23                 raise MutuallyExclusiveArgsError(groups)
     24             self.func(*args, **kwargs)
     25     return functools.partial(_inner, arg_groups=args)
MutuallyExclusiveArgsError: These groups or arguments are mutually exclusive: ('two',),('one',)
ex(one=1)
1
ex(two=2)
2
or like this:
@exclusive_args("one", ("two","three"))
def ex(*, one=None, two=None, three=None):
    print(one, two, three)
ex(one=1)
1 None None
ex(two=1)
None 1 None
ex(three=1)
None None 1
ex(two=1, three=2)
None 1 2
ex(one=1, two=2)
---------------------------------------------------------------------------
MutuallyExclusiveArgsError                Traceback (most recent call last)
<ipython-input-46-0f1d142483d2> in <module>
----> 1 ex(one=1, two=2)
<ipython-input-36-c2ff5f47260f> in __call__(self, *args, **kwargs)
     21             groups = {self.arg_groups[kw] for kw in kwargs}
     22             if len(groups) > 1:
---> 23                 raise MutuallyExclusiveArgsError(groups)
     24             self.func(*args, **kwargs)
     25     return functools.partial(_inner, arg_groups=args)
MutuallyExclusiveArgsError: These groups or arguments are mutually exclusive: ('one',),('two', 'three')
ex(one=1,three=3)
---------------------------------------------------------------------------
MutuallyExclusiveArgsError                Traceback (most recent call last)
<ipython-input-47-0dcb487cba71> in <module>
----> 1 ex(one=1,three=3)
<ipython-input-36-c2ff5f47260f> in __call__(self, *args, **kwargs)
     21             groups = {self.arg_groups[kw] for kw in kwargs}
     22             if len(groups) > 1:
---> 23                 raise MutuallyExclusiveArgsError(groups)
     24             self.func(*args, **kwargs)
     25     return functools.partial(_inner, arg_groups=args)
MutuallyExclusiveArgsError: These groups or arguments are mutually exclusive: ('one',),('two', 'three')