Last active
January 23, 2026 18:04
-
-
Save Sachaa-Thanasius/fa54f1b95639f2a9e410fae55b6b49a6 to your computer and use it in GitHub Desktop.
Python's Protocol class, if it were separated out from typing without bringing along all its dependencies.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """The implementation of typing.Protocol, but without top-level dependencies on typing and inspect.""" | |
| import abc | |
| from _typing import Generic | |
| # ============================================================================ | |
| # region -------- Adapted from typing -------- | |
| # ============================================================================ | |
| # ---------------------------------------------------------------------------- | |
| # region ---- Replacement for Generic.__init_subclass__ | |
| # | |
| # This should be upstreamed first, such that: | |
| # | |
| # a) Generic.__init_subclass__ does this | |
| # b) Generic implements this in C and/or imports it from an isolated, freezable | |
| # module that isn't typing. | |
| # | |
| # Note: Some of the logic might already be in C for types.GenericAlias? | |
| # ---------------------------------------------------------------------------- | |
| def _is_unpacked_typevartuple(x: object) -> bool: | |
| # Need to check 'is True' here | |
| # See: https://github.com/python/cpython/issues/137706 | |
| return (not isinstance(x, type)) and getattr(x, "__typing_is_unpacked_typevartuple__", False) is True | |
| # MODIFIED: | |
| # An adapted version of typing._collect_type_parameters to avoid depending on the typing module. The following changes | |
| # were made: | |
| # | |
| # - Substitute the isinstance check (typing._GenericAlias) with a check for .__origin__, which typing._GenericAlias and | |
| # types.GenericAlias both have. Hopefully not problematic. | |
| def _collect_type_parameters( | |
| args: list | tuple, | |
| *, | |
| enforce_default_ordering: bool = True, | |
| validate_all: bool = False, | |
| ) -> tuple: | |
| """Collect all type parameters in args | |
| in order of first appearance (lexicographic order). | |
| Having an explicit `Generic` or `Protocol` base class determines | |
| the exact parameter order. | |
| For example:: | |
| >>> P = ParamSpec('P') | |
| >>> T = TypeVar('T') | |
| >>> _collect_type_parameters((T, Callable[P, T])) | |
| (~T, ~P) | |
| >>> _collect_type_parameters((list[T], Generic[P, T])) | |
| (~P, ~T) | |
| """ | |
| # required type parameter cannot appear after parameter with default | |
| default_encountered = False | |
| # or after TypeVarTuple | |
| type_var_tuple_encountered = False | |
| parameters = [] | |
| for t in args: | |
| if isinstance(t, type): | |
| # We don't want __parameters__ descriptor of a bare Python class. | |
| pass | |
| elif isinstance(t, tuple): | |
| # `t` might be a tuple, when `ParamSpec` is substituted with | |
| # `[T, int]`, or `[int, *Ts]`, etc. | |
| for x in t: | |
| for collected in _collect_type_parameters([x]): | |
| if collected not in parameters: | |
| parameters.append(collected) | |
| elif hasattr(t, "__typing_subst__"): | |
| if t not in parameters: | |
| if enforce_default_ordering: | |
| if type_var_tuple_encountered and t.has_default(): | |
| raise TypeError("Type parameter with a default follows TypeVarTuple") | |
| if t.has_default(): | |
| default_encountered = True | |
| elif default_encountered: | |
| raise TypeError(f"Type parameter {t!r} without a default follows type parameter with a default") | |
| parameters.append(t) | |
| elif not validate_all and hasattr(t, "__origin__") and t.__origin__ in (Generic, Protocol): | |
| # If we see explicit `Generic[...]` or `Protocol[...]` base classes, | |
| # we need to just copy them as-is. | |
| # Unless `validate_all` is passed, in this case it means that | |
| # we are doing a validation of `Generic` subclasses, | |
| # then we collect all unique parameters to be able to inspect them. | |
| parameters = t.__parameters__ | |
| else: | |
| if _is_unpacked_typevartuple(t): | |
| type_var_tuple_encountered = True | |
| for x in getattr(t, "__parameters__", ()): | |
| if x not in parameters: | |
| parameters.append(x) | |
| return tuple(parameters) | |
| # MODIFIED: | |
| # A version of Generic.__init_subclass__ that's severed from the typing module. The following symbols were avoided | |
| # or vendored: | |
| # | |
| # - typing._TypedDictMeta | |
| # - Substitute the metaclass type check ("is") with a name check (.__name__ and "=="). | |
| # - Should be equivalent in hackiness to name-checking Protocol, which was being done already. | |
| # - typing._collect_type_parameters | |
| # - Vendor the function and a dependency, typing._is_unpacked_typevartuple. | |
| # - typing._GenericAlias | |
| # - Substitute the isinstance check (typing._GenericAlias) with a check for .__origin__, which typing._GenericAlias | |
| # and types.GenericAlias both have. Hopefully not problematic. | |
| def _generic_init_subclass(cls, *args, **kwargs) -> None: | |
| super(Generic, cls).__init_subclass__(*args, **kwargs) | |
| tvars = [] | |
| if "__orig_bases__" in cls.__dict__: | |
| error = Generic in cls.__orig_bases__ | |
| else: | |
| error = Generic in cls.__bases__ and cls.__name__ != "Protocol" and type(cls).__name__ != "_TypedDictMeta" | |
| if error: | |
| raise TypeError("Cannot inherit from plain Generic") | |
| if "__orig_bases__" in cls.__dict__: | |
| tvars = _collect_type_parameters(cls.__orig_bases__, validate_all=True) | |
| # Look for Generic[T1, ..., Tn]. | |
| # If found, tvars must be a subset of it. | |
| # If not found, tvars is it. | |
| # Also check for and reject plain Generic, | |
| # and reject multiple Generic[...]. | |
| gvars = None | |
| basename = None | |
| for base in cls.__orig_bases__: | |
| if hasattr(base, "__origin__") and base.__origin__ in (Generic, Protocol): | |
| if gvars is not None: | |
| raise TypeError("Cannot inherit from Generic[...] multiple times.") | |
| gvars = base.__parameters__ | |
| basename = base.__origin__.__name__ | |
| if gvars is not None: | |
| tvarset = set(tvars) | |
| gvarset = set(gvars) | |
| if not tvarset <= gvarset: | |
| s_vars = ", ".join(str(t) for t in tvars if t not in gvarset) | |
| s_args = ", ".join(str(g) for g in gvars) | |
| raise TypeError(f"Some type variables ({s_vars}) are not listed in {basename}[{s_args}]") | |
| tvars = gvars | |
| cls.__parameters__ = tuple(tvars) | |
| Generic.__init_subclass__ = classmethod(_generic_init_subclass) | |
| # endregion | |
| _TYPING_INTERNALS = frozenset( | |
| { | |
| "__parameters__", | |
| "__orig_bases__", | |
| "__orig_class__", | |
| "_is_protocol", | |
| "_is_runtime_protocol", | |
| "__protocol_attrs__", | |
| "__non_callable_proto_members__", | |
| "__type_params__", | |
| }, | |
| ) | |
| _SPECIAL_NAMES = frozenset( | |
| { | |
| "__abstractmethods__", | |
| "__annotations__", | |
| "__dict__", | |
| "__doc__", | |
| "__init__", | |
| "__module__", | |
| "__new__", | |
| "__slots__", | |
| "__subclasshook__", | |
| "__weakref__", | |
| "__class_getitem__", | |
| "__match_args__", | |
| "__static_attributes__", | |
| "__firstlineno__", | |
| "__annotate__", | |
| "__annotate_func__", | |
| "__annotations_cache__", | |
| }, | |
| ) | |
| # These special attributes will be not collected as protocol members. | |
| EXCLUDED_ATTRIBUTES = _TYPING_INTERNALS | _SPECIAL_NAMES | {"_MutableMapping__marker"} | |
| # These abcs are considered protocols when mixed in with actual protocols. | |
| _PROTO_ALLOWLIST = { | |
| "collections.abc": [ | |
| "Callable", | |
| "Awaitable", | |
| "Iterable", | |
| "Iterator", | |
| "AsyncIterable", | |
| "AsyncIterator", | |
| "Hashable", | |
| "Sized", | |
| "Container", | |
| "Collection", | |
| "Reversible", | |
| "Buffer", | |
| ], | |
| "contextlib": ["AbstractContextManager", "AbstractAsyncContextManager"], | |
| "io": ["Reader", "Writer"], | |
| "os": ["PathLike"], | |
| } | |
| def _caller(depth: int = 1, default: str = "__main__") -> str | None: | |
| # PYUPDATE(3.15): Switch to top-level "lazy import". | |
| import sys | |
| try: | |
| return sys._getframemodulename(depth + 1) or default | |
| except AttributeError: # For platforms without _getframemodulename() | |
| pass | |
| try: | |
| return sys._getframe(depth + 1).f_globals.get("__name__", default) | |
| except (AttributeError, ValueError): # For platforms without _getframe() | |
| pass | |
| return None | |
| def _allow_reckless_class_checks(depth: int = 2) -> bool: | |
| """Allow instance and class checks for special stdlib modules. | |
| The abc and functools modules indiscriminately call isinstance() and | |
| issubclass() on the whole MRO of a user class, which may contain protocols. | |
| """ | |
| # gh-136047: When `_abc` module is not available, `_py_abc` is required to | |
| # allow `_py_abc.ABCMeta` fallback. | |
| return _caller(depth) in {"abc", "_py_abc", "functools", None} | |
| # Preload these once, as globals, as a micro-optimisation. | |
| # This makes a significant difference to the time it takes | |
| # to do `isinstance()`/`issubclass()` checks | |
| # against runtime-checkable protocols with only one callable member. | |
| _abc_instancecheck = abc.ABCMeta.__instancecheck__ | |
| _abc_subclasscheck = abc.ABCMeta.__subclasscheck__ | |
| def _type_check_issubclass_arg_1(arg: object) -> None: | |
| """Raise TypeError if `arg` is not an instance of `type` | |
| in `issubclass(arg, <protocol>)`. | |
| In most cases, this is verified by type.__subclasscheck__. | |
| Checking it again unnecessarily would slow down issubclass() checks, | |
| so, we don't perform this check unless we absolutely have to. | |
| For various error paths, however, | |
| we want to ensure that *this* error message is shown to the user | |
| where relevant, rather than a typing.py-specific error message. | |
| """ | |
| if not isinstance(arg, type): | |
| # Same error message as for issubclass(1, int). | |
| raise TypeError("issubclass() arg 1 must be a class") | |
| # MODIFIED: | |
| # The logic in the original metaclass __new__ and __init__ was moved to __init_subclass__ to avoid depending on | |
| # the metaclass for more than is necessary. Also makes things a bit cleaner, imo. | |
| class _ProtocolMeta(abc.ABCMeta): | |
| # This metaclass is somewhat unfortunate, | |
| # but is necessary for several reasons... | |
| def __subclasscheck__(cls, other: type) -> bool: | |
| if cls is Protocol: | |
| return type.__subclasscheck__(cls, other) | |
| if getattr(cls, "_is_protocol", False) and not _allow_reckless_class_checks(): | |
| if not getattr(cls, "_is_runtime_protocol", False): | |
| _type_check_issubclass_arg_1(other) | |
| raise TypeError("Instance and class checks can only be used with @runtime_checkable protocols") | |
| # this attribute is set by @runtime_checkable: | |
| if cls.__non_callable_proto_members__ and cls.__dict__.get("__subclasshook__") is _proto_hook: | |
| _type_check_issubclass_arg_1(other) | |
| non_method_attrs: list[str] = sorted(cls.__non_callable_proto_members__) | |
| raise TypeError( | |
| "Protocols with non-method members don't support issubclass(). " | |
| f"Non-method members: {str(non_method_attrs)[1:-1]}." | |
| ) | |
| return _abc_subclasscheck(cls, other) | |
| def __instancecheck__(cls, instance: object) -> bool: | |
| # We need this method for situations where attributes are | |
| # assigned in __init__. | |
| if cls is Protocol: | |
| return type.__instancecheck__(cls, instance) | |
| if not getattr(cls, "_is_protocol", False): | |
| # i.e., it's a concrete subclass of a protocol | |
| return _abc_instancecheck(cls, instance) | |
| if not getattr(cls, "_is_runtime_protocol", False) and not _allow_reckless_class_checks(): | |
| raise TypeError("Instance and class checks can only be used with @runtime_checkable protocols") | |
| if _abc_instancecheck(cls, instance): | |
| return True | |
| # PYUPDATE(3.15): Switch to top-level "lazy import". | |
| import inspect | |
| for attr in cls.__protocol_attrs__: | |
| try: | |
| val = inspect.getattr_static(instance, attr) | |
| except AttributeError: | |
| return False | |
| # this attribute is set by @runtime_checkable: | |
| if (val is None) and (attr not in cls.__non_callable_proto_members__): | |
| return False | |
| return True | |
| @classmethod | |
| def _proto_hook(cls, other: type) -> bool: | |
| if not cls.__dict__.get("_is_protocol", False): | |
| return NotImplemented | |
| for attr in cls.__protocol_attrs__: | |
| for base in other.__mro__: | |
| # Check if the members appear: | |
| # 1. In the class dictionary. | |
| if attr in base.__dict__: | |
| if base.__dict__[attr] is None: | |
| return NotImplemented | |
| return True | |
| # 2. In annotations, if it is a sub-protocol. | |
| if issubclass(other, Generic) and getattr(other, "_is_protocol", False): | |
| # We avoid the slower path through annotationlib here because in most | |
| # cases it should be unnecessary. | |
| try: | |
| annos = base.__annotations__ | |
| except Exception: | |
| # PYUPDATE(3.15): Switch to top-level "lazy import". | |
| import annotationlib | |
| annos = annotationlib.get_annotations(base, format=annotationlib.Format.FORWARDREF) | |
| if attr in annos: | |
| return True | |
| return NotImplemented | |
| return True | |
| def _no_init_or_replace_init(self, *args, **kwargs) -> None: | |
| cls = type(self) | |
| if cls._is_protocol: | |
| raise TypeError("Protocols cannot be instantiated") | |
| # Already using a custom `__init__`. No need to calculate correct | |
| # `__init__` to call. This can lead to RecursionError. See bpo-45121. | |
| if cls.__init__ is not _no_init_or_replace_init: | |
| return | |
| # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. | |
| # The first instantiation of the subclass will call `_no_init_or_replace_init` which | |
| # searches for a proper new `__init__` in the MRO. The new `__init__` | |
| # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent | |
| # instantiation of the protocol subclass will thus use the new | |
| # `__init__` and no longer call `_no_init_or_replace_init`. | |
| for base in cls.__mro__: | |
| init = base.__dict__.get("__init__", _no_init_or_replace_init) | |
| if init is not _no_init_or_replace_init: | |
| cls.__init__ = init | |
| break | |
| else: | |
| # should not happen | |
| cls.__init__ = object.__init__ | |
| cls.__init__(self, *args, **kwargs) | |
| def _get_protocol_attrs(cls: type) -> set[str]: | |
| """Collect protocol members from a protocol class objects. | |
| This includes names actually defined in the class dictionary, as well | |
| as names that appear in annotations. Special names are skipped. | |
| """ | |
| attrs: set[str] = set() | |
| for base in cls.__mro__[:-1]: # without object | |
| if base is Protocol or base is Generic: | |
| continue | |
| try: | |
| annotations = base.__annotations__ | |
| except Exception: | |
| # Only go through annotationlib to handle deferred annotations if we need to | |
| # PYUPDATE(3.15): Switch to top-level "lazy import". | |
| import annotationlib | |
| annotations = annotationlib.get_annotations(base, format=annotationlib.Format.FORWARDREF) | |
| for attr in (*base.__dict__, *annotations): | |
| if not attr.startswith("_abc_") and attr not in EXCLUDED_ATTRIBUTES: | |
| attrs.add(attr) | |
| return attrs | |
| # MODIFIED: | |
| # Initialization logic and class variable assignment was moved here from the original metaclass __new__ and __init__ | |
| # to avoid depending on the metaclass for more than is necessary. Also makes things a bit cleaner, imo. | |
| class Protocol(Generic, metaclass=_ProtocolMeta): | |
| __slots__ = () | |
| _is_protocol: bool = True | |
| _is_runtime_protocol: bool = False | |
| __protocol_attrs__: set[str] = set() | |
| def __init_subclass__(cls, *args, **kwargs) -> None: | |
| # Ensure the bases are all "protocols". | |
| for base in cls.__bases__: | |
| if not ( | |
| ((base is object) or (base is Generic)) | |
| or base.__name__ in _PROTO_ALLOWLIST.get(base.__module__, []) | |
| or (issubclass(base, Generic) and getattr(base, "_is_protocol", False)) | |
| ): | |
| raise TypeError(f"Protocols can only inherit from other protocols, got {base!r}") | |
| # Ensure Generic.__init_subclass__() is called. | |
| super().__init_subclass__(*args, **kwargs) | |
| # Determine if this is a protocol or a concrete subclass. | |
| if not cls.__dict__.get("_is_protocol", False): | |
| cls._is_protocol = any(b is Protocol for b in cls.__bases__) | |
| # Set (or override) the protocol subclass hook. | |
| if "__subclasshook__" not in cls.__dict__: | |
| cls.__subclasshook__ = _proto_hook | |
| # Prohibit instantiation for protocol classes. | |
| if cls._is_protocol and cls.__init__ is Protocol.__init__: | |
| cls.__init__ = _no_init_or_replace_init | |
| # If this is a protocol, save its members. | |
| if getattr(cls, "_is_protocol", False): | |
| cls.__protocol_attrs__ = _get_protocol_attrs(cls) | |
| def runtime_checkable(cls: type) -> type: | |
| """Mark a protocol class as a runtime protocol. | |
| Such protocol can be used with isinstance() and issubclass(). | |
| Raise TypeError if applied to a non-protocol class. | |
| This allows a simple-minded structural check very similar to | |
| one trick ponies in collections.abc such as Iterable. | |
| For example:: | |
| @runtime_checkable | |
| class Closable(Protocol): | |
| def close(self): ... | |
| assert isinstance(open('/some/file'), Closable) | |
| Warning: this will check only the presence of the required methods, | |
| not their type signatures! | |
| """ | |
| if not issubclass(cls, Generic) or not getattr(cls, "_is_protocol", False): | |
| raise TypeError(f"@runtime_checkable can be only applied to protocol classes, got {cls!r}") | |
| cls._is_runtime_protocol = True | |
| # PEP 544 prohibits using issubclass() | |
| # with protocols that have non-method members. | |
| # See gh-113320 for why we compute this attribute here, | |
| # rather than in `_ProtocolMeta.__init__` | |
| cls.__non_callable_proto_members__ = set() | |
| for attr in cls.__protocol_attrs__: | |
| try: | |
| is_callable = callable(getattr(cls, attr, None)) | |
| except Exception as e: | |
| raise TypeError(f"Failed to determine whether protocol member {attr!r} is a method member") from e | |
| else: | |
| if not is_callable: | |
| cls.__non_callable_proto_members__.add(attr) | |
| return cls | |
| # endregion | |
| @runtime_checkable | |
| class SupportsBytes(Protocol): | |
| """An ABC with one abstract method __bytes__.""" | |
| __slots__ = () | |
| @abc.abstractmethod | |
| def __bytes__(self) -> bytes: | |
| pass |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # NOTE: The stuff always imported on Python startup has been removed from the output. | |
| # Actual timings aside, this does show that it's doable to make Protocol more independent, albeit hackily, | |
| # without first having to untangle the mess that is typing and _typing, GenericAlias and _GenericAlias, etc. | |
| $ python --version | |
| Python 3.14.2 | |
| $ python -m platform | |
| Windows-10-10.0.19044-SP0 | |
| $ python -X importtime -c "from typing import SupportsBytes" | |
| import time: self [us] | cumulative | imported package | |
| ... | |
| import time: 131 | 131 | itertools | |
| import time: 862 | 862 | keyword | |
| import time: 78 | 78 | _operator | |
| import time: 1046 | 1124 | operator | |
| import time: 1232 | 1232 | reprlib | |
| import time: 116 | 116 | _collections | |
| import time: 1939 | 5403 | collections | |
| import time: 936 | 936 | copyreg | |
| import time: 49 | 49 | _types | |
| import time: 973 | 1022 | types | |
| import time: 101 | 101 | _functools | |
| import time: 1425 | 2547 | functools | |
| import time: 41 | 41 | _typing | |
| import time: 2902 | 11826 | typing | |
| $ python -X importtime -c "from protocol import SupportsBytes" | |
| ... | |
| import time: 49 | 49 | _typing | |
| import time: 916 | 965 | protocol |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment