Skip to content

Commit

Permalink
Add TypedDoc and TypedMap typed containers
Browse files Browse the repository at this point in the history
  • Loading branch information
davidbrochart committed Jan 5, 2025
1 parent 9675eaf commit 8943169
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 51 deletions.
2 changes: 2 additions & 0 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
- TextEvent
- Transaction
- TransactionEvent
- TypedDoc
- TypedMap
- UndoManager
- XmlElement
- XmlFragment
Expand Down
80 changes: 33 additions & 47 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,58 +280,44 @@ array0.append("foo") # error: Argument 1 to "append" of "Array" has incompatibl
array1 = doc.get("array1", type=Array[str]) # error: Argument "type" to "get" of "Doc" has incompatible type "type[pycrdt._array.Array[Any]]"; expected "type[pycrdt._array.Array[int]]"
```

Trying to append a `str` will result in a type check error. Likewise if trying to get a root type of `Array[str]`.
Trying to append a `str` to an `Array[int]` will result in a type check error. Likewise if trying to get a root type of `Array[str]` from the `Doc[Array[int]]`.

Like an `Array`, a `Map` can be declared as uniform, i.e. with values of the same type. If one wants to associate types with specific keys, the `Map` can be cast to a [TypedDict](https://mypy.readthedocs.io/en/stable/typed_dict.html):
Like an `Array`, a `Map` can be declared as uniform, i.e. with values of the same type. For instance, if declared as `Map[int]`, then all values will be of type `int`. Likewise for a `Doc`: a `Doc[Map[int]]` will have root values of type `Map[int]`.

But if one wants to associate types with specific keys, a `TypedMap` can be used instead of a `Map`, and a `TypedDoc` can be used instead of a `Doc`:

```py
from typing import TypedDict, cast
from pycrdt import Array, Doc, Map
doc: Doc[Map] = Doc()
MyMap = TypedDict(
"MyMap",
{
"name": str,
"toggle": bool,
"nested": Array[bool],
},
)
map0 = cast(MyMap, doc.get("map0", type=Map))
map0["name"] = "foo"
map0["toggle"] = False
map0["toggle"] = 3 # error: Value of "toggle" has incompatible type "int"; expected "bool"
array0 = Array([1, 2, 3])
map0["nested"] = array0 # error: Value of "nested" has incompatible type "Array[int]"; expected "Array[bool]"
array1 = Array([False, True])
map0["nested"] = array1
v0: str = map0["name"]
v1: str = map0["toggle"] # error: Incompatible types in assignment (expression has type "bool", variable has type "str")
v2: bool = map0["toggle"]
map0["key0"] # error: TypedDict "MyMap" has no key "key0"
from pycrdt import Array, Text, TypedDoc, TypedMap
class MyMap(TypedMap):
name: str
toggle: bool
nested: Array[bool]
class MyDoc(TypedDoc):
map0: MyMap
array0: Array[int]
text0: Text
doc = MyDoc()
doc.map0.name = "foo"
doc.map0.toggle = False
doc.map0.toggle = 3 # error: Incompatible types in assignment (expression has type "int", variable has type "bool") [assignment]
doc.array0 = Array([1, 2, 3])
doc.map0.nested = Array([4]) # error: List item 0 has incompatible type "int"; expected "bool" [list-item]
doc.map0.nested = Array([False, True])
v0: str = doc.map0.name
v1: str = doc.map0.toggle # error: Incompatible types in assignment (expression has type "bool", variable has type "str") [assignment]
v2: bool = doc.map0.toggle
doc.map0.wrong_key0 # error: "MyMap" has no attribute "wrong_key0" [attr-defined]
```

Note however that this solution is not ideal, since a `Map` is not exactly a `dict`, although it behaves similarly. For instance, `map0` may need to be cast again to a `Map` if one wants to access specific methods.

Like a `Map`, a `Doc` can be declared as consisting of uniform root types, or as a `TypedDict`:
`TypedMap` and `TypedDoc` are special container types, i.e. they are not subclasses of `Map` and `Doc`, respectively. Instead, they *have* a `Map` and a `Doc`, respectively. Those can be accessed with the `_` property:

```py
from typing import TypedDict, cast
from pycrdt import Doc, Array, Text
MyDoc = TypedDict(
"MyDoc",
{
"text0": Text,
"array0": Array[int],
}
)
doc = cast(MyDoc, Doc())
doc["text0"] = Text()
doc["array0"] = Array[bool]() # error: Value of "array0" has incompatible type "Array[bool]"; expected "Array[int]"
doc["array0"] = Array[int]()
```
from pycrdt import Doc, Map
Here again, beware that a `Doc` has some similarities with a `dict`, but also differences. Cast `doc` back to a `Doc` when needed.
untyped_doc: Doc = doc._
untyped_map: Map = doc.map0._
```
2 changes: 2 additions & 0 deletions python/pycrdt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
from ._array import ArrayEvent as ArrayEvent
from ._awareness import Awareness as Awareness
from ._doc import Doc as Doc
from ._doc import TypedDoc as TypedDoc
from ._map import Map as Map
from ._map import MapEvent as MapEvent
from ._map import TypedMap as TypedMap
from ._pycrdt import StackItem as StackItem
from ._pycrdt import SubdocsEvent as SubdocsEvent
from ._pycrdt import Subscription as Subscription
Expand Down
46 changes: 45 additions & 1 deletion python/pycrdt/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from abc import ABC, abstractmethod
from functools import lru_cache, partial
from inspect import signature
from typing import TYPE_CHECKING, Any, Callable, Type, cast
from typing import TYPE_CHECKING, Any, Callable, Type, cast, get_type_hints

