Skip to content

Commit 3a69a87

Browse files
committed
Clean up EventBase internals
1 parent 35c0bdc commit 3a69a87

File tree

1 file changed

+79
-17
lines changed

1 file changed

+79
-17
lines changed

streamdeck/models/events/base.py

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
from __future__ import annotations
22

33
from abc import ABC
4-
from typing import TYPE_CHECKING, Any, ClassVar, Literal
4+
from typing import TYPE_CHECKING, Literal
5+
from weakref import WeakValueDictionary
56

67
from pydantic import BaseModel, ConfigDict, create_model
7-
from typing_extensions import LiteralString, override # noqa: UP035
8+
from typing_extensions import LiteralString, TypedDict, override # noqa: UP035
9+
10+
11+
if TYPE_CHECKING:
12+
from typing import Any, ClassVar
13+
814

915

1016
class ConfiguredBaseModel(BaseModel, ABC):
@@ -29,11 +35,24 @@ def model_dump_json(self, **kwargs: Any) -> str:
2935
return super().model_dump_json(**kwargs)
3036

3137

38+
class EventMetadataDict(TypedDict):
39+
"""Metadata for specialized EventBase submodels.
40+
41+
Similar to the __pydantic_generic_metadata__ attribute, but for use in the EventBase class——which isn't actually generic.
42+
"""
43+
origin: type[EventBase]
44+
"""Origin class of the specialized EventBase submodel."""
45+
args: tuple[str, ...]
46+
"""Event names for the specialized EventBase submodel."""
47+
48+
3249
if TYPE_CHECKING:
3350
# Because we can't override a BaseModel's metaclass __getitem__ method without angering Pydantic during runtime,
3451
# we define this stub here to satisfy static type checkers that introspect the metaclass method annotations
3552
# to determine expected types in the class subscriptions.
3653

54+
from collections.abc import Callable
55+
3756
from pydantic._internal._model_construction import ModelMetaclass # type: ignore[import]
3857

3958
class EventMeta(ModelMetaclass):
@@ -42,12 +61,32 @@ class EventMeta(ModelMetaclass):
4261
def __getitem__(cls, event_names: LiteralString | tuple[LiteralString, ...]) -> type[EventBase]: ...
4362

4463
class EventBase(BaseModel, metaclass=EventMeta):
45-
"""Base class for all event models."""
64+
"""Base class for all event models.
65+
66+
EventBase itself should not be instantiated, nor should it be subclassed directly.
67+
Instead, use a subscripted subclass of EventBase, e.g. `EventBase["eventName"]`, to subclass from.
68+
69+
Examples:
70+
```
71+
class KeyDown(EventBase["keyDown"]):
72+
# 'event' field's type annotation is internally set here as `Literal["keyDown"]`
73+
...
74+
75+
class TestEvent(EventBase["test", "testing"]):
76+
# 'event' field's type annotation is internally set here as `Literal["test", "testing"]`
77+
...
78+
79+
```
80+
"""
4681
event: LiteralString
4782
"""Name of the event used to identify what occurred.
4883
4984
Subclass models must define this field as a Literal type with the event name string that the model represents.
5085
"""
86+
__event_metadata__: ClassVar[EventMetadataDict]
87+
"""Metadata for specialized EventBase submodels."""
88+
__event_type__: ClassVar[Callable[[], type[object] | None]]
89+
"""Return the event type for the event model."""
5190

5291
@classmethod
5392
def get_model_event_names(cls) -> tuple[str, ...]:
@@ -56,9 +95,27 @@ def get_model_event_names(cls) -> tuple[str, ...]:
5695

