Skip to content

Instantly share code, notes, and snippets.

@Sachaa-Thanasius
Last active January 23, 2026 18:04
Show Gist options
  • Select an option

  • Save Sachaa-Thanasius/fa54f1b95639f2a9e410fae55b6b49a6 to your computer and use it in GitHub Desktop.

Select an option

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.
"""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
# 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