Briefly: I find myself wanting something like NotRequired
for typing an attribute on a class that, when a user attempts to access it, throws AttributeError if it does not exist, and can be narrowed by hasattr
, similarly to how a NotRequired
field on a TypedDict throws KeyError
on access if it does not exist, and can be narrowed by in
.
Motivation
In stripe-python
, we have a class StripeObject
which is the parent class for all data objects in the library, that wrap JSON responses from the Stripe servers. StripeObject defines a custom __getattr__
such that accessing attributes obj.id
, obj.description
etc. throw an AttributeError
if the JSON from the server did not contain the corresponding field.
While I wouldn’t choose this design if I were writing stripe-python from scratch today, it seemed reasonable ten years ago when the library was initially written, and changing it would be too breaking to our existing users for us to consider.
We have some workarounds in mind (see the github issue), but we’d really like to be able to provide our users with types that describe this behavior as it is. I’m fairly sure that this is impossible with the Python type system as currently designed, and I figured I would describe the problem as food for thought if nothing else.
Example
Here’s a simplified code example, to demonstrate what we’re trying to do.
from typing import Dict, Any, TYPE_CHECKING, Optional, Self, TypedDict, NotRequired
class StripeObject(Dict[str, Any]):
if not TYPE_CHECKING:
def __getattr__(self, k):
try:
return self[k]
except KeyError as err:
raise AttributeError(*err.args)
@classmethod
def init_from_json(cls, json) -> Self:
obj = cls()
for k, v in json.items():
super(StripeObject, obj).__setattr__(k, v)
return obj
class Customer(StripeObject):
id: str
description: Optional[str] # Optional is a lie. We'd rather give this something like NotRequired.
def only_strings_allowed(x: str):
print(x)
cus = Customer.init_from_json({"id": "cus_xyz"})
if cus.description is not None: # Raises an attribute error
only_strings_allowed(cus.description)
if hasattr("description", cus): # It would be great if this narrowed the type
only_strings_allowed(cus.description)
Thoughts
One line of thought I explored – could I define our own narrowing function using TypeGuard
? For instance
def stripe_has_attr(obj) -> TypeGuard[???]:
# ...
if stripe_has_attr("description", cus):
only_strings_allowed(cus.description)
What would TypeGuard
narrow to? Some sort of generic protocol HasAttr[TAttrName]
? I think this doesn’t work out, because protocols have to define a fixed set of methods – you can’t have a “dynamic” protocol with a method named after a generic type variable like TAttrName
.
Questions
Anyway, my questions are:
- Can anybody think of a way to type this that I am just missing?
- If not, is extending Python’s type system in a way that would allow this something that would be considered?