5796
else:
5897
class EventBase(ConfiguredBaseModel, ABC):
59-
"""Base class for all event models."""
60-
_subtypes: ClassVar[dict[str, type[EventBase]]] = {}
61-
__args__: ClassVar[tuple[str, ...]]
98+
"""Base class for all event models.
99+
100+
EventBase itself should not be instantiated, nor should it be subclassed directly.
101+
Instead, use a subscripted subclass of EventBase, e.g. `EventBase["eventName"]`, to subclass from.
102+
103+
Examples:
104+
```
105+
class KeyDown(EventBase["keyDown"]):
106+
# 'event' field's type annotation is internally set here as `Literal["keyDown"]`
107+
...
108+
109+
class TestEvent(EventBase["test", "testing"]):
110+
# 'event' field's type annotation is internally set here as `Literal["test", "testing"]`
111+
...
112+
113+
```
114+
"""
115+
# A weak reference dictionary to store subscripted subclasses of EventBase. Weak references are used to minimize memory usage.
116+
_cached_specialized_submodels: ClassVar[WeakValueDictionary[str, type[EventBase]]] = WeakValueDictionary()
117+
__event_metadata__: ClassVar[EventMetadataDict]
118+
"""Metadata for specialized EventBase submodels."""
62119

63120
event: str
64121
"""Name of the event used to identify what occurred.
@@ -106,25 +163,30 @@ def __class_getitem__(cls: type[EventBase], event_names: LiteralString | tuple[L
106163
def __new_subscripted_base__(cls: type[EventBase], new_name: str, event_name_args: tuple[str, ...]) -> type[EventBase]:
107164
"""Dynamically create a new Singleton subclass of EventBase with the given event names for the event field.
108165
109-
Only create a new subscripted subclass if it doesn't already exist in the _subtypes dictionary, otherwise return the existing subclass.
166+
Only create a new subscripted subclass if it doesn't already exist in the _cached_specialized_submodels dictionary, otherwise return the existing subclass.
110167
The new subclasses created here will be ignored in the __init_subclass__ method.
111168
"""
112-
if new_name not in cls._subtypes:
113-
# Pass in the _is_base_subtype kwarg to __init_subclass__ to indicate that this is a base subtype of EventBase, and should be ignored.
114-
cls._subtypes[new_name] = create_model(
169+
if new_name not in cls._cached_specialized_submodels:
170+
# Make sure not to pass in a value `_cached_specialized_submodels` in the create_model() call, in order to avoid shadowing the class variable.
171+
cls._cached_specialized_submodels[new_name] = create_model(
115172
new_name,
116173
__base__=cls,
117-
__args__=(tuple[str, ...], event_name_args),
174+
__event_metadata__=(EventMetadataDict, {"origin": cls, "args": event_name_args}),
118175
event=(Literal[event_name_args], ...),
119-
__cls_kwargs__={"_is_base_subtype": True},
176+
__cls_kwargs__={"_is_specialized_base": True}, # This gets passed to __init_subclass__ as a kwarg to indicate that this is a specialized (subscripted) subclass of EventBase.
120177
)
121178

122-
return cls._subtypes[new_name]
179+
return cls._cached_specialized_submodels[new_name]
123180

124181
@classmethod
125-
def __init_subclass__(cls, _is_base_subtype: bool = False) -> None:
126-
"""Validate a child class of EventBase (not a subscripted base subclass) is subclassing from a subscripted EventBase."""
127-
if _is_base_subtype:
182+
def __init_subclass__(cls, _is_specialized_base: bool = False) -> None:
183+
"""Validate a child class of EventBase (not a subscripted base subclass) is subclassing from a subscripted EventBase.
184+
185+
Args:
186+
_is_specialized_base: Whether this is a specialized submodel of EventBase (i.e., a subscripted subclass).
187+
This should only be True for the subscripted subclasses created in __class_getitem__.
188+
"""
189+
if _is_specialized_base:
128190
# This is a subscripted subclass of EventBase, so we don't need to do anything.
129191
return
130192

@@ -147,4 +209,4 @@ def __event_type__(cls) -> type[object] | None:
147209
@classmethod
148210
def get_model_event_names(cls) -> tuple[str, ...]:
149211
"""Return the event names for the event model."""
150-
return cls.__args__
212+
return cls.__event_metadata__["args"]

0 commit comments

Comments
 (0)