Some Introspection with Python
The discord bot I am working on has a number of features 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, if you want to try it out.
This example is based on python 3.14. Earlier versions of python support similar functionality, through different APIs.
To try it, you can run:
git clone https://codeberg.org/ginabythebay/examples.git
cd examples/annotations_and_newtype
uv run main.py
Discord uses Snowflakes to identify pretty much everything: users, channels, servers, roles. They are all over the place. The library I’m using to interact with discord, 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 format things like
channels and roles differently. So, assuming ExampleConfig(17, 18, 19), then I want to format the fields like this:
| Field | Formatting | Example Discord Rendering |
|---|---|---|
| channel | <#17> |
|
| allowed_role | <@&18> |
|
| max_count | 19 | 19 |
The formats shown above for channels and roles will be rendered in
discord with their current names and will be clickable links. If
you click on the channel, you jump to that channel. If you click on a
role, you see a pop-up 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 format them properly. Note
that the example format I show for roles above gives the rendering I
describe without pinging all of the members of the role. A rendering
like <@18> would render the role the same way as above but would
additionally ping each member 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 behavior, 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 behavior changes, these types are visible to a type checker. If you use one, 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.
While I could turn ChannelId and RoleId into actual classes and give
them format() methods, I would rather not embed the formatting
logic directly in my model. The theoretical reason is that it is a bad
idea to embed view logic into model code. In practice, the
format_role() method would output different text depending on whether
I want discord to ping members with that role or not. As written
above, it will not ping them. Furthermore, it is likely I will also want to
format roles and channels for a browser and that will probably involve HTML
option and select elements.
This seems to have some similarity to the rust New Type Idiom. The type checking is the same, if you are using a type checker with python. The thing that seems to be different is the runtime introspection. I could be wrong about this part; I’m not very familiar with rust.
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 pages 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, 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: object) -> str:
args: tuple[object, ...] = get_args(field_info)
base_type: object = args[0]
formatter = formatter_for(base_type)
value = cast(object, getattr(cfg, field_name))
field_text = formatter(value)
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-explanatory.
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’ introspection, 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.
Thank you to Kevin Lynagh for reviewing an early version of this and offering feedback. This post is much better for it. All mistakes are mine.
You can comment on mastodon if you like!