Heterogeneous, mutable, type-safe mapping
![](https://secure.gravatar.com/avatar/8e0c07a0830212891d6bd7becb324f39.jpg?s=120&d=mm&r=g)
This post doesn't have any particular aim, just a design report. I'm curious if anyone else had done this and has experience to share, or maybe there's a nicer solution for this in Python. ### Motivation In pytest we have plugin system where external plugins may implement various hooks. For the sake of example let's say a plugin wants to do something on each test ("item") setup and teardown: def pytest_runtest_setup(item: pytest.Item) -> None: ... def pytest_runtest_teardown(item: pytest.Item) -> None: ... One thing plugins often want to do is to store/associate some data with the item on setup, then use it on teardown. The traditional way plugins did this was to use an attribute on the `item`: def pytest_runtest_setup(item: pytest.Item) -> None: item.my_plugin_attr = MyPluginAttrType(...) def pytest_runtest_teardown(item: pytest.Item) -> None: use_my_attr(item.my_plugin_attr) # Maybe del item.my_plugin_attr The problem with this is that type checkers are not aware of `my_plugin_attr` and they complain. To make things clean for type checkers, and type safe (no casts/asserts) an alternative solution is needed. ### Rejected solution 1 We can add a dict to each item on the pytest side: class Item: def __init__(self, ...) -> None: ... self.store: Final[Dict[str, ???]] = {} which plugins will use as def pytest_runtest_setup(item: pytest.Item) -> None: item.store["my_plugin_attr"] = MyPluginAttrType(...) This fixes the "undefined attribute" issue, but as the `???` indicates, it's still not good. We can either use `object` and force casts/asserts, or `Any` and lose type safety. ### Rejected solution 2 We can imagine a typing feature which allows a 3rd-party to "overlay" a type it did not define with extra fields/methods. I think some languages support this (TypeScript?). But it does not seem like a desirable feature to me. ### Solution we ended up with We define a `Stash` type (because plugins "stash" their data there), which is used as follows: # In pytest class Item: def __init__(self, ...) -> None: ... self.stash: Final[Stash] = Stash() # In plugin my_plugin_attr: Final = pytest.StashKey[MyPluginAttrType]() def pytest_runtest_setup(item: pytest.Item) -> None: item.stash[my_plugin_attr] = MyPluginAttrType(...) def pytest_runtest_teardown(item: pytest.Item) -> None: use_my_attr(item.stash[my_plugin_attr]) # Maybe del item.stash[my_plugin_attr] Each plugin defines `StashKey`s for its attributes, and the stash key carries the attribute type which makes things type safe. The `Stash` is defined like this (see [0] for full implementation in pytest): T = TypeVar("T") D = TypeVar("D") class StashKey(Generic[T]): __slots__ = () class Stash: __slots__ = ("_storage",) def __init__(self) -> None: self._storage: Dict[StashKey[Any], object] = {} def __setitem__(self, key: StashKey[T], value: T) -> None: self._storage[key] = value def __getitem__(self, key: StashKey[T]) -> T: return cast(T, self._storage[key]) def __delitem__(self, key: StashKey[T]) -> None: del self._storage[key] # .. other mutable mapping methods -- same idea. [0] https://github.com/pytest-dev/pytest/blob/7.4.0/src/_pytest/stash.py ### Advantages No undefined attributes. Type safe. The `StashKey`s are namespaced objects, which makes conflicts between plugins impossible, unlike with string keys. ### Disadvantages Not standard, users are not familiar with idea and are confused, try to use it as a dict. Less ergonomic, need to define the `StashKey`s at the module level.
![](https://secure.gravatar.com/avatar/d995b462a98fea412efa79d17ba3787a.jpg?s=120&d=mm&r=g)
On Fri, 18 Aug 2023 at 08:35, Ran Benita via Typing-sig < typing-sig@python.org> wrote:
The traditional way plugins did this was to use an attribute on the `item`:
def pytest_runtest_setup(item: pytest.Item) -> None: item.my_plugin_attr = MyPluginAttrType(...)
def pytest_runtest_teardown(item: pytest.Item) -> None: use_my_attr(item.my_plugin_attr) # Maybe del item.my_plugin_attr
The problem with this is that type checkers are not aware of `my_plugin_attr` and they complain. To make things clean for type checkers, and type safe (no casts/asserts) an alternative solution is needed.
This sounds very much like `types.SimpleNamespace`. So maybe you could look at how typeshed annotates that type? Paul
![](https://secure.gravatar.com/avatar/674110aa302d25920561886d05f17153.jpg?s=120&d=mm&r=g)
typeshed uses Any: https://github.com/python/typeshed/blob/main/stdlib/types.pyi#L329-335 As noted in the PR, SimpleNamespace appears to need a plugin to understand specific types of the arguments/keys. It would be a good chance to raise an issue for mypy. On Fri, Aug 18, 2023, 05:08 Paul Moore <p.f.moore@gmail.com> wrote:
On Fri, 18 Aug 2023 at 08:35, Ran Benita via Typing-sig < typing-sig@python.org> wrote:
The traditional way plugins did this was to use an attribute on the `item`:
def pytest_runtest_setup(item: pytest.Item) -> None: item.my_plugin_attr = MyPluginAttrType(...)
def pytest_runtest_teardown(item: pytest.Item) -> None: use_my_attr(item.my_plugin_attr) # Maybe del item.my_plugin_attr
The problem with this is that type checkers are not aware of `my_plugin_attr` and they complain. To make things clean for type checkers, and type safe (no casts/asserts) an alternative solution is needed.
This sounds very much like `types.SimpleNamespace`. So maybe you could look at how typeshed annotates that type?
Paul _______________________________________________ Typing-sig mailing list -- typing-sig@python.org To unsubscribe send an email to typing-sig-leave@python.org
![](https://secure.gravatar.com/avatar/674110aa302d25920561886d05f17153.jpg?s=120&d=mm&r=g)
Oops, the PR link: https://github.com/python/typeshed/pull/2207 On Fri, Aug 18, 2023, 10:51 Zixuan James Li <zixuan.li@nyu.edu> wrote:
typeshed uses Any: https://github.com/python/typeshed/blob/main/stdlib/types.pyi#L329-335
As noted in the PR, SimpleNamespace appears to need a plugin to understand specific types of the arguments/keys. It would be a good chance to raise an issue for mypy.
On Fri, Aug 18, 2023, 05:08 Paul Moore <p.f.moore@gmail.com> wrote:
On Fri, 18 Aug 2023 at 08:35, Ran Benita via Typing-sig < typing-sig@python.org> wrote:
The traditional way plugins did this was to use an attribute on the `item`:
def pytest_runtest_setup(item: pytest.Item) -> None: item.my_plugin_attr = MyPluginAttrType(...)
def pytest_runtest_teardown(item: pytest.Item) -> None: use_my_attr(item.my_plugin_attr) # Maybe del item.my_plugin_attr
The problem with this is that type checkers are not aware of `my_plugin_attr` and they complain. To make things clean for type checkers, and type safe (no casts/asserts) an alternative solution is needed.
This sounds very much like `types.SimpleNamespace`. So maybe you could look at how typeshed annotates that type?
Paul _______________________________________________ Typing-sig mailing list -- typing-sig@python.org To unsubscribe send an email to typing-sig-leave@python.org
![](https://secure.gravatar.com/avatar/d995b462a98fea412efa79d17ba3787a.jpg?s=120&d=mm&r=g)
On Fri, 18 Aug 2023 at 15:58, Zixuan James Li <zixuan.li@nyu.edu> wrote:
Oops, the PR link: https://github.com/python/typeshed/pull/2207
On Fri, Aug 18, 2023, 10:51 Zixuan James Li <zixuan.li@nyu.edu> wrote:
typeshed uses Any: https://github.com/python/typeshed/blob/main/stdlib/types.pyi#L329-335
As noted in the PR, SimpleNamespace appears to need a plugin to understand specific types of the arguments/keys. It would be a good chance to raise an issue for mypy.
I thought the OP's point was that it's not possible to define a type that allows the user to assign arbitrary attributes - or rather that type checkers can't handle such a type. Given that SimpleNamespace is *exactly* such a type, I'd have hoped that making pytest.Item a subclass of SimpleNamespace would work. If it doesn't, then I would view that as something that type checkers should address, rather than as something that user code should be expected to work around. Paul
![](https://secure.gravatar.com/avatar/83614b355812c11ff3f99b0c78e55bf0.jpg?s=120&d=mm&r=g)
The point of type checkers is to identify potential runtime errors before they happen, no? It's of course easy to create an object that you can assign anything to: from typing import Any x: Any x.my_random_attr = "foo" # no complaint from type checker But then you might do this later: print(x.my_random_atr) # note that I misspelled it And because `x` is Any, the type checker can't catch the potential runtime error for you.
![](https://secure.gravatar.com/avatar/8e0c07a0830212891d6bd7becb324f39.jpg?s=120&d=mm&r=g)
On Fri, Aug 18, 2023, at 23:47, Thomas Kehrenberg wrote:
The point of type checkers is to identify potential runtime errors before they happen, no?
Exactly, as Thomas said, the problem with `SimpleNamespace` is that it is not type-safe. I can imagine `SimpleNamespace` being extended with special super-powers by type checkers, but I think at most it can become an "attribute TypedDict" sort of thing. That would require specifying all possible attributes in advance, so would not be suitable for the "pytest with unknown-in-advance plugins" scenario. Ran
![](https://secure.gravatar.com/avatar/129c316ce2aafb4ae9133c627ebb9dd5.jpg?s=120&d=mm&r=g)
I haven't tested it in-depth, but what about using a Protocol `ItemLike` that describes the Item interface as the argument annotation in `pytest_runtest_setup`? Then plugins could just create a protocol that includes `ItemLike` and the attributes they are setting themselves. from typing import Protocol from pytest import ItemLike class MyPluginItemLike(ItemLike, Protocol): plugin_attr: int # etc. def pytest_runtest_setup(item: MyPluginItemLike) -> None: item.plugin_attr = 1 def pytest_runtest_teardown(item: MyPluginItemLike) -> None: print(item.plugin_attr) ---- ORIGINAL MESSAGE ---- Date: 2023-08-18 09:34:11 UTC+0200 From:typing-sig@python.org To:typing-sig@python.org Subject: [Typing-sig] Heterogeneous, mutable, type-safe mapping
This post doesn't have any particular aim, just a design report. I'm curious if anyone else had done this and has experience to share, or maybe there's a nicer solution for this in Python.
### Motivation
In pytest we have plugin system where external plugins may implement various hooks. For the sake of example let's say a plugin wants to do something on each test ("item") setup and teardown:
def pytest_runtest_setup(item: pytest.Item) -> None: ... def pytest_runtest_teardown(item: pytest.Item) -> None: ...
One thing plugins often want to do is to store/associate some data with the item on setup, then use it on teardown.
The traditional way plugins did this was to use an attribute on the `item`:
def pytest_runtest_setup(item: pytest.Item) -> None: item.my_plugin_attr = MyPluginAttrType(...)
def pytest_runtest_teardown(item: pytest.Item) -> None: use_my_attr(item.my_plugin_attr) # Maybe del item.my_plugin_attr
The problem with this is that type checkers are not aware of `my_plugin_attr` and they complain. To make things clean for type checkers, and type safe (no casts/asserts) an alternative solution is needed.
### Rejected solution 1
We can add a dict to each item on the pytest side:
class Item: def __init__(self, ...) -> None: ... self.store: Final[Dict[str, ???]] = {}
which plugins will use as
def pytest_runtest_setup(item: pytest.Item) -> None: item.store["my_plugin_attr"] = MyPluginAttrType(...)
This fixes the "undefined attribute" issue, but as the `???` indicates, it's still not good. We can either use `object` and force casts/asserts, or `Any` and lose type safety.
### Rejected solution 2
We can imagine a typing feature which allows a 3rd-party to "overlay" a type it did not define with extra fields/methods. I think some languages support this (TypeScript?). But it does not seem like a desirable feature to me.
### Solution we ended up with
We define a `Stash` type (because plugins "stash" their data there), which is used as follows:
# In pytest
class Item: def __init__(self, ...) -> None: ... self.stash: Final[Stash] = Stash()
# In plugin
my_plugin_attr: Final = pytest.StashKey[MyPluginAttrType]()
def pytest_runtest_setup(item: pytest.Item) -> None: item.stash[my_plugin_attr] = MyPluginAttrType(...)
def pytest_runtest_teardown(item: pytest.Item) -> None: use_my_attr(item.stash[my_plugin_attr]) # Maybe del item.stash[my_plugin_attr]
Each plugin defines `StashKey`s for its attributes, and the stash key carries the attribute type which makes things type safe.
The `Stash` is defined like this (see [0] for full implementation in pytest):
T = TypeVar("T") D = TypeVar("D")
class StashKey(Generic[T]): __slots__ = ()
class Stash: __slots__ = ("_storage",)
def __init__(self) -> None: self._storage: Dict[StashKey[Any], object] = {}
def __setitem__(self, key: StashKey[T], value: T) -> None: self._storage[key] = value
def __getitem__(self, key: StashKey[T]) -> T: return cast(T, self._storage[key])
def __delitem__(self, key: StashKey[T]) -> None: del self._storage[key]
# .. other mutable mapping methods -- same idea.
[0] https://github.com/pytest-dev/pytest/blob/7.4.0/src/_pytest/stash.py
### Advantages
No undefined attributes. Type safe. The `StashKey`s are namespaced objects, which makes conflicts between plugins impossible, unlike with string keys.
### Disadvantages
Not standard, users are not familiar with idea and are confused, try to use it as a dict.
Less ergonomic, need to define the `StashKey`s at the module level.
_______________________________________________ Typing-sig mailing list --typing-sig@python.org To unsubscribe send an email totyping-sig-leave@python.org https://mail.python.org/mailman3/lists/typing-sig.python.org/ Member address:dev@reggx.eu
![](https://secure.gravatar.com/avatar/8e0c07a0830212891d6bd7becb324f39.jpg?s=120&d=mm&r=g)
On Fri, Aug 18, 2023, at 19:32, ReggX wrote:
I haven't tested it in-depth, but what about using a Protocol `ItemLike` that describes the Item interface as the argument annotation in `pytest_runtest_setup`?
Then plugins could just create a protocol that includes `ItemLike` and the attributes they are setting themselves.
Interesting, I think this is workable in principle. I think with intersection types it can be made a bit cleaner as well (not sure). Some issues I see with it: 1. Requires creating the `ItemLike` protocol. Of course we want the actual `Item` to remain a concrete nominal type so we would need to create a twin `ItemLike` protocol with the full `Item`'s interface. This is probably too much work. I can imagine a `ItemLike: TypeAlias = Protocolize[pytest.Item]` type operator which takes a nominal type and turns it into the "equivalent" structural type. I don't know if such an operator is possible, but it would make this viable in terms of maintenance at least. 2. In terms of type-checking, having the plugins do def pytest_runtest_setup(item: MyPluginItemLike) -> None: ... is not good, because this hook implementation function type is not a sub-type of the hook specification's function type (because parameters are contra-variant). Currently, pytest's plugin system (called pluggy) is unable to actually check this, but this is something we are planning to do using a mypy plugin or such. 3. I think this would confuse users even more than the `StashKey` thing :) Ran
![](https://secure.gravatar.com/avatar/83614b355812c11ff3f99b0c78e55bf0.jpg?s=120&d=mm&r=g)
Why not something like: from typing import Any, Generic, Mapping, TypeVar, TypedDict, cast T = TypeVar("T", bound=Mapping[str, Any]) class Item(Generic[T]): def __init__(self) -> None: self.stash: T = cast(T, {}) class MyStash(TypedDict): my_plugin_attr: int def pytest_runtest_setup(item: Item[MyStash]) -> None: item.stash["my_plugin_attr"] = 8 def pytest_runtest_teardown(item: Item[MyStash]) -> None: print(item.stash["my_plugin_attr"]) # Maybe del item.stash["my_plugin_attr"] # type error here because the key is required This works in both pyright and mypy.
![](https://secure.gravatar.com/avatar/674110aa302d25920561886d05f17153.jpg?s=120&d=mm&r=g)
Agreed on making the plugin define a TypedDict of their own as opposed to the monkey-patching approach. I guess an issue with the TypedDict approach is that we are potentially lying that all or some of the keys on the TypedDict are required. Can we improve on this approach without the cast? Perhaps we can advise plugins to set total=False when using it with Item[T], but there isn't a way to enforce that AFAIK? Still a disadvantage of this is that we lose the namespace isolation by sharing the same dict of string keys. On Fri, Aug 18, 2023 at 4:38 PM Thomas Kehrenberg <tmke8@posteo.net> wrote:
Why not something like:
from typing import Any, Generic, Mapping, TypeVar, TypedDict, cast
T = TypeVar("T", bound=Mapping[str, Any])
class Item(Generic[T]): def __init__(self) -> None: self.stash: T = cast(T, {})
class MyStash(TypedDict): my_plugin_attr: int
def pytest_runtest_setup(item: Item[MyStash]) -> None: item.stash["my_plugin_attr"] = 8
def pytest_runtest_teardown(item: Item[MyStash]) -> None: print(item.stash["my_plugin_attr"]) # Maybe del item.stash["my_plugin_attr"] # type error here because the key is required
This works in both pyright and mypy. _______________________________________________ Typing-sig mailing list -- typing-sig@python.org To unsubscribe send an email to typing-sig-leave@python.org
![](https://secure.gravatar.com/avatar/8e0c07a0830212891d6bd7becb324f39.jpg?s=120&d=mm&r=g)
On Fri, Aug 18, 2023, at 23:37, Thomas Kehrenberg wrote:
Why not something like:
from typing import Any, Generic, Mapping, TypeVar, TypedDict, cast
T = TypeVar("T", bound=Mapping[str, Any])
class Item(Generic[T]): def __init__(self) -> None: self.stash: T = cast(T, {})
class MyStash(TypedDict): my_plugin_attr: int
def pytest_runtest_setup(item: Item[MyStash]) -> None: item.stash["my_plugin_attr"] = 8
def pytest_runtest_teardown(item: Item[MyStash]) -> None: print(item.stash["my_plugin_attr"]) # Maybe del item.stash["my_plugin_attr"] # type error here because the key is required
This works in both pyright and mypy.
This is another interesting approach! My first reaction is that making the entire `Item` generic for this purpose is too heavy-handed; now it would show up in the docs, every function handling `Item` needs to become generic itself, every subclass would need to handle it, etc. That's why my inclination is to only use generics for primary concerns of the type (like container value types etc.) and not more marginal things like the "stash". I think type-parameter defaults can help with some of this, but not all. Another thing as Zixuan said, is that we lose the namespacing quality, but that's something I could live with. Ran
![](https://secure.gravatar.com/avatar/9f34b1346c40b11c3f177718b17ac256.jpg?s=120&d=mm&r=g)
I have a use case for this in a pluggable framework for registering dispatchable callbacks and exception handlers. Internally, these get stored in mappings that are from type to a callable that receives that type, ie exc_handlers.get(FailedAuthenticationException, None) # Callable[[Context, FailedAuthenticationException], None] | None Right now, this is typed as a Protocol with getitem defined, and setting items is just type ignored. The preferred solution is much as proposed, a special form in Collections that is just mapping, but defines the relation between key type and value type for heterogenous mappings, allowing it to be type safe still. The preferred expression of this would be something like: HeterogenousMutableMapping[KeyType, ValueType], where KeyType and ValueType *must* contain type variables and the same ones in each, this corresponding to what those would be in getitem This would in the above case have my definition for exception handlers look like the following: T = TypeVar("T", bound=Exception) exc_handlers: HeterogenousMutableMapping[type[T], Callable[[Context, T], None] ={}
participants (6)
-
Michael H
-
Paul Moore
-
Ran Benita
-
ReggX
-
Thomas Kehrenberg
-
Zixuan James Li