Notes, often about Machining

I'm late to the party. Again

Some Introspection with Python

2026-07-23

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.