Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TypedDoc and TypedMap typed containers #211

Merged
merged 1 commit into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
Loading