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 TypedArray #213

Merged
merged 1 commit into from
Jan 8, 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
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"
Loading