The Problem
We want our function to type hint such that only objects with a particular method e.g. .attack()
, can be received. Something like this:
def all_attack(units: Sequence(HasAttackMethod)):
One of the objects only has a .fire()
method. How do we make sure it can meet the requirement and be used in the same way as the other objects?
Extend the External Object – Make a Wrapper
Here we simply make a light wrapper around the object that doesn’t change the interface apart from adding the missing method. The new method is added to the object using setattr
and everything else is delegated to the original object.
Now we can supply the extended object and it will function as required. It will also meet the required type specification, meeting the Attacker Protocol by having an .attack()
method.
"""
OK - we have one weird external object - how do we handle it?
"""
from functools import partial
from typing import Protocol, Sequence
class Soldier:
def __init__(self, name):
self.name = name
def attack(self):
print(f"Soldier {self.name} swings their sword!")
class Archer:
def __init__(self, name):
self.name = name
def attack(self):
print(f"Archer {self.name} fires their arrow!")
class Catapult:
def __init__(self, name):
self.name = name
def fire(self): ## not the required name
print(f"Catapult {self.name} hurls their fireball!")
s1 = Soldier('Tim')
s2 = Soldier('Sal')
a1 = Archer('Cal')
c1 = Catapult('Mech')
class Extend():
def __init__(self, original, method_name, method):
self.original = original
## being a method self needs to come first
method_with_self_arg = partial(method, self=self)
## Add method e.g. .attack()
setattr(self, method_name, method_with_self_arg)
def __getattr__(self, name, *args, **kwargs):
"""
Delegate everything other than the added method
to the original object
"""
original_attr = self.original.__getattribute__(name)
if hasattr(original_attr, '__call__'): ## handle methods
return original_attr(*args, **kwargs)
else: ## handle ordinary attributes
return original_attr
def catapult_attack(self):
self.original.fire()
c1_extended = Extend(original=c1, method_name='attack',
method=catapult_attack)
class Attacker(Protocol):
def attack(self) -> None:
...
def all_attack(units: Sequence[Attacker]):
for unit in units:
unit.attack()
all_attack([s1, s2, a1, c1_extended])
print(c1_extended.name)
Making a Polymorpher
Another approach is to make a polymorpher function. This relies upon the classes pip package https://pypi.org/project/classes/. See also: https://classes.readthedocs.io/en/latest/pages/supports.html#regular-types
As at the time of writing there seem to be problems making this code pass mypy which defeats the purpose. The explanation seems to be some brittleness in the (very promising) classes
package but that is not fully confirmed.
For example, if you were making a game and you needed a function to only accept objects with an attack()
method you could do it like this:
def all_attack(units: Sequence[Supports[RequiredAttackType]]):
for unit in units:
unit.attack()
There are some nasty and opaque steps required to make this work. These can be concentrated in one function to make it easy to use this duck hinting approach. In the code below the nastiest part is in polymorpher()
. The function is included to show how it works and so others can potentially improve on it.
"""
Purpose: in a static-typing context,
how do we enable external objects
to comply with type requirements in a function?
E.g. all_attack(units: Sequence[RequiredType])
which only permits sequences of the required type.
That is no problem with Soldier and Archer
as they have attack methods.
But Catapult doesn't have an attack method.
How do we give it one without:
* changing the source code
* monkey patching
And how do we type hint (enforced by mypy) so that only objects which have the attack method can be received?
Basically it would be nice if we could add an attack method to
Catapult using a decorator e.g. like
@attack.instance(Catapult)
def _fn_catapult(catapult: Catapult):
print(f"Catapult {catapult.name} hurls their fireball!")
And, instead of saying which types are acceptable,
follow a duck typing approach and say
what behaviours are required e.g. must have an attack method. E.g.
def all_attack(units: Sequence[Supports[RequiredAttackType]]):
ā¦
To make that all work, we have made a nasty, opaque function (polymorpher) that gives us both parts needed - the decorator,
and the behaviour type.
"""
from typing import Protocol, Sequence
## pip installation required
from classes import AssociatedType, Supports, typeclass
## UNITS *********************************************************
class Soldier:
def __init__(self, name):
self.name = name
def attack(self):
print(f"Soldier {self.name} swings their sword!")
class Archer:
def __init__(self, name):
self.name = name
def attack(self):
print(f"Archer {self.name} fires their arrow!")
## assume this class defined an object we can't / shouldn't modify
class Catapult:
def __init__(self, name):
self.name = name
s1 = Soldier('Tim')
s2 = Soldier('Sal')
a1 = Archer('Cal')
c1 = Catapult('Mech')
## ATTACKING *******************************************************
Opaque, somewhat nasty function that makes it easy to make external objects polymorphic so they'll work with a function requiring
compliant objects
def polymorpher(*, method_name: str):
## want separate types depending on method_name
RequiredType = type(method_name, (AssociatedType, ), {})
@typeclass(RequiredType)
def fn(instance):
pass
def self2pass(self):
pass
RequiredProtocol = type(
f'{method_name}_protocol',
(Protocol, ),
{'_is_runtime_protocol': True,
method_name: self2pass,
})
@fn.instance(protocol=RequiredProtocol)
def _fn_run_method(obj: RequiredProtocol):
return getattr(obj, method_name)()
return fn, RequiredType
## method_name and returned fn don't have to share name
attack, RequiredAttackType = polymorpher(method_name='attack')
## now easy to make external objects polymorphic
so they'll work with a function requiring compliant objects
@attack.instance(Catapult)
def _fn_catapult(catapult: Catapult):
print(f"Catapult {catapult.name} hurls their fireball!")
def all_attack(units: Sequence[Supports[RequiredAttackType]]):
for unit in units:
attack(unit) ## note - not unit.attack()
## UNITs ATTACKing ****************************************************
units = [s1, s2, a1, c1]
all_attack(units)
Wrap Up
Type hinting and duck typing are both parts of modern Python and they need to play together well – ideally in a simpler and standardised way. The code above hopefully assists with that evolution.
See also https://www.daan.fyi/writings/python-protocols and https://medium.com/alan/python-typing-with-mypy-progressive-type-checking-on-a-large-code-base-74e13356bd3a
And a big thanks to Ben Denham for doing most of the heavy lifting with making the polymorpher (almost ;-)) work.