python-attrs / attrs

Python Classes Without Boilerplate
https://www.attrs.org/
MIT License
5.19k stars 360 forks source link

Brainstorming: Pick types #1125

Open Tinche opened 1 year ago

Tinche commented 1 year ago

This is something for us to consider, and it's come up on the typing mailing list a few times.

A Pick type is a TypeScript thing. You take an existing type, and you create a new type from a subset of that type's fields. Essentially, you pick attributes from the original type.

Example from the TS docs:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

This might be useful in a bunch of contexts, like:

Why the additional complexity instead of just defining what you need from scratch? DRY I guess, and keeping the origin and pick models in sync automatically.

I think in runtime we can do this relatively easily. Typing-wise though it'll require a Mypy plugin change for sure.

If we do want to do this, what would the API look like?

@define
class Todo:
    title: str
    description: str
    completed: bool

from attrs import pick, fields as f

TodoPreview = pick(Todo, f(Todo).title, f(Todo).description)

# Alternatively
TodoPreview = pick(Todo, "title", "description")

# Alternatively. This syntax feels extensible.
TodoPreview = pick(Todo, title=True, description=True)

The last syntax feels like it could be extended into the Omit type (the opposite of pick - where you exclude types).

TodoPreview = pick(Todo, completed=False)

I think at runtime we'd just create a new type, inherinting from object, while copying over the attributes and their defaults. So TodoPreview is not a subclass or Todo, or vice versa. There should be some sort of link back to the origin class at runtime via a hidden attribute, but that's about it.

Thoughts?

hynek commented 1 year ago

It looks cute and easy enough to implement (basically just use make_class :)) however:

Typing-wise though it'll require a Mypy plugin change for sure.

...is unfortunate and I guess there's a chance of a snowball in hell for this to ever be supported by pyright/pylance?

Tinche commented 1 year ago

Since people have asked for it before, and if we make the feature good, maybe there's a path to standardization.

That said, I've done a little more research and ran into some complications.

TypeScript has a much easier time using structural subtyping. To remind you, structural subtyping is essentially matching not on the class itself but the types of its fields, and in Python this is done with protocols. The other kind of subtyping (the one Python uses mostly) is called nominal subtyping.

This is a problem because pick types go really well with structural subtyping, and less well with nominal subtyping. Maybe this is the reason no one has them in Python yet.

Let me give you a real-life example (from my codebases actually). Imagine you have a large user model in the database (~50 fields), but your handler requires only 5. You use a pick type to query the database.

user: UserPick = await find_one(pick(User, {"id", "username", "privilege", "outfit", "created_at"}), id=1)

So far so good. But now you have to actually use this data by giving it to some other functions. One of these functions wants pick(User, {"id", "username", "created_at"}) and the other wants pick(User, {"id", "privilege", "outfit"}), but you have none of these, you have pick(User, {"id", "username", "privilege", "outfit", "created_at"}), and that type isn't a superclass of the others.

Now, if we were using structural subtyping, it'd work, since structurally pick(User, {"id", "username", "privilege", "outfit", "created_at"}) is a superclass of the others, but nominally it isn't (and I think it'd be very hard to make it be).

Interestingly, other ways of doing structural subtyping in Python are TypedDicts and tuples. TypedDicts have some other issues though, and they're not in the spirit of attrs.

All of this is a type-checking issue, if you're not using type hints there's no problem. But the type-checking angle is a very important one.

I think one of the solutions would be to make picks behave like protocols in Mypy. Mypy already has support for protocols/structural subtyping, so maybe it's just twiddling some flags in the plugin.