Skip to content

Commit

Permalink
Add TypedArray (#213)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidbrochart authored Jan 8, 2025
1 parent 7db56e4 commit 0807a8a
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 63 deletions.
1 change: 1 addition & 0 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- TextEvent
- Transaction
- TransactionEvent
- TypedArray
- TypedDoc
- TypedMap
- UndoManager
Expand Down
1 change: 1 addition & 0 deletions python/pycrdt/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from ._array import Array as Array
from ._array import ArrayEvent as ArrayEvent
from ._array import TypedArray as TypedArray
from ._awareness import Awareness as Awareness
from ._doc import Doc as Doc
from ._doc import TypedDoc as TypedDoc
Expand Down
73 changes: 72 additions & 1 deletion python/pycrdt/_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, cast, 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 Array as _Array
from ._pycrdt import ArrayEvent as _ArrayEvent
from ._pycrdt import Subscription
Expand Down Expand Up @@ -408,5 +408,76 @@ def __next__(self) -> Any:
return res


class TypedArray(Typed, Generic[T]):
"""
A container for an [Array][pycrdt.Array.__init__] where values have types that can be
other typed containers, e.g. a [TypedMap][pycrdt.TypedMap]. The subclass of `TypedArray[T]`
must have a special `type: T` annotation where `T` is the same type.
The underlying `Array` can be accessed with the special `_` attribute.
```py
from pycrdt import Array, TypedArray, TypedDoc, TypedMap
class MyMap(TypedMap):
name: str
toggle: bool
nested: Array[bool]
class MyArray(TypedArray[MyMap]):
type: MyMap
class MyDoc(TypedDoc):
array0: MyArray
doc = MyDoc()
map0 = MyMap()
doc.array0.append(map0)
map0.name = "foo"
map0.toggle = True
map0.nested = Array([True, False])
print(doc.array0._.to_py())
# [{'name': 'foo', 'toggle': True, 'nested': [True, False]}]
print(doc.array0[0].name)
# foo
print(doc.array0[0].toggle)
# True
print(doc.array0[0].nested.to_py())
# [True, False]
```
"""

type: T
_: Array

def __init__(self, array: TypedArray | Array | None = None) -> None:
super().__init__()
if array is None:
array = Array()
elif isinstance(array, TypedArray):
array = array._
self._ = array
self.__dict__["type"] = self.__dict__["annotations"]["type"]

def __getitem__(self, key: int) -> T:
return self.__dict__["type"](self._[key])

def __setitem__(self, key: int, value: T) -> None:
item = value._ if isinstance(value, Typed) else value
self._[key] = item

def append(self, value: T) -> None:
item = value._ if isinstance(value, Typed) else value
self._.append(item)

def extend(self, value: list[T]) -> None:
items = [item._ if isinstance(item, Typed) else item for item in value]
self._.extend(items)

def __len__(self) -> int:
return len(self._)


base_types[_Array] = Array
event_types[_ArrayEvent] = ArrayEvent
61 changes: 31 additions & 30 deletions python/pycrdt/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,41 +259,42 @@ 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"] = {
name: _type
for name, _type in get_type_hints(type(self).mro()[0]).items()
if name != "_"
}
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}"')
annotations = self.__dict__["annotations"]
if key not in annotations:
raise AttributeError(f'"{type(self).mro()[0]}" has no attribute "{key}"')
expected_type = annotations[key]
if hasattr(expected_type, "mro") and Typed in expected_type.mro():
return expected_type(self._[key])
return self._[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
if key == "_":
self.__dict__["_"] = value
return
raise AttributeError(f'"{type(self).mro()[0]}" has no attribute "{key}"')
annotations = self.__dict__["annotations"]
if key not in annotations:
raise AttributeError(f'"{type(self).mro()[0]}" has no attribute "{key}"')
expected_type = annotations[key]
if hasattr(expected_type, "__origin__"):
expected_type = expected_type.__origin__
if hasattr(expected_type, "__args__"):
expected_types = expected_type.__args__
else:
expected_types = (expected_type,)
if type(value) not in expected_types:
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._[key] = value
13 changes: 8 additions & 5 deletions python/pycrdt/_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,16 +320,19 @@ class MyDoc(TypedDoc):

_: Doc

def __init__(self, doc: Doc | None = None) -> None:
def __init__(self, doc: TypedDoc | 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()
elif isinstance(doc, TypedDoc):
doc = doc._
assert isinstance(doc, Doc)
self._ = doc
for name, _type in self.__dict__["annotations"].items():
root_type = _type()
if isinstance(root_type, Typed):
root_type = root_type._
self.__dict__["_"][key] = root_type
doc[name] = root_type


base_types[_Doc] = Doc
6 changes: 4 additions & 2 deletions python/pycrdt/_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,11 +344,13 @@ class MyDoc(TypedDoc):

_: Map

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


class MapEvent(BaseEvent):
Expand Down
86 changes: 64 additions & 22 deletions tests/test_typed.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,62 @@
from __future__ import annotations

import sys

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


def test_typed():
class MyTypedMap0(TypedMap):
k0: bool
def test_typed_init():
doc0 = Doc()

typed_doc0 = TypedDoc(doc0)
assert typed_doc0._ is doc0

typed_doc1 = TypedDoc(typed_doc0)
assert typed_doc1._ is doc0

array0 = doc0.get("array0", type=Array)
map0 = doc0.get("map0", type=Map)

typed_array0 = TypedArray(array0)
assert typed_array0._ is array0

typed_array1 = TypedArray(typed_array0)
assert typed_array1._ is array0

typed_map0 = TypedMap(map0)
assert typed_map0._ is map0

typed_map1 = TypedMap(typed_map0)
assert typed_map1._ is map0


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

class MySubTypedDoc(TypedDoc):
my_typed_map: MyTypedMap1

class MyTypedDoc(MySubTypedDoc):
my_array: Array[bool]
class MyTypedMap0(TypedMap):
k0: bool


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


class MySubTypedDoc(TypedDoc):
my_typed_map: MyTypedMap1


class MyTypedDoc(MySubTypedDoc):
my_array: MyTypedArray


@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires python3.10 or higher")
def test_typed():
doc = Doc()
assert MyTypedDoc(doc)._ is doc

Expand All @@ -35,22 +74,24 @@ class MyTypedDoc(MySubTypedDoc):
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])
my_typed_doc.my_typed_map.key4 = "bar"
assert my_typed_doc.my_typed_map.key4 == "bar"

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"'
)
assert str(excinfo.value) == '"<class \'test_typed.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"'
)
assert str(excinfo.value) == '"<class \'test_typed.MyTypedMap1\'>" has no attribute "wrong_key"'