import anyio

Expand Down Expand Up @@ -253,3 +253,47 @@ def process_event(value: Any, doc: Doc) -> Any:
def count_parameters(func: Callable) -> int:
"""Count the number of parameters in a callable"""
return len(signature(func).parameters)


class Typed:
_: Any

def __init__(self) -> None:
annotation_lists = [
get_type_hints(class_) for class_ in type(self).mro() if class_ is not object
]
annotations = {
key: value
for annotations in annotation_lists
for key, value in annotations.items()
if key != "_"
}
self.__dict__["__annotations__"] = annotations

if not TYPE_CHECKING:

def __getattr__(self, key: str) -> Any:
annotations = self.__dict__["__annotations__"]
if key in annotations:
expected_type = annotations[key]
if Typed in expected_type.mro():
return expected_type(self.__dict__["_"][key])
return self.__dict__["_"][key]
raise AttributeError(f'"{type(self).mro()[0]}" has no attribute "{key}"')

def __setattr__(self, key: str, value: Any) -> None:
annotations = self.__dict__["__annotations__"]
if key in annotations:
expected_type = annotations[key]
if hasattr(expected_type, "__origin__"):
expected_type = expected_type.__origin__
if type(value) is not expected_type:
raise TypeError(
f'Incompatible types in assignment (expression has type "{expected_type}", '
f'variable has type "{type(value)}")'
)
if isinstance(value, Typed):
value = value._
self.__dict__["_"][key] = value
return
raise AttributeError(f'"{type(self).mro()[0]}" has no attribute "{key}"')
38 changes: 37 additions & 1 deletion python/pycrdt/_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Any, Callable, Generic, Iterable, Type, TypeVar, cast

from ._base import BaseDoc, BaseType, base_types, forbid_read_transaction
from ._base import BaseDoc, BaseType, Typed, base_types, forbid_read_transaction
from ._pycrdt import Doc as _Doc
from ._pycrdt import SubdocsEvent, Subscription, TransactionEvent
from ._pycrdt import Transaction as _Transaction
Expand Down Expand Up @@ -296,4 +296,40 @@ def unobserve(self, subscription: Subscription) -> None:
subscription.drop()


class TypedDoc(Typed):
"""
A container for a [Doc][pycrdt.Doc.__init__] where root shared values have types associated
with specific keys. The underlying `Doc` can be accessed with the special `_` attribute.
```py
from pycrdt import Array, Doc, Map, Text, TypedDoc
class MyDoc(TypedDoc):
map0: Map[int]
array0: Array[bool]
text0: Text
doc = MyDoc()
doc.map0["foo"] = 3
doc.array0.append(True)
doc.text0 += "Hello"
untyped_doc: Doc = doc._
```
"""

_: Doc

def __init__(self, doc: Doc | None = None) -> None:
super().__init__()
if doc is None:
doc = Doc()
self.__dict__["_"] = doc
for key, value in self.__dict__["__annotations__"].items():
root_type = value()
if isinstance(root_type, Typed):
root_type = root_type._
self.__dict__["_"][key] = root_type


base_types[_Doc] = Doc
38 changes: 37 additions & 1 deletion python/pycrdt/_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
overload,
)

from ._base import BaseDoc, BaseEvent, BaseType, base_types, event_types
from ._base import BaseDoc, BaseEvent, BaseType, Typed, base_types, event_types
from ._pycrdt import Map as _Map
from ._pycrdt import MapEvent as _MapEvent
from ._pycrdt import Subscription
Expand Down Expand Up @@ -315,6 +315,42 @@ def observe(self, callback: Callable[[MapEvent], None]) -> Subscription:
return super().observe(cast(Callable[[BaseEvent], None], callback))


class TypedMap(Typed):
"""
A container for a [Map][pycrdt.Map.__init__] where values have types associated with
specific keys. The underlying `Map` can be accessed with the special `_` attribute.
```py
from pycrdt import Array, Map, TypedDoc, TypedMap
class MyMap(TypedMap):
name: str
toggle: bool
nested: Array[bool]
class MyDoc(TypedDoc):
map0: MyMap
doc = MyDoc()
doc.map0.name = "John"
doc.map0.toggle = True
doc.map0.nested = Array([True, False])
print(doc.map0._.to_py())
# {'nested': [True, False], 'toggle': True, 'name': 'John'}
```
"""

