syncwave 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: syncwave
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
5
5
  Keywords:
6
6
  Author: Lucas Iannitello
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "syncwave"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Lucas Iannitello", email = "lucas.iannitello@protonmail.com" }]
@@ -26,35 +26,47 @@ build-backend = "uv_build"
26
26
  [dependency-groups]
27
27
  dev = [
28
28
  "fastapi[standard]>=0.128.8",
29
+ "mkdocs-material>=9.7.5",
30
+ "mkdocstrings-python>=1.18.2",
29
31
  "ruff>=0.15.4",
30
32
  "ty>=0.0.20",
31
33
  ]
32
34
 
35
+ [tool.ruff.format]
36
+ docstring-code-format = true
37
+
33
38
  [tool.ruff.lint]
34
39
  select = [
35
40
  "E", # pycodestyle errors
36
41
  "F", # pyflakes
37
42
  "I", # isort
43
+ "D", # pydocstyle
44
+ "A", # flake8-builtins
38
45
  "B", # flake8-bugbear
39
46
  "C4", # flake8-comprehensions
40
- "SIM", # flake8-simplify
41
47
  "ANN", # flake8-annotations
48
+ "FBT", # flake8-boolean-trap
49
+ "PYI", # flake8-pyi
50
+ "SIM", # flake8-simplify
42
51
  "RUF", # ruff
43
52
  "UP", # pyupgrade
44
53
  ]
45
-
54
+ extend-select = ["D401","D404","D413"]
46
55
  ignore = [
47
- "ANN401"
56
+ "D105", # missing docstring in magic method
57
+ "ANN401", # typing.Any disallowed
48
58
  ]
49
-
50
-
51
59
  unfixable = [
52
60
  "F401" # disable fix for unused imports
53
61
  ]
62
+ pydocstyle = { convention = "google" }
54
63
 
55
64
  [tool.ruff.lint.per-file-ignores]
56
65
  "__init__.py" = ["I002"]
66
+ "!src/**.py" = ["D"]
67
+ "!src/__init__.py" = ["D100"]
57
68
 
58
69
  [tool.ruff.lint.isort]
59
70
  required-imports = ["from __future__ import annotations"]
60
71
  extra-standard-library = ["typing_extensions"]
72
+ known-third-party = ["fastapi", "pydantic", "syncwave"]
@@ -1,12 +1,15 @@
1
- __version__ = "0.2.0"
1
+ """Turn plain JSONs into live data stores, two-way synced with Python objects."""
2
2
 
3
+ __version__ = "0.2.1"
3
4
 
4
- from .reactive import Reactive
5
+
6
+ from .reactive import DeadReferenceError, Reactive
5
7
  from .sync_collection import SyncCollection, SyncDict, SyncList, SyncSet
6
8
  from .sync_model import SyncModel, is_sync_model_supported
7
9
  from .syncwave import Syncwave
8
10
 
