Constraining type vars by kind

I was trying to create an id, but I don’t think this is expressible in python’s type system right now. Before making any feature requests, I figured I’d ask if there’s something here that I’m missing.

Here’s what I’m doing

class Meta(type):
  def __new__(cls, name, bases, class_dict, prefix: str):
    cls.prefix = prefix
    return super().__new__(cls, name, bases, class_dict)

class Foo(metaclass=Meta, prefix="foo"):
  pass

@dataclass
class Id[T]:
  value: str
  
  # This doesn't work, just stating what I'd like to be able to do
  @classmethod
  def generate[U, type[U]: Meta](cls, meta_cls: type[U]) -> Id[U]:
    return cls(meta_cls.prefix)

The problem seems to be that I have no way of constraining the type of a type variable. Any ideas for doing it?

I think you are overcomplicating this. You want an instance of Meta, not the Meta class being passed in, since prefix gets set on the instance, so wrapping it in type again is not doing what you want.

class Meta(type):
  def __new__(cls, name, bases, class_dict, prefix: str):
    cls.prefix = prefix
    return super().__new__(cls, name, bases, class_dict)

class Foo(metaclass=Meta, prefix="foo"):
  pass

@dataclass
class Id[T]:
  value: str
  
  @classmethod
  def generate[U: Meta](cls, meta: U) -> Id[U]:
    return cls(meta.prefix)

For the specific api I’m trying to build, I don’t actually have an instance of the class. I do actually just want the type of the class. In fact, the way that I implemented this, only the class has access to the prefix and the length.

There a small bug here with the metaclass as well that I needed to fix so that the prefix is set on the class instead of the metaclass instance:

class _Unset:
  pass

_UNSET = _Unset

class Meta(type):
  def __new__(cls, name, bases, class_dict, prefix: str | _Unset = _UNSET):
    # conditional needed for dataclasses for some reason
    if not isinstance(prefix, _Unset):
      class_dict["__id_prefix__"] = prefix
    return super().__new__(cls, name, bases, class_dict)

The way that I handle generate is to simply use a cast. Not ideal, but it’s the best I can think of with my knowledge of python’s current feature set.

  @classmethod
  def generate[U](cls, meta_cls: type[U]) -> Id[U]:
    return cls(cast(Meta, meta_cls).prefix)

I really don’t understand how your API makes any sense, is it not the case that you want to pass Foo into generate?

It makes no sense to me to pass Meta into generate, since it doesn’t have the required attribute.

Foo is an instance of Meta, Meta is an instance of type[Meta].

Or is perhaps the part that’s bothering you, that you will end up with Id[type[Foo]] rather than Id[Foo]?

That part makes a lot more sense to me. You could probably use a callback protocol to achieve most of the effect, since you only seem to care about the attribute being there, not necessarily having access to the class itself.

But the best solution is probably to get rid of the metaclass entirely. You’re not doing anything that can’t be handled by __init_subclass__.

class Prefixed:
  def __init_subclass__(cls, prefix: str, **kwargs):
    super().__init_subclass__(**kwargs)
    cls.prefix = prefix

class Foo(Prefixed, prefix="foo"):
  pass

@dataclass
class Id[T]:
  value: str
  
  @classmethod
  def generate[U: Prefixed](cls, prefixed_cls: type[U]) -> Id[U]:
    return cls(prefixed_cls.prefix)

Yup, I didn’t want the extra type in the id. And yes, I did end up at using a class + inheritence instead of a metaclass myself, but my version was way more complicated. Specifically, I was using a function to generate the class. Your way is so much cleaner. Thanks. That helps a lot!