_: Map

def __init__(self, map: Map | None = None) -> None:
super().__init__()
if map is None:
map = Map()
self.__dict__["_"] = map


class MapEvent(BaseEvent):
"""
A map change event.
Expand Down
67 changes: 67 additions & 0 deletions tests/test_typed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import pytest
from pycrdt import Array, Doc, Map, TypedDoc, TypedMap


def test_typed():
class MyTypedMap0(TypedMap):
k0: bool

class MyTypedMap1(TypedMap):
key0: str
key1: int
key2: MyTypedMap0
key3: Array[int]

class MySubTypedDoc(TypedDoc):
my_typed_map: MyTypedMap1

class MyTypedDoc(MySubTypedDoc):
my_array: Array[bool]

doc = Doc()
assert MyTypedDoc(doc)._ is doc

my_typed_doc = MyTypedDoc()
my_typed_doc.my_typed_map.key0 = "foo"

with pytest.raises(TypeError) as excinfo:
my_typed_doc.my_typed_map.key0 = 3
assert str(excinfo.value) == (
"Incompatible types in assignment (expression has type "
"\"<class 'str'>\", variable has type \"<class 'int'>\")"
)

my_typed_doc.my_typed_map.key1 = 123
my_typed_doc.my_typed_map.key2 = MyTypedMap0()
my_typed_doc.my_typed_map.key2.k0 = False
my_typed_doc.my_typed_map.key3 = Array([1, 2, 3])

with pytest.raises(AttributeError) as excinfo:
my_typed_doc.my_typed_map.wrong_key = "foo"
assert (
str(excinfo.value)
== '"<class \'test_typed.test_typed.<locals>.MyTypedMap1\'>" has no attribute "wrong_key"'
)

with pytest.raises(AttributeError) as excinfo:
my_typed_doc.my_typed_map.wrong_key
assert (
str(excinfo.value)
== '"<class \'test_typed.test_typed.<locals>.MyTypedMap1\'>" has no attribute "wrong_key"'
)

my_typed_doc.my_array.append(True)

update = my_typed_doc._.get_update()

my_other_doc = Doc()
my_other_doc.apply_update(update)
my_map = my_other_doc.get("my_typed_map", type=Map)
assert my_map.to_py() == {
"key0": "foo",
"key1": 123.0,
"key2": {"k0": False},
"key3": [1.0, 2.0, 3.0],
}
my_array = my_other_doc.get("my_array", type=Array[bool])
assert my_array.to_py() == [True]
33 changes: 32 additions & 1 deletion tests/test_types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import TypedDict, cast

import pytest
from pycrdt import Array, Doc, Map, Text
from pycrdt import Array, Doc, Map, Text, TypedMap, TypedDoc


@pytest.mark.mypy_testing
Expand Down Expand Up @@ -84,3 +84,34 @@ def mypy_test_typed_doc():
doc["text0"] = Text()
doc["array0"] = Array[bool]() # E: Value of "array0" has incompatible type "Array[bool]"; expected "Array[int]"
doc["array0"] = Array()


@pytest.mark.mypy_testing
def mypy_test_typed():

class MyTypedMap0(TypedMap):
k0: bool

class MyTypedMap1(TypedMap):
key0: str
key1: int
key2: MyTypedMap0

class MySubTypedDoc(TypedDoc):
my_typed_map: MyTypedMap1

class MyTypedDoc(MySubTypedDoc):
my_array: Array[bool]

my_typed_doc = MyTypedDoc()
my_typed_doc.my_typed_map.key0 = "foo"
my_typed_doc.my_typed_map.key0 = 3 # E: Incompatible types in assignment (expression has type "int", variable has type "str")
my_typed_doc.my_typed_map.key1 = 123
my_typed_doc.my_typed_map.key1 = "bar" # E: Incompatible types in assignment (expression has type "str", variable has type "int")
my_typed_doc.my_typed_map.key2.k0 = 3 # E: Incompatible types in assignment (expression has type "int", variable has type "bool")
my_typed_doc.my_typed_map.key2.k0 = False
my_typed_doc.my_typed_map.key2.k1 # E: "MyTypedMap0" has no attribute "k1"
my_typed_doc.my_array.append(True)
my_typed_doc.my_array.append(2) # E: Argument 1 to "append" of "Array" has incompatible type "int"; expected "bool"
my_typed_doc.my_wrong_root # E: "MyTypedDoc" has no attribute "my_wrong_root"
my_typed_doc.my_typed_map.wrong_key # E: "MyTypedMap1" has no attribute "wrong_key"

0 comments on commit 8943169

Please sign in to comment.