diff --git a/tests/helpers.py b/tests/helpers.py index 86e2d3a5..c6776d48 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,9 @@ -import functools import inspect from types import ModuleType from typing import Protocol, cast +from optype import CanBool, CanLt + def is_protocol(cls: type) -> bool: """Based on `typing_extensions.is_protocol`.""" @@ -19,7 +20,6 @@ def is_runtime_protocol(cls: type) -> bool: return is_protocol(cls) and getattr(cls, '_is_runtime_protocol', False) -@functools.cache def get_protocol_members(cls: type) -> frozenset[str]: """ A variant of `typing_extensions.get_protocol_members()` that doesn't @@ -106,3 +106,34 @@ def get_callable_members(module: ModuleType) -> frozenset[str]: and callable(cls := getattr(module, name)) and not is_protocol(cls) }) + + +def pascamel_to_snake( + pascamel: str, + start: CanLt[int, CanBool] = 0, + /, +) -> str: + """Converts 'CamelCase' or 'pascalCase' to 'snake_case'.""" + assert pascamel.isidentifier() + + snake = ''.join( + f'_{char}' if i > start and char.isupper() else char + for i, char in enumerate(pascamel) + ).lower() + assert snake.isidentifier() + assert snake[0] != '_' + assert snake[-1] != '_' + + return snake + + +def is_dunder(name: str, /) -> bool: + """Whether the name is a valid `__dunder_name__`.""" + return ( + len(name) > 4 # noqa: PLR2004 + and name.isidentifier() + and name.islower() + and name[:2] == name[-2:] == '__' + and name[2].isalpha() + and name[-3].isalpha() + ) diff --git a/tests/test_protocols.py b/tests/test_protocols.py index a7b3fe02..2fb2c1b9 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -9,8 +9,10 @@ get_callable_members, get_protocol_members, get_protocols, + is_dunder, is_protocol, is_runtime_protocol, + pascamel_to_snake, ) @@ -84,33 +86,34 @@ def test_name_matches_dunder(cls: type): assert members member_count = len(members) - super_count = sum(map(is_protocol, cls.mro()[1:-1])) + parents = list(filter(is_protocol, cls.mro()[1:])) + + # this test should probably be split up... if member_count > 1: - assert super_count == member_count - return - - # convert CamelCase to to snake_case (ignoring the first char, which - # could be an async (A), augmented (I), or reflected (R) binop name prefix) - member_expect = ''.join( - f'_{c}' if i > 1 and c.isupper() else c - for i, c in enumerate(name.removeprefix(prefix)) - ).lower() - # sanity checks (a bit out-of-scope, but humankind will probably survive) - assert member_expect.isidentifier() - assert '__' not in member_expect - assert member_expect[0] != '_' - assert member_expect[-1] != '_' - - # remove potential trailing arity digit - if member_expect[-1].isdigit(): - member_expect = member_expect[:-1] - # another misplaced check (ah well, let's hope the extinction event is fun) - assert not member_expect[-1].isdigit() - - member = next(iter(members)) - if member[:2] == member[-2:] == '__': - # add some thunder... or was is döner...? wait, no; dunder!. - member_expect = f'__{member_expect}__' - - assert member == member_expect + # ensure #parent protocols == #members (including inherited) + assert len(parents) == member_count + + members_concrete = set(members) + for parent in parents: + members_concrete.difference_update(get_protocol_members(parent)) + + assert not members_concrete + else: + # remove the `Can`, `Has`, or `Does` prefix + stem = name.removeprefix(prefix) + # strip the arity digit if exists + if stem[-1].isdigit(): + stem = stem[:-1] + assert stem[-1].isalpha() + + # the `1` arg ensures that any potential leading `A`, `I` or `R` chars + # won't have a `_` directly after (i.e. considers `stem[:2].lower()`). + member_predict = pascamel_to_snake(stem, 1) + member_expect = next(iter(members)) + + # prevent comparing apples with oranges: paint the apples orange! + if is_dunder(member_expect): + member_predict = f'__{member_predict}__' + + assert member_predict == member_expect