9
11
  __all__ = [
12
+ "DeadReferenceError",
10
13
  "Reactive",
11
14
  "SyncCollection",
12
15
  "SyncDict",
@@ -11,6 +11,9 @@ from pydantic import TypeAdapter, ValidationError
11
11
 
12
12
  from .watcher import watcher
13
13
 
14
+ __all__ = []
15
+
16
+
14
17
  PendingWrite = tuple[Any, TypeAdapter, Timer]
15
18
 
16
19
 
@@ -88,7 +91,7 @@ class _IO:
88
91
  self._atomic_write(path, self._serialize(init_value, ta))
89
92
  return init_value
90
93
 
91
- def read_json(self, path: Path, ta: TypeAdapter = _any_ta) -> Any:
94
+ def load(self, path: Path, ta: TypeAdapter = _any_ta) -> Any:
92
95
  # never returns EmptyFile, it throws an error if the file is empty
93
96
  with self._lock:
94
97
  if path in self._pending_writes:
@@ -97,10 +100,10 @@ class _IO:
97
100
  return value
98
101
  text = self._serialize(value, previous_ta)
99
102
  return self._deserialize(text, ta, path)
100
- text = path.read_text(encoding=self.ENCODING).strip()
103
+ text = path.read_text(encoding=self.ENCODING)
101
104
  return self._deserialize(text, ta, path)
102
105
 
103
- def write_json(self, path: Path, value: Any, ta: TypeAdapter = _any_ta) -> None:
106
+ def dump(self, path: Path, value: Any, ta: TypeAdapter = _any_ta) -> None:
104
107
  with self._lock:
105
108
  if path in self._pending_writes:
106
109
  self._pending_writes[path][2].cancel()
@@ -108,6 +111,20 @@ class _IO:
108
111
  self._pending_writes[path] = (value, ta, timer)
109
112
  timer.start()
110
113
 
114
+ def read_json(self, path: Path) -> str:
115
+ with self._lock:
116
+ if path in self._pending_writes:
117
+ value, ta, _ = self._pending_writes[path]
118
+ return self._serialize(value, ta)
119
+ return path.read_text(encoding=self.ENCODING)
120
+
121
+ def write_json(self, path: Path, text: str) -> None:
122
+ with self._lock:
123
+ if path in self._pending_writes:
124
+ _, _, timer = self._pending_writes.pop(path)
125
+ timer.cancel()
126
+ self._atomic_write(path, text)
127
+
111
128
  def _scheduled_write(self, path: Path) -> None:
112
129
  with self._lock:
113
130
  if path not in self._pending_writes:
@@ -127,7 +144,6 @@ class _IO:
127
144
  # Atomically replace the target file
128
145
  watcher.mark_self_write(path)
129
146
  os.replace(tmp_path, path)
130
- # print(f"Wrote JSON to {path}")
131
147
 
132
148
  except Exception as e:
133
149
  # Attempt to remove temporary file on error
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABCMeta, abstractmethod
4
+ from dataclasses import dataclass
5
+ from functools import wraps
6
+ from threading import RLock
7
+ from typing import Any, Callable, NoReturn, TypeVar, final
8
+ from typing_extensions import ParamSpec
9
+
10
+ __all__ = ["DeadReferenceError", "Reactive"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class StoreRef:
15
+ lock: RLock
16
+ on_change: Callable[[], None]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Context:
21
+ tp: type[Reactive]
22
+
23
+
24
+ class ContextMap(dict[type["Reactive"], Context]): ...
25
+
26
+
27
+ CtxSubCls = TypeVar("CtxSubCls", bound=Context)
28
+ ReactiveSubCls = TypeVar("ReactiveSubCls", bound="Reactive")
29
+
30
+
31
+ class Reactive(metaclass=ABCMeta):
32
+ """Base class shared by all reactive values in Syncwave.
33
+
34
+ All reactive types (`SyncDict`, `SyncList`, `SyncSet` and `SyncModel`) are
35
+ subclasses of `Reactive`. You will mainly encounter it for type checks:
36
+ `isinstance(value, Reactive)`.
37
+
38
+ A reactive object can become dead when the corresponding store entry is removed or
39
+ replaced. Once dead, `sync_live` returns `False` and any further operation raises
40
+ `DeadReferenceError`.
41
+
42
+ Example:
43
+ ```python
44
+ from syncwave import Reactive, SyncDict, Syncwave
45
+
46
+ syncwave = Syncwave()
47
+ sync_dict = syncwave.create_store(SyncDict[str, int], name="sync_dict")
48
+ print(isinstance(sync_dict, Reactive)) # True
49
+ print(issubclass(SyncDict, Reactive)) # True
50
+ ```
51
+
52
+ ---
53
+
54
+ Abstract: Usage Documentation
55
+ [Reactive](https://placeholder.dev/usage/syncwave/)
56
+
57
+ """
58
+
59
+ __syncwave_sref__: StoreRef
60
+ __syncwave_ctx__: Context
61
+ __syncwave_live__: bool
62
+
63
+ def __new__(cls, *args: Any, **kwargs: Any) -> NoReturn: # noqa: D102
64
+ raise TypeError("Reactive types can never be instantiated directly.")
65
+
66
+ @final
67
+ @property
68
+ def sync_live(self) -> bool:
69
+ """Whether this reactive object is still connected to the store.
70
+
71
+ Returns `False` once the object has been removed or replaced in its parent
72
+ store, for example because a key was deleted from a `SyncDict`. After that, any
73
+ operation on the object raises `DeadReferenceError`.
74
+
75
+ Example:
76
+ ```python
77
+ from pydantic import BaseModel
78
+ from syncwave import Syncwave
79
+
80
+ syncwave = Syncwave()
81
+
82
+
83
+ @syncwave.register(name="customers")
84
+ class Customer(BaseModel):
85
+ name: str
86
+ age: int
87
+
88
+
89
+ customers = syncwave["customers"]
90
+ customers.append({"name": "Alice", "age": 30})
91
+ alice = customers[0]
92
+ print(alice.sync_live) # True
93
+ del customers[0]
94
+ print(alice.sync_live) # False
95
+ ```
96
+
97
+ ---
98
+
99
+ Abstract: Usage Documentation
100
+ [Reactive](https://placeholder.dev/usage/syncwave/)
101
+
102
+ """
103
+ return self.__syncwave_live__ # atomic, no need to lock
104
+
105
+ @abstractmethod
106
+ def __syncwave_init__(self, sref: StoreRef, ctx: CtxSubCls) -> None:
107
+ raise NotImplementedError
108
+
109
+ @abstractmethod
110
+ def __syncwave_kill__(self) -> None:
111
+ raise NotImplementedError
112
+
113
+ @abstractmethod
114
+ def __syncwave_update__(self, new: ReactiveSubCls) -> None:
115
+ raise NotImplementedError
116
+
117
+
118
+ P = ParamSpec("P")
119
+ R = TypeVar("R")
120
+
121
+
122
+ def atomic(fn: Callable[P, R]) -> Callable[P, R]:
123
+ @wraps(fn)
124
+ def wrapper(self: Reactive, *args: P.args, **kwargs: P.kwargs) -> R:
125
+ with self.__syncwave_sref__.lock:
126
+ if not self.__syncwave_live__:
127
+ raise DeadReferenceError(reference=self)
128
+ return fn(self, *args, **kwargs) # ty: ignore[invalid-argument-type]
129
+
130
+ return wrapper # ty: ignore[invalid-return-type]
131
+
132
+
133
+ def mut_atomic(fn: Callable[P, R]) -> Callable[P, None]:
134
+ @wraps(fn)
135
+ def wrapper(self: Reactive, *args: P.args, **kwargs: P.kwargs) -> None:
136
+ with self.__syncwave_sref__.lock:
137
+ if not self.__syncwave_live__:
138
+ raise DeadReferenceError(reference=self)
139
+ result = fn(self, *args, **kwargs) # ty: ignore[invalid-argument-type]
140
+ if result is not None:
141
+ unreachable()
142
+ self.__syncwave_sref__.on_change()
143
+
144
+ return wrapper # ty: ignore[invalid-return-type]
145
+
146
+
147
+ class DeadReferenceError(RuntimeError):
148
+ """Raised when an operation is attempted on a dead reactive object.
149
+
150
+ A reactive object becomes dead when it is removed from the store, for example
151
+ by deleting a key from a `SyncDict` or by deleting the store entirely. Catching
152
+ this error is one way to check whether a reference is still valid, though
153
+ checking `sync_live` first is usually cleaner.
154
+
155
+ Example:
156
+ ```python
157
+ from pydantic import BaseModel
158
+ from syncwave import DeadReferenceError, Syncwave
159
+
160
+ syncwave = Syncwave()
161
+
162
+
163
+ @syncwave.register(name="customers")
164
+ class Customer(BaseModel):
165
+ name: str
166
+ age: int
167
+
168
+
169
+ customers = syncwave["customers"]
170
+ customers.append({"name": "Alice", "age": 30})
171
+ alice = customers[0]
172
+ del customers[0]
173
+
174
+ try:
175
+ alice.age = 31
176
+ except DeadReferenceError as e:
177
+ print(e)
178
+ ```
179
+
180
+ """
181
+
182
+ def __init__(self, *, reference: Reactive) -> None: # noqa: D107
183
+ message = f"Operation attempted on a dead reference: {reference!r}"
184
+ super().__init__(message)
185
+
186
+
187
+ def unreachable() -> NoReturn:
188
+ raise RuntimeError("Internal Error")
@@ -34,12 +34,35 @@ from .reactive import (
34
34
  unreachable,
35
35
  )
36
36
 
37
+ __all__ = ["SyncCollection", "SyncDict", "SyncList", "SyncSet"]
38
+
39
+
37
40
  KT = TypeVar("KT")
38
41
  VT = TypeVar("VT", bound=Union[Reactive, Any])
39
42
 
40
43
 
41
44
  @final
42
45
  class SyncCollection(Reactive):
46
+ """Virtual base class for Syncwave's reactive collection types.
47
+
48
+ The `SyncCollection` types are `SyncDict`, `SyncList`, and `SyncSet`.
49
+
50
+ `SyncCollection` itself cannot be instantiated or subclassed. Can be used for
51
+ `isinstance` and `issubclass` checks.
52
+
53
+ Example:
54
+ ```python
55
+ from syncwave import SyncCollection, SyncList, Syncwave
56
+
57
+ syncwave = Syncwave()
58
+ sync_list = syncwave.create_store(SyncList[int], name="sync_list")
59
+
60
+ print(isinstance(sync_list, SyncCollection)) # True
61
+ print(issubclass(SyncList, SyncCollection)) # True
62
+ ```
63
+
64
+ """
65
+
43
66
  def __init_subclass__(cls, /, **kwargs: Any) -> NoReturn:
44
67
  raise TypeError("SyncCollection cannot be subclassed.")
45
68
 
@@ -54,13 +77,45 @@ class SyncCollection(Reactive):
54
77
 
55
78
 
56
79
  @dataclass(frozen=True)
57
- class SyncDictCtx(Generic[KT, VT], Context):
80
+ class SyncDictCtx(Context, Generic[KT, VT]):
58
81
  tp: type[SyncDict]
59
82
  inner_ctx: Context | ContextMap | None
60
83
  inner_type_adapter: TypeAdapter[VT]
61
84
 
62
85
 
63
86
  class SyncDict(MutableMapping[KT, VT], Reactive):
87
+ """A reactive dictionary.
88
+
89
+ `SyncDict` behaves like a regular `dict`. Assignments, updates, and deletions
90
+ trigger a write to the backing JSON file, and external changes to the file are
91
+ reflected in place.
92
+
93
+ References to reactive values are stable by key: a reference to `store["alice"]`
94
+ continues to represent whatever is stored under `"alice"` until that key is removed,
95
+ at which point `sync_live` is set to `False` and any further operation raises
96
+ `DeadReferenceError`.
97
+
98
+ Example:
99
+ ```python
100
+ from syncwave import SyncDict, Syncwave
101
+
102
+ syncwave = Syncwave()
103
+ sync_dict = syncwave.create_store(SyncDict[str, int], name="sync_dict")
104
+
105
+ sync_dict["a"] = 1
106
+ sync_dict["b"] = 2
107
+ sync_dict["c"] = 3
108
+ print(sync_dict)
109
+ print(list(sync_dict.items()))
110
+ ```
111
+
112
+ ---
113
+
114
+ Abstract: Usage Documentation
115
+ [SyncDict](https://placeholder.dev/usage/syncwave/)
116
+
117
+ """
118
+
64
119
  __syncwave_ctx__: SyncDictCtx[KT, VT]
65
120
  __data: dict[KT, VT]
66
121
 
@@ -221,13 +276,43 @@ class SyncDict(MutableMapping[KT, VT], Reactive):
221
276
 
222
277
 
223
278
  @dataclass(frozen=True)
224
- class SyncListCtx(Generic[VT], Context):
279
+ class SyncListCtx(Context, Generic[VT]):
225
280
  tp: type[SyncList]
226
281
  inner_ctx: Context | ContextMap | None
227
282
  inner_type_adapter: TypeAdapter[VT]
228
283
 
229
284
 
230
285
  class SyncList(MutableSequence[VT], Reactive):
286
+ """A reactive list.
287
+
288
+ `SyncList` behaves like a regular `list`. Appending, replacing, inserting, and
289
+ deleting items trigger a write to the backing JSON file, and external changes to the
290
+ file are reflected in place.
291
+
292
+ When a `SyncList` holds reactive items, references are stable by position, not by
293
+ value. A reference to `store[0]` represents whatever is at index `0`. Inserting a
294
+ new element at the beginning shifts that reference to the new element, not to the
295
+ one that was there before.
296
+
297
+ Example:
298
+ ```python
299
+ from syncwave import SyncList, Syncwave
300
+
301
+ syncwave = Syncwave()
302
+ sync_list = syncwave.create_store(SyncList[int], name="sync_list")
303
+
304
+ sync_list.append(1)
305
+ sync_list.extend([2, 3])
306
+ print(sync_list)
307
+ ```
308
+
309
+ ---
310
+
311
+ Abstract: Usage Documentation
312
+ [SyncList](https://placeholder.dev/usage/syncwave/)
313
+
314
+ """
315
+
231
316
  __syncwave_ctx__: SyncListCtx[VT]
232
317
  __data: list[VT]
233
318
 
@@ -374,7 +459,7 @@ class SyncList(MutableSequence[VT], Reactive):
374
459
  return len(self.__data)
375
460
 
376
461
  @mut_atomic
377
- def insert(self, index: SupportsIndex, value: VT) -> None:
462
+ def insert(self, index: SupportsIndex, value: VT) -> None: # noqa: D102
378
463
  i = _get_index(index)
379
464
  inner_ctx = self.__syncwave_ctx__.inner_ctx
380
465
  new_item = self.__syncwave_ctx__.inner_type_adapter.validate_python(value)
@@ -426,13 +511,42 @@ def _get_index(index: Any) -> int:
426
511
 
427
512
 
428
513
  @dataclass(frozen=True)
429
- class SyncSetCtx(Generic[VT], Context):
514
+ class SyncSetCtx(Context, Generic[VT]):
430
515
  tp: type[SyncSet]
431
516
  inner_ctx: None # never holds reactive items
432
517
  inner_type_adapter: TypeAdapter[VT]
433
518
 
434
519
 
435
520
  class SyncSet(MutableSet[VT], Reactive):
521
+ """A reactive set.
522
+
523
+ `SyncSet` behaves like a regular `set`. Adding and discarding items trigger a write
524
+ to the backing JSON file, and external changes to the file are reflected in place.
525
+
526
+ `SyncSet` can only hold non-reactive, hashable values such as `str`, `int`, `UUID`,
527
+ etc. Reactive types like `SyncCollection` or `SyncModel` are mutable and therefore
528
+ not supported.
529
+
530
+ Example:
531
+ ```python
532
+ from syncwave import SyncSet, Syncwave
533
+
534
+ syncwave = Syncwave()
535
+ sync_set: SyncSet[int] = syncwave.create_store(SyncSet[int], name="sync_set")
536
+
537
+ sync_set.add(1)
538
+ sync_set.add(2)
539
+ sync_set.discard(2)
540
+ print(sync_set)
541
+ ```
542
+
543
+ ---
544
+
545
+ Abstract: Usage Documentation
546
+ [SyncSet](https://placeholder.dev/usage/syncwave/)
547
+
548
+ """
549
+
436
550
  # SyncSet cannot hold reactive items because a reactive item is mutable
437
551
  __syncwave_ctx__: SyncSetCtx[VT]
438
552
  __data: set[VT]
@@ -490,12 +604,12 @@ class SyncSet(MutableSet[VT], Reactive):
490
604
  return len(self.__data)
491
605
 
492
606
  @mut_atomic
493
- def add(self, value: VT) -> None:
607
+ def add(self, value: VT) -> None: # noqa: D102
494
608
  new_item = self.__syncwave_ctx__.inner_type_adapter.validate_python(value)
495
609
  self.__data.add(new_item)
496
610
 
497
611
  @mut_atomic
498
- def discard(self, value: VT) -> None:
612
+ def discard(self, value: VT) -> None: # noqa: D102
499
613
  if value in self.__data:
500
614
  self.__data.discard(value)
501
615
 
@@ -38,8 +38,48 @@ if TYPE_CHECKING:
38
38
  # 3. classes decorated with `@pydantic.dataclasses.dataclass`.
39
39
  SMS = Union[BaseModel, RootModel, PydanticDataclass]
40
40
 
41
+ __all__ = ["SyncModel", "is_sync_model_supported"]
42
+
41
43
 
42
44
  def is_sync_model_supported(cls: Any) -> TypeGuard[type[SMS]]:
45
+ """Return whether `cls` can be made into a `SyncModel`.
46
+
47
+ Supported classes are:
48
+
49
+ - subclasses of `pydantic.BaseModel`
50
+ - subclasses of `pydantic.RootModel`
51
+ - classes decorated with `pydantic.dataclasses.dataclass`
52
+
53
+ Standard-library dataclasses or plain Python classes are not supported.
54
+
55
+ Example:
56
+ ```python
57
+ from pydantic import BaseModel
58
+ from syncwave import is_sync_model_supported
59
+
60
+
61
+ class Customer(BaseModel):
62
+ name: str
63
+
64
+
65
+ is_sync_model_supported(Customer) # True
66
+ is_sync_model_supported(dict) # False
67
+ ```
68
+
69
+ ---
70
+
71
+ Abstract: Usage Documentation
72
+ [is_sync_model_supported](https://placeholder.dev/usage/syncwave/)
73
+
74
+ Args:
75
+ cls: Object to test.
76
+
77
+ Returns:
78
+ `True` if `cls` can be passed to [Syncwave.make_reactive](https://placeholder.dev/api/syncwave/#syncwave.Syncwave.make_reactive)
79
+ or used with [Syncwave.register](https://placeholder.dev/api/syncwave/#syncwave.Syncwave.register),
80
+ and False otherwise.
81
+
82
+ """
43
83
  if not isclass(cls):
44
84
  return False
45
85
  if issubclass(cls, SyncModel):
@@ -56,6 +96,46 @@ class SyncModelCtx(Context):
56
96
 
57
97
 
58
98
  class SyncModel(Reactive):
99
+ """Base class for reactive models.
100
+
101
+ Instances of `SyncModel` behave just like the original model: field access and
102
+ assignment work exactly the same way. The difference is that every field assignment
103
+ triggers a write to the backing JSON file, and external changes to the file are
104
+ reflected in the same object.
105
+
106
+ You will rarely interact with `SyncModel` directly. Instances appear when you access
107
+ reactive model values from a store, and `isinstance(value, SyncModel)` is the main
108
+ way to check for them.
109
+
110
+ Example:
111
+ ```python
112
+ from pydantic import BaseModel
113
+ from syncwave import SyncModel, Syncwave
114
+
115
+ syncwave = Syncwave()
116
+
117
+
118
+ @syncwave.register(name="customers")
119
+ class Customer(BaseModel):
120
+ name: str
121
+ age: int
122
+
123
+
124
+ customers = syncwave["customers"]
125
+ customers.append({"name": "Alice", "age": 30})
126
+ alice = customers[0]
127
+ isinstance(alice, SyncModel) # True
128
+ alice.age = 31 # writes to customers.json immediately
129
+ print(alice) # name='Alice' age=31
130
+ ```
131
+
132
+ ---
133
+
134
+ Abstract: Usage Documentation
135
+ [SyncModel](https://placeholder.dev/usage/syncwave/)
136
+
137
+ """
138
+
59
139
  __syncwave_ctx__: SyncModelCtx
60
140
  __syncwave_original_cls__: type[SMS]
61
141
 
@@ -220,7 +300,7 @@ class SyncModel(Reactive):
220
300
  o_setattr(self, f, n)
221
301
 
222
302
 
223
- def create_sync_model(cls: type[SMS], rename: bool | str = True) -> type[SyncModel]:
303
+ def create_sync_model(cls: type[SMS], *, rename: bool | str = True) -> type[SyncModel]:
224
304
  cls_name = f"Sync{cls.__name__}" if rename is True else rename or cls.__name__
225
305
  return type(
226
306
  cls_name,