Some Introspection with Python
The discord bot I am working on has a number of feature that can be configured separately and I wanted a way to display the configuration within discord. I ended up exploring some new-to-me areas of python to do this. I’m pretty pleased with how it turned out and want to share it with you!
I have made a runnable example of the code from this article available on, if you want to try it out.
To try it, you can run:
git clone https://codeberg.org/ginabythebay/examples.git
cd examples/annotations_and_newtype
uv run main.py
The library I’m using to interact with discord, discord.py. Discord uses Snowflakes to identify pretty much everything: users, channels, servers, roles. They are all over the place. discord.py treats them as python ints and at first I was just treating them as ints too and that was fine. Like this:
@dataclass(frozen=True)
class ExampleConfig:
channel: int
allowed_role: int
max_count: int
The problem I ran into was that I wanted to render things like
channels and roles differently. So, assuming ExampleConfig(17, 18, 19), then I want to want to render the fields like this:
| Field | Rendering |
|---|---|
| channel | <#17> |
| allowed_role | <@&18> |
| max_count | 19 |
The renderings shown above for channels and roles will be replaced by their current names and by clickable links. If you click on the channel, you jump to that channel. If you click on a role, you see a pop-up of with a count of the members who have that role and a scrollable list of those members. So it is definitely worthwhile to do the bit of extra work to render them properly. Note that the example rendering I show for roles above give the rendering I describe without pinging all of the members of the role.
At this point, I am up to 10 features (and therefore 10 dataclasses) and some of them have a number of fields. I wanted a mechanism to render these that would feel lightweight and maintainable.
I landed on this as the basic building block:
ChannelId = NewType("ChannelId", int)
RoleId = NewType("RoleId", int)
@dataclass(frozen=True)
class ExampleConfig:
channel: ChannelId
allowed_role: RoleId
max_count: int
These are basically just ints, with no new behaviour, but at runtime I can introspect a field to see if it is one of those types. While they don’t have any different runtime behaviour changes, these types are visible to a type checker. If you use one and it can warn you when you are passing an id for a role to a method that expects a channel for example. I think that is a nice extra benefit.
Here is some example code to produce different output depending on the field type:
type Formatter = Callable[[object], str]
def format_channel(channel_id: object) -> str:
return f"<#{channel_id}>"
def format_role(role_id: object) -> str:
return f"<@&{role_id}>"
def formatter_for(field_type: object) -> Formatter:
if field_type is ChannelId:
return format_channel
if field_type is RoleId:
return format_role
return str
Of course if you like, you could replace the if statements with a match statement or a dictionary lookup. I am using a dictionary lookup in my production code.
Finally, I wanted to attach a user-friendly name to my fields, and
landed on using Annotated, which looks like this:
@dataclass(frozen=True)
class ExampleConfig:
channel: Annotated[ChannelId, "channel"]
allowed_role: Annotated[RoleId, "allowed role"]
max_count: Annotated[int, "maximum count"]
These have more information about Annotated: Annotations Best
Practices and
typing - Support for type
hints.
Now I can pass one of these configuration objects off to some rendering code and it can figure it out from there. Here is the first part of an example I adapted for sending output to a terminal:
def to_text(cfg: object) -> str:
field_descriptors: dict[str, Annotated[object, ...]] = (
annotationlib.get_annotations(type(cfg))
)
lines = (
render_field(cfg, field_name, field_type)
for field_name, field_type in field_descriptors.items()
)
return "\n".join(lines)
The first thing we do is call get_annotations() to fetch a map that
goes from field name to information about the Annotation for that
field.
Then for each map entry, we call render_field() which looks like this:
def render_field(
cfg: object, field_name: str, field_info: Annotated[object, ...]
) -> str:
args: tuple[object, ...] = get_args(field_info)
base_type: object = args[0]
formatter = formatter_for(base_type)
field_text = formatter(getattr(cfg, field_name)) # pyright: ignore[reportAny]
return f"{args[1]}: {field_text}"
first we call get_args to crack open the Annotated entry. In our
case, it will return a tuple with 2 elements. The first element is the
type of the field and the second element is the user-friendly name
(this isn’t enforced by Annotated, it is just how I am using it
here). Most of the rest of the method should be self-exlanatory. The
pyright suppression is because getattr returns an ‘Any’ type.
While I could turn ChannelId and RoleId into actual classes and give
them format() methods, but I would rather not embed the rendering logic
directly in my model.
I could imagine doing the introspection once and then caching it for future runs but I don’t think it is likely to be a performance bottleneck for my use case.
Normally I’m not too excited about ‘magical’ instrospection, but I find this reasonably easy to reason about and it is better than the other options I could come up with to solve this problem.