assert len(my_typed_doc.my_array) == 0
my_typed_doc.my_array.append(True)
assert len(my_typed_doc.my_array) == 1
assert my_typed_doc.my_array[0] is True
my_typed_doc.my_array[0] = False
assert my_typed_doc.my_array[0] is False
my_typed_doc.my_array.extend([True])

update = my_typed_doc._.get_update()

Expand All @@ -62,6 +103,7 @@ class MyTypedDoc(MySubTypedDoc):
"key1": 123.0,
"key2": {"k0": False},
"key3": [1.0, 2.0, 3.0],
"key4": "bar",
}
my_array = my_other_doc.get("my_array", type=Array[bool])
assert my_array.to_py() == [True]
assert my_array.to_py() == [False, True]
9 changes: 6 additions & 3 deletions 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, TypedMap, TypedDoc
from pycrdt import Array, Doc, Map, Text, TypedArray, TypedMap, TypedDoc


@pytest.mark.mypy_testing
Expand Down Expand Up @@ -89,6 +89,9 @@ def mypy_test_typed_doc():
@pytest.mark.mypy_testing
def mypy_test_typed():

class MyTypedArray(TypedArray[bool]):
type: bool

class MyTypedMap0(TypedMap):
k0: bool

Expand All @@ -101,7 +104,7 @@ class MySubTypedDoc(TypedDoc):
my_typed_map: MyTypedMap1

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

my_typed_doc = MyTypedDoc()
my_typed_doc.my_typed_map.key0 = "foo"
Expand All @@ -112,6 +115,6 @@ class MyTypedDoc(MySubTypedDoc):
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_array.append(2) # E: Argument 1 to "append" of "TypedArray" 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 0807a8a

Please sign in to comment.