jmux 0.0.6__py3-none-any.whl → 0.1.0__py3-none-any.whl
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.
- jmux/__init__.py +11 -0
- jmux/awaitable.py +28 -16
- jmux/base.py +9 -0
- jmux/cli.py +62 -0
- jmux/generator.py +381 -0
- {jmux-0.0.6.dist-info → jmux-0.1.0.dist-info}/METADATA +184 -20
- jmux-0.1.0.dist-info/RECORD +17 -0
- jmux-0.1.0.dist-info/entry_points.txt +2 -0
- jmux-0.0.6.dist-info/RECORD +0 -13
- {jmux-0.0.6.dist-info → jmux-0.1.0.dist-info}/WHEEL +0 -0
- {jmux-0.0.6.dist-info → jmux-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {jmux-0.0.6.dist-info → jmux-0.1.0.dist-info}/top_level.txt +0 -0
jmux/__init__.py
CHANGED
jmux/awaitable.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import math
|
|
4
4
|
from enum import Enum
|
|
5
5
|
from types import NoneType
|
|
6
6
|
from typing import (
|
|
@@ -14,6 +14,9 @@ from typing import (
|
|
|
14
14
|
runtime_checkable,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
+
from anyio import Event, create_memory_object_stream
|
|
18
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
19
|
+
|
|
17
20
|
from jmux.error import NothingEmittedError, SinkClosedError
|
|
18
21
|
from jmux.helpers import extract_types_from_generic_alias
|
|
19
22
|
|
|
@@ -27,7 +30,7 @@ class SinkType(Enum):
|
|
|
27
30
|
|
|
28
31
|
class UnderlyingGenericMixin(Generic[T]):
|
|
29
32
|
"""
|
|
30
|
-
A mixin class that provides methods for inspecting the generic types of a
|
|
33
|
+
A mixin class that provides methods for inspecting the generic types of a
|
|
31
34
|
class at runtime.
|
|
32
35
|
"""
|
|
33
36
|
|
|
@@ -105,12 +108,17 @@ class IAsyncSink(Protocol[T]):
|
|
|
105
108
|
class StreamableValues(UnderlyingGenericMixin[T], Generic[T]):
|
|
106
109
|
"""
|
|
107
110
|
A class that represents a stream of values that can be asynchronously iterated over.
|
|
108
|
-
It uses
|
|
109
|
-
stream and closing it when no more items will be added.
|
|
111
|
+
It uses anyio memory object streams to store the items and allows for putting items
|
|
112
|
+
into the stream and closing it when no more items will be added.
|
|
110
113
|
"""
|
|
111
114
|
|
|
115
|
+
_send_stream: MemoryObjectSendStream[T | None]
|
|
116
|
+
_receive_stream: MemoryObjectReceiveStream[T | None]
|
|
117
|
+
|
|
112
118
|
def __init__(self):
|
|
113
|
-
self.
|
|
119
|
+
self._send_stream, self._receive_stream = create_memory_object_stream[T | None](
|
|
120
|
+
max_buffer_size=math.inf
|
|
121
|
+
)
|
|
114
122
|
self._last_item: T | None = None
|
|
115
123
|
self._closed = False
|
|
116
124
|
|
|
@@ -142,7 +150,7 @@ class StreamableValues(UnderlyingGenericMixin[T], Generic[T]):
|
|
|
142
150
|
if self._closed:
|
|
143
151
|
raise ValueError("Cannot put item into a closed sink.")
|
|
144
152
|
self._last_item = item
|
|
145
|
-
await self.
|
|
153
|
+
await self._send_stream.send(item)
|
|
146
154
|
|
|
147
155
|
async def close(self):
|
|
148
156
|
"""
|
|
@@ -154,10 +162,11 @@ class StreamableValues(UnderlyingGenericMixin[T], Generic[T]):
|
|
|
154
162
|
if self._closed:
|
|
155
163
|
raise SinkClosedError(
|
|
156
164
|
f"SinkType {self.get_sink_type()}[{self.get_underlying_main_generic()}]"
|
|
157
|
-
|
|
165
|
+
+ " is already closed."
|
|
158
166
|
)
|
|
159
167
|
self._closed = True
|
|
160
|
-
await self.
|
|
168
|
+
await self._send_stream.send(None)
|
|
169
|
+
await self._send_stream.aclose()
|
|
161
170
|
|
|
162
171
|
async def ensure_closed(self):
|
|
163
172
|
"""
|
|
@@ -195,13 +204,16 @@ class StreamableValues(UnderlyingGenericMixin[T], Generic[T]):
|
|
|
195
204
|
return self._stream()
|
|
196
205
|
|
|
197
206
|
async def _stream(self) -> AsyncGenerator[T, None]:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
try:
|
|
208
|
+
while True:
|
|
209
|
+
item = await self._receive_stream.receive()
|
|
210
|
+
if item is None and self._closed:
|
|
211
|
+
break
|
|
212
|
+
if item is None:
|
|
213
|
+
raise ValueError("Received None item, but the sink is not closed.")
|
|
214
|
+
yield item
|
|
215
|
+
finally:
|
|
216
|
+
await self._receive_stream.aclose()
|
|
205
217
|
|
|
206
218
|
|
|
207
219
|
class AwaitableValue(UnderlyingGenericMixin[T], Generic[T]):
|
|
@@ -242,7 +254,7 @@ class AwaitableValue(UnderlyingGenericMixin[T], Generic[T]):
|
|
|
242
254
|
if self._is_closed:
|
|
243
255
|
raise SinkClosedError(
|
|
244
256
|
f"SinkType {self.get_sink_type()}"
|
|
245
|
-
+"[{self.get_underlying_main_generic().__name__}] is already closed."
|
|
257
|
+
+ "[{self.get_underlying_main_generic().__name__}] is already closed."
|
|
246
258
|
)
|
|
247
259
|
elif not self._event.is_set() and NoneType in self.get_underlying_generics():
|
|
248
260
|
self._event.set()
|
jmux/base.py
ADDED
jmux/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from jmux.generator import find_streamable_models, generate_jmux_code
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="jmux",
|
|
13
|
+
description="JMux CLI for generating JMux classes from Pydantic models",
|
|
14
|
+
)
|
|
15
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
16
|
+
|
|
17
|
+
gen_parser = subparsers.add_parser(
|
|
18
|
+
"generate",
|
|
19
|
+
help="Generate JMux classes from StreamableBaseModel subclasses",
|
|
20
|
+
)
|
|
21
|
+
gen_parser.add_argument(
|
|
22
|
+
"--root",
|
|
23
|
+
type=Path,
|
|
24
|
+
default=Path("."),
|
|
25
|
+
help="Root directory to scan for StreamableBaseModel subclasses",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
command: str | None = args.command
|
|
30
|
+
|
|
31
|
+
if command is None:
|
|
32
|
+
parser.print_help()
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
if command == "generate":
|
|
36
|
+
root: Path = args.root
|
|
37
|
+
generate_command(root)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_command(root: Path) -> None:
|
|
41
|
+
resolved_root = root.resolve()
|
|
42
|
+
print(f"Scanning for StreamableBaseModel subclasses in: {resolved_root}")
|
|
43
|
+
|
|
44
|
+
models = find_streamable_models(resolved_root)
|
|
45
|
+
|
|
46
|
+
if not models:
|
|
47
|
+
print("No StreamableBaseModel subclasses found.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
print(f"Found {len(models)} model(s): {', '.join(m.__name__ for m in models)}")
|
|
51
|
+
|
|
52
|
+
code = generate_jmux_code(models)
|
|
53
|
+
|
|
54
|
+
output_path = Path(__file__).parent / "generated" / "__init__.py"
|
|
55
|
+
output_path.parent.mkdir(exist_ok=True)
|
|
56
|
+
output_path.write_text(code)
|
|
57
|
+
|
|
58
|
+
print(f"Generated JMux classes written to: {output_path}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
jmux/generator.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import importlib.util
|
|
5
|
+
import sys
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType, NoneType, UnionType
|
|
9
|
+
from typing import Annotated, Any, Union, get_args, get_origin, get_type_hints
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from jmux.base import StreamableBaseModel, Streamed
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_models_from_source(
|
|
17
|
+
source: str,
|
|
18
|
+
module_name: str = "__jmux_extract__",
|
|
19
|
+
) -> list[type[StreamableBaseModel]]:
|
|
20
|
+
if not _source_imports_streamable_base_model(source):
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
module = _exec_source_as_module(source, module_name)
|
|
24
|
+
if module is None:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
return _extract_models_from_module(module)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_streamable_models(root_path: Path) -> list[type[StreamableBaseModel]]:
|
|
31
|
+
models: list[type[StreamableBaseModel]] = []
|
|
32
|
+
root_path = root_path.resolve()
|
|
33
|
+
|
|
34
|
+
for py_file in root_path.rglob("*.py"):
|
|
35
|
+
source = _read_file_safe(py_file)
|
|
36
|
+
if source is None:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if not _source_imports_streamable_base_model(source):
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
module = _import_module_from_path(py_file, root_path)
|
|
43
|
+
if module is None:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
models.extend(_extract_models_from_module(module))
|
|
47
|
+
|
|
48
|
+
return models
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_models_from_module(
|
|
52
|
+
module: ModuleType,
|
|
53
|
+
) -> list[type[StreamableBaseModel]]:
|
|
54
|
+
models: list[type[StreamableBaseModel]] = []
|
|
55
|
+
for name in dir(module):
|
|
56
|
+
obj = getattr(module, name)
|
|
57
|
+
if (
|
|
58
|
+
isinstance(obj, type)
|
|
59
|
+
and issubclass(obj, StreamableBaseModel)
|
|
60
|
+
and obj is not StreamableBaseModel
|
|
61
|
+
):
|
|
62
|
+
models.append(obj)
|
|
63
|
+
return models
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read_file_safe(py_file: Path) -> str | None:
|
|
67
|
+
try:
|
|
68
|
+
return py_file.read_text()
|
|
69
|
+
except (OSError, UnicodeDecodeError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _source_imports_streamable_base_model(source: str) -> bool:
|
|
74
|
+
try:
|
|
75
|
+
tree = ast.parse(source)
|
|
76
|
+
except SyntaxError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
for node in ast.walk(tree):
|
|
80
|
+
if isinstance(node, ast.ImportFrom):
|
|
81
|
+
if node.module and "jmux" in node.module:
|
|
82
|
+
for alias in node.names:
|
|
83
|
+
if alias.name == "StreamableBaseModel":
|
|
84
|
+
return True
|
|
85
|
+
elif isinstance(node, ast.Import):
|
|
86
|
+
for alias in node.names:
|
|
87
|
+
if "jmux" in alias.name:
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _exec_source_as_module(source: str, module_name: str) -> ModuleType | None:
|
|
93
|
+
try:
|
|
94
|
+
module = ModuleType(module_name)
|
|
95
|
+
module.__dict__["__builtins__"] = __builtins__
|
|
96
|
+
sys.modules[module_name] = module
|
|
97
|
+
exec(compile(source, f"<{module_name}>", "exec"), module.__dict__)
|
|
98
|
+
return module
|
|
99
|
+
except Exception:
|
|
100
|
+
if module_name in sys.modules:
|
|
101
|
+
del sys.modules[module_name]
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _import_module_from_path(py_file: Path, root_path: Path) -> ModuleType | None:
|
|
106
|
+
try:
|
|
107
|
+
relative = py_file.relative_to(root_path)
|
|
108
|
+
module_name = str(relative.with_suffix("")).replace("/", ".").replace("\\", ".")
|
|
109
|
+
|
|
110
|
+
if root_path not in [Path(p) for p in sys.path]:
|
|
111
|
+
sys.path.insert(0, str(root_path))
|
|
112
|
+
|
|
113
|
+
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
114
|
+
if spec is None or spec.loader is None:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
module = importlib.util.module_from_spec(spec)
|
|
118
|
+
sys.modules[module_name] = module
|
|
119
|
+
spec.loader.exec_module(module)
|
|
120
|
+
return module
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_streamed_marker(obj: Any) -> bool:
|
|
126
|
+
if obj is Streamed:
|
|
127
|
+
return True
|
|
128
|
+
if isinstance(obj, type) and obj.__name__ == "Streamed":
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_resolved_annotations(model: type[StreamableBaseModel]) -> dict[str, Any]:
|
|
134
|
+
try:
|
|
135
|
+
globalns = getattr(sys.modules.get(model.__module__, None), "__dict__", {})
|
|
136
|
+
return get_type_hints(model, globalns=globalns, include_extras=True)
|
|
137
|
+
except Exception:
|
|
138
|
+
return model.__annotations__
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def generate_jmux_code(models: list[type[StreamableBaseModel]]) -> str:
|
|
142
|
+
lines = [
|
|
143
|
+
"from enum import Enum",
|
|
144
|
+
"from typing import Union",
|
|
145
|
+
"",
|
|
146
|
+
"from jmux.awaitable import AwaitableValue, StreamableValues",
|
|
147
|
+
"from jmux.demux import JMux",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
enum_imports = _collect_enum_imports(models)
|
|
152
|
+
if enum_imports:
|
|
153
|
+
lines.extend(enum_imports)
|
|
154
|
+
lines.append("")
|
|
155
|
+
|
|
156
|
+
nested_models = _collect_nested_models(models)
|
|
157
|
+
all_models = _topological_sort(models, nested_models)
|
|
158
|
+
|
|
159
|
+
for model in all_models:
|
|
160
|
+
class_lines = _generate_class(model)
|
|
161
|
+
lines.extend(class_lines)
|
|
162
|
+
lines.append("")
|
|
163
|
+
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _collect_enum_imports(models: list[type[StreamableBaseModel]]) -> list[str]:
|
|
168
|
+
enums: set[type[Enum]] = set()
|
|
169
|
+
for model in models:
|
|
170
|
+
resolved = _get_resolved_annotations(model)
|
|
171
|
+
for field_name in model.model_fields:
|
|
172
|
+
annotation = resolved.get(field_name)
|
|
173
|
+
if annotation is None:
|
|
174
|
+
continue
|
|
175
|
+
_collect_enums_from_type(annotation, enums)
|
|
176
|
+
|
|
177
|
+
imports = []
|
|
178
|
+
for enum_type in enums:
|
|
179
|
+
module = enum_type.__module__
|
|
180
|
+
name = enum_type.__name__
|
|
181
|
+
imports.append(f"from {module} import {name}")
|
|
182
|
+
return sorted(imports)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _collect_enums_from_type(annotation: Any, enums: set[type[Enum]]) -> None:
|
|
186
|
+
origin = get_origin(annotation)
|
|
187
|
+
args = get_args(annotation)
|
|
188
|
+
|
|
189
|
+
if origin is Annotated:
|
|
190
|
+
if args:
|
|
191
|
+
_collect_enums_from_type(args[0], enums)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
195
|
+
enums.add(annotation)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
if origin is list and args:
|
|
199
|
+
_collect_enums_from_type(args[0], enums)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _collect_nested_models(
|
|
203
|
+
models: list[type[StreamableBaseModel]],
|
|
204
|
+
) -> set[type[StreamableBaseModel]]:
|
|
205
|
+
nested: set[type[StreamableBaseModel]] = set()
|
|
206
|
+
for model in models:
|
|
207
|
+
resolved = _get_resolved_annotations(model)
|
|
208
|
+
for field_name in model.model_fields:
|
|
209
|
+
annotation = resolved.get(field_name)
|
|
210
|
+
if annotation is None:
|
|
211
|
+
continue
|
|
212
|
+
_collect_nested_from_type(annotation, nested)
|
|
213
|
+
return nested
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _collect_nested_from_type(
|
|
217
|
+
annotation: Any, nested: set[type[StreamableBaseModel]]
|
|
218
|
+
) -> None:
|
|
219
|
+
origin = get_origin(annotation)
|
|
220
|
+
args = get_args(annotation)
|
|
221
|
+
|
|
222
|
+
if origin is Annotated:
|
|
223
|
+
if args:
|
|
224
|
+
_collect_nested_from_type(args[0], nested)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
isinstance(annotation, type)
|
|
229
|
+
and issubclass(annotation, BaseModel)
|
|
230
|
+
and issubclass(annotation, StreamableBaseModel)
|
|
231
|
+
and annotation is not StreamableBaseModel
|
|
232
|
+
):
|
|
233
|
+
nested.add(annotation)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
if origin is list and args:
|
|
237
|
+
_collect_nested_from_type(args[0], nested)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _topological_sort(
|
|
241
|
+
models: list[type[StreamableBaseModel]],
|
|
242
|
+
nested: set[type[StreamableBaseModel]],
|
|
243
|
+
) -> list[type[StreamableBaseModel]]:
|
|
244
|
+
all_models = set(models) | nested
|
|
245
|
+
result: list[type[StreamableBaseModel]] = []
|
|
246
|
+
visited: set[type[StreamableBaseModel]] = set()
|
|
247
|
+
|
|
248
|
+
def visit(model: type[StreamableBaseModel]) -> None:
|
|
249
|
+
if model in visited:
|
|
250
|
+
return
|
|
251
|
+
visited.add(model)
|
|
252
|
+
resolved = _get_resolved_annotations(model)
|
|
253
|
+
for field_name in model.model_fields:
|
|
254
|
+
annotation = resolved.get(field_name)
|
|
255
|
+
if annotation is None:
|
|
256
|
+
continue
|
|
257
|
+
dep = _get_nested_model_dependency(annotation)
|
|
258
|
+
if dep and dep in all_models:
|
|
259
|
+
visit(dep)
|
|
260
|
+
result.append(model)
|
|
261
|
+
|
|
262
|
+
for model in all_models:
|
|
263
|
+
visit(model)
|
|
264
|
+
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _get_nested_model_dependency(annotation: Any) -> type[StreamableBaseModel] | None:
|
|
269
|
+
origin = get_origin(annotation)
|
|
270
|
+
args = get_args(annotation)
|
|
271
|
+
|
|
272
|
+
if origin is Annotated:
|
|
273
|
+
if args:
|
|
274
|
+
return _get_nested_model_dependency(args[0])
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
if (
|
|
278
|
+
isinstance(annotation, type)
|
|
279
|
+
and issubclass(annotation, BaseModel)
|
|
280
|
+
and issubclass(annotation, StreamableBaseModel)
|
|
281
|
+
and annotation is not StreamableBaseModel
|
|
282
|
+
):
|
|
283
|
+
return annotation
|
|
284
|
+
|
|
285
|
+
if origin is list and args:
|
|
286
|
+
return _get_nested_model_dependency(args[0])
|
|
287
|
+
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _generate_class(model: type[StreamableBaseModel]) -> list[str]:
|
|
292
|
+
class_name = f"{model.__name__}JMux"
|
|
293
|
+
lines = [f"class {class_name}(JMux):"]
|
|
294
|
+
|
|
295
|
+
resolved = _get_resolved_annotations(model)
|
|
296
|
+
has_fields = False
|
|
297
|
+
for field_name in model.model_fields:
|
|
298
|
+
annotation = resolved.get(field_name)
|
|
299
|
+
if annotation is None:
|
|
300
|
+
continue
|
|
301
|
+
jmux_type = get_jmux_type(annotation)
|
|
302
|
+
lines.append(f" {field_name}: {jmux_type}")
|
|
303
|
+
has_fields = True
|
|
304
|
+
|
|
305
|
+
if not has_fields:
|
|
306
|
+
lines.append(" pass")
|
|
307
|
+
|
|
308
|
+
return lines
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_jmux_type(annotation: Any) -> str:
|
|
312
|
+
origin = get_origin(annotation)
|
|
313
|
+
args = get_args(annotation)
|
|
314
|
+
|
|
315
|
+
if origin is Annotated:
|
|
316
|
+
inner_type = args[0] if args else None
|
|
317
|
+
metadata = args[1:] if len(args) > 1 else ()
|
|
318
|
+
has_streamed = any(_is_streamed_marker(m) for m in metadata)
|
|
319
|
+
|
|
320
|
+
if has_streamed and inner_type is str:
|
|
321
|
+
return "StreamableValues[str]"
|
|
322
|
+
|
|
323
|
+
return get_jmux_type(inner_type)
|
|
324
|
+
|
|
325
|
+
if origin is list:
|
|
326
|
+
inner = args[0] if args else Any
|
|
327
|
+
inner_jmux = _get_inner_type_str(inner)
|
|
328
|
+
return f"StreamableValues[{inner_jmux}]"
|
|
329
|
+
|
|
330
|
+
if annotation is str:
|
|
331
|
+
return "AwaitableValue[str]"
|
|
332
|
+
|
|
333
|
+
if annotation is int:
|
|
334
|
+
return "AwaitableValue[int]"
|
|
335
|
+
|
|
336
|
+
if annotation is float:
|
|
337
|
+
return "AwaitableValue[float]"
|
|
338
|
+
|
|
339
|
+
if annotation is bool:
|
|
340
|
+
return "AwaitableValue[bool]"
|
|
341
|
+
|
|
342
|
+
if annotation is NoneType or annotation is None:
|
|
343
|
+
return "AwaitableValue[None]"
|
|
344
|
+
|
|
345
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
346
|
+
return f"AwaitableValue[{annotation.__name__}]"
|
|
347
|
+
|
|
348
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
349
|
+
if issubclass(annotation, StreamableBaseModel):
|
|
350
|
+
return f"AwaitableValue[{annotation.__name__}JMux]"
|
|
351
|
+
return f"AwaitableValue[{annotation.__name__}]"
|
|
352
|
+
|
|
353
|
+
type_origin = get_origin(annotation)
|
|
354
|
+
if type_origin is UnionType or type_origin is Union:
|
|
355
|
+
non_none_args = [a for a in args if a is not NoneType and a is not None]
|
|
356
|
+
if len(non_none_args) == 1:
|
|
357
|
+
inner = _get_inner_type_str(non_none_args[0])
|
|
358
|
+
return f"AwaitableValue[{inner} | None]"
|
|
359
|
+
return f"AwaitableValue[{annotation}]"
|
|
360
|
+
|
|
361
|
+
return f"AwaitableValue[{annotation}]"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _get_inner_type_str(annotation: Any) -> str:
|
|
365
|
+
if annotation is str:
|
|
366
|
+
return "str"
|
|
367
|
+
if annotation is int:
|
|
368
|
+
return "int"
|
|
369
|
+
if annotation is float:
|
|
370
|
+
return "float"
|
|
371
|
+
if annotation is bool:
|
|
372
|
+
return "bool"
|
|
373
|
+
if annotation is NoneType or annotation is None:
|
|
374
|
+
return "None"
|
|
375
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
376
|
+
return annotation.__name__
|
|
377
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
378
|
+
if issubclass(annotation, StreamableBaseModel):
|
|
379
|
+
return f"{annotation.__name__}JMux"
|
|
380
|
+
return annotation.__name__
|
|
381
|
+
return str(annotation)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jmux
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: JMux: A Python package for demultiplexing a JSON string into multiple awaitable variables.
|
|
5
5
|
Author-email: "Johannes A.I. Unruh" <johannes@unruh.ai>
|
|
6
6
|
License: MIT License
|
|
@@ -40,10 +40,12 @@ Requires-Dist: pydantic>=2.0.0
|
|
|
40
40
|
Provides-Extra: test
|
|
41
41
|
Requires-Dist: pytest; extra == "test"
|
|
42
42
|
Requires-Dist: pytest-anyio; extra == "test"
|
|
43
|
+
Requires-Dist: trio; extra == "test"
|
|
43
44
|
Provides-Extra: dev
|
|
44
45
|
Requires-Dist: ruff; extra == "dev"
|
|
45
46
|
Requires-Dist: pytest; extra == "dev"
|
|
46
47
|
Requires-Dist: pytest-anyio; extra == "dev"
|
|
48
|
+
Requires-Dist: trio; extra == "dev"
|
|
47
49
|
Requires-Dist: uv; extra == "dev"
|
|
48
50
|
Requires-Dist: build; extra == "dev"
|
|
49
51
|
Requires-Dist: twine; extra == "dev"
|
|
@@ -61,8 +63,10 @@ This package is inspired by `Snapshot Streaming` mentioned in the [`WWDC25: Meet
|
|
|
61
63
|
|
|
62
64
|
## Features
|
|
63
65
|
|
|
64
|
-
- **Asynchronous by Design**: Built on top of `
|
|
66
|
+
- **Asynchronous by Design**: Built on top of `anyio`, JMux supports both `asyncio` and `trio` backends, making it perfect for modern, high-performance Python applications.
|
|
67
|
+
- **Python 3.10+**: Supports Python 3.10 and newer versions.
|
|
65
68
|
- **Pydantic Integration**: Validate your `JMux` classes against Pydantic models to ensure type safety and consistency.
|
|
69
|
+
- **Code Generation**: Automatically generate JMux classes from `StreamableBaseModel` subclasses using the `jmux generate` CLI command.
|
|
66
70
|
- **Awaitable and Streamable Sinks**: Use `AwaitableValue` for single values and `StreamableValues` for streams of values.
|
|
67
71
|
- **Robust Error Handling**: JMux provides a comprehensive set of exceptions to handle parsing errors and other issues.
|
|
68
72
|
- **Lightweight**: JMux has only a few external dependencies, making it easy to integrate into any project.
|
|
@@ -75,21 +79,155 @@ You can install JMux from PyPI using pip:
|
|
|
75
79
|
pip install jmux
|
|
76
80
|
```
|
|
77
81
|
|
|
82
|
+
## Code Generation
|
|
83
|
+
|
|
84
|
+
JMux provides a CLI tool to automatically generate JMux classes from Pydantic models. Instead of manually writing both a Pydantic model and a corresponding JMux class, you can define your models using `StreamableBaseModel` and let JMux generate the demultiplexer classes for you.
|
|
85
|
+
|
|
86
|
+
### Defining Models with StreamableBaseModel
|
|
87
|
+
|
|
88
|
+
Use `StreamableBaseModel` as your base class instead of `pydantic.BaseModel`:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from typing import Annotated
|
|
92
|
+
from jmux import StreamableBaseModel, Streamed
|
|
93
|
+
|
|
94
|
+
class LlmResponse(StreamableBaseModel):
|
|
95
|
+
thought: str
|
|
96
|
+
tool_code: Annotated[str, Streamed]
|
|
97
|
+
tags: list[str]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Type Mappings
|
|
101
|
+
|
|
102
|
+
The generator converts your model fields to JMux types as follows:
|
|
103
|
+
|
|
104
|
+
| Model Field Type | Generated JMux Type |
|
|
105
|
+
| ----------------------------- | --------------------------------- |
|
|
106
|
+
| `str`, `int`, `float`, `bool` | `AwaitableValue[T]` |
|
|
107
|
+
| `Enum` | `AwaitableValue[EnumType]` |
|
|
108
|
+
| `T \| None` | `AwaitableValue[T \| None]` |
|
|
109
|
+
| `list[T]` | `StreamableValues[T]` |
|
|
110
|
+
| `Annotated[str, Streamed]` | `StreamableValues[str]` |
|
|
111
|
+
| Nested `StreamableBaseModel` | `AwaitableValue[NestedModelJMux]` |
|
|
112
|
+
|
|
113
|
+
The `Streamed` marker is useful when you want to stream a string field character-by-character (e.g., for real-time display of LLM output) rather than awaiting the complete value.
|
|
114
|
+
|
|
115
|
+
### Using the CLI
|
|
116
|
+
|
|
117
|
+
Run the `jmux generate` command to scan your codebase and generate JMux classes:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
jmux generate --root <directory>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This will:
|
|
124
|
+
|
|
125
|
+
1. Recursively scan `<directory>` for Python files containing `StreamableBaseModel` subclasses
|
|
126
|
+
2. Generate corresponding JMux classes with the suffix `JMux` (e.g., `LlmResponse` → `LlmResponseJMux`)
|
|
127
|
+
3. Write the generated code to `src/jmux/generated/__init__.py`
|
|
128
|
+
|
|
129
|
+
### Example
|
|
130
|
+
|
|
131
|
+
Given this model:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from typing import Annotated
|
|
135
|
+
from jmux import StreamableBaseModel, Streamed
|
|
136
|
+
|
|
137
|
+
class LlmResponse(StreamableBaseModel):
|
|
138
|
+
thought: str
|
|
139
|
+
tool_code: Annotated[str, Streamed]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Running `jmux generate` produces:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from jmux.awaitable import AwaitableValue, StreamableValues
|
|
146
|
+
from jmux.demux import JMux
|
|
147
|
+
|
|
148
|
+
class LlmResponseJMux(JMux):
|
|
149
|
+
thought: AwaitableValue[str]
|
|
150
|
+
tool_code: StreamableValues[str]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
You can then import and use the generated class:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from jmux.generated import LlmResponseJMux
|
|
157
|
+
|
|
158
|
+
jmux_instance = LlmResponseJMux()
|
|
159
|
+
```
|
|
160
|
+
|
|
78
161
|
## Usage with LLMs (e.g., `litellm`)
|
|
79
162
|
|
|
80
163
|
The primary use case for `jmux` is to process streaming JSON responses from LLMs. This allows you to react to parts of the data as it arrives, rather than waiting for the entire JSON object to be transmitted. While this should be obvious, I should mention, that **the order in which the pydantic model defines the properties, defines which stream is filled first**.
|
|
81
164
|
|
|
82
|
-
|
|
165
|
+
### Using Code Generation (Recommended)
|
|
166
|
+
|
|
167
|
+
The easiest way to use JMux with LLMs is to define your models using `StreamableBaseModel` and generate JMux classes automatically:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from typing import Annotated
|
|
171
|
+
from jmux import StreamableBaseModel, Streamed
|
|
172
|
+
|
|
173
|
+
class LlmResponse(StreamableBaseModel): # Use `StreamableBaseModel` so that the CLI can find the `pydantic` models to parse
|
|
174
|
+
thought: str
|
|
175
|
+
tool_code: Annotated[str, Streamed]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Then run `jmux generate --root .` to generate the `LlmResponseJMux` class. You can then use it directly:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
import anyio
|
|
182
|
+
from jmux.generated import LlmResponseJMux
|
|
183
|
+
|
|
184
|
+
async def mock_llm_stream():
|
|
185
|
+
json_stream = '{"thought": "I need to write some code.", "tool_code": "print(\'Hello, World!\')"}'
|
|
186
|
+
for char in json_stream:
|
|
187
|
+
yield char
|
|
188
|
+
await anyio.sleep(0.01)
|
|
189
|
+
|
|
190
|
+
async def process_llm_response():
|
|
191
|
+
jmux_instance = LlmResponseJMux()
|
|
192
|
+
|
|
193
|
+
async def feed_stream():
|
|
194
|
+
async for chunk in mock_llm_stream():
|
|
195
|
+
await jmux_instance.feed_chunks(chunk)
|
|
196
|
+
|
|
197
|
+
async def consume_thought():
|
|
198
|
+
thought = await jmux_instance.thought
|
|
199
|
+
print(f"LLM's thought received: '{thought}'")
|
|
200
|
+
|
|
201
|
+
async def consume_tool_code():
|
|
202
|
+
print("Receiving tool code...")
|
|
203
|
+
full_code = ""
|
|
204
|
+
async for code_fragment in jmux_instance.tool_code:
|
|
205
|
+
full_code += code_fragment
|
|
206
|
+
print(f" -> Received fragment: {code_fragment}")
|
|
207
|
+
print(f"Full tool code received: {full_code}")
|
|
208
|
+
|
|
209
|
+
async with anyio.create_task_group() as tg:
|
|
210
|
+
tg.start_soon(feed_stream)
|
|
211
|
+
tg.start_soon(consume_thought)
|
|
212
|
+
tg.start_soon(consume_tool_code)
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
anyio.run(process_llm_response, backend="asyncio")
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Manual Approach
|
|
219
|
+
|
|
220
|
+
If you prefer more control, you can manually define both the Pydantic model and the JMux class:
|
|
83
221
|
|
|
84
222
|
```python
|
|
85
|
-
import
|
|
223
|
+
import anyio
|
|
86
224
|
from pydantic import BaseModel
|
|
87
225
|
from jmux import JMux, AwaitableValue, StreamableValues
|
|
88
226
|
# litellm is used conceptually here
|
|
89
227
|
# from litellm import acompletion
|
|
90
228
|
|
|
91
229
|
# 1. Define the Pydantic model for the expected JSON response
|
|
92
|
-
class LlmResponse(BaseModel):
|
|
230
|
+
class LlmResponse(BaseModel): # No need to use `StreamableBaseModel` here, since it is only used for detection purposes
|
|
93
231
|
thought: str # **This property is filled first**
|
|
94
232
|
tool_code: str
|
|
95
233
|
|
|
@@ -106,7 +244,7 @@ async def mock_llm_stream():
|
|
|
106
244
|
json_stream = '{"thought": "I need to write some code.", "tool_code": "print(\'Hello, World!\')"}'
|
|
107
245
|
for char in json_stream:
|
|
108
246
|
yield char
|
|
109
|
-
await
|
|
247
|
+
await anyio.sleep(0.01) # Simulate network latency
|
|
110
248
|
|
|
111
249
|
# Main function to orchestrate the call and processing
|
|
112
250
|
async def process_llm_response():
|
|
@@ -132,15 +270,16 @@ async def process_llm_response():
|
|
|
132
270
|
print(f" -> Received fragment: {code_fragment}")
|
|
133
271
|
print(f"Full tool code received: {full_code}")
|
|
134
272
|
|
|
135
|
-
# Run all tasks concurrently
|
|
136
|
-
|
|
137
|
-
feed_stream
|
|
138
|
-
consume_thought
|
|
139
|
-
consume_tool_code
|
|
140
|
-
)
|
|
273
|
+
# Run all tasks concurrently using anyio task group
|
|
274
|
+
async with anyio.create_task_group() as tg:
|
|
275
|
+
tg.start_soon(feed_stream)
|
|
276
|
+
tg.start_soon(consume_thought)
|
|
277
|
+
tg.start_soon(consume_tool_code)
|
|
141
278
|
|
|
279
|
+
# Run with asyncio backend
|
|
142
280
|
if __name__ == "__main__":
|
|
143
|
-
|
|
281
|
+
anyio.run(process_llm_response, backend="asyncio")
|
|
282
|
+
# Or use trio: anyio.run(process_llm_response, backend="trio")
|
|
144
283
|
```
|
|
145
284
|
|
|
146
285
|
## Example Implementation
|
|
@@ -212,7 +351,7 @@ You can either `await awaitable_llm_result` if you need the full result, or use
|
|
|
212
351
|
Here is a simple example of how to use JMux to parse a JSON stream:
|
|
213
352
|
|
|
214
353
|
```python
|
|
215
|
-
import
|
|
354
|
+
import anyio
|
|
216
355
|
from enum import Enum
|
|
217
356
|
from types import NoneType
|
|
218
357
|
from pydantic import BaseModel
|
|
@@ -297,10 +436,13 @@ async def main():
|
|
|
297
436
|
nested_key_str = await key_nested.key_str
|
|
298
437
|
print(f"nested_key_str: {nested_key_str}")
|
|
299
438
|
|
|
300
|
-
|
|
439
|
+
async with anyio.create_task_group() as tg:
|
|
440
|
+
tg.start_soon(produce)
|
|
441
|
+
tg.start_soon(consume)
|
|
301
442
|
|
|
302
443
|
if __name__ == "__main__":
|
|
303
|
-
|
|
444
|
+
anyio.run(main, backend="asyncio")
|
|
445
|
+
# Or use trio: anyio.run(main, backend="trio")
|
|
304
446
|
```
|
|
305
447
|
|
|
306
448
|
## API Reference
|
|
@@ -349,13 +491,35 @@ Additionally the following type is supported without being wrapped into `list`:
|
|
|
349
491
|
|
|
350
492
|
This allows you to fully stream strings directly to a sink.
|
|
351
493
|
|
|
352
|
-
|
|
494
|
+
### Class `jmux.StreamableBaseModel`
|
|
353
495
|
|
|
354
|
-
|
|
496
|
+
A Pydantic `BaseModel` subclass used for defining models that can be automatically converted to JMux classes via the `jmux generate` CLI command.
|
|
497
|
+
|
|
498
|
+
```python
|
|
499
|
+
from jmux import StreamableBaseModel
|
|
355
500
|
|
|
356
|
-
|
|
501
|
+
class MyModel(StreamableBaseModel):
|
|
502
|
+
name: str
|
|
503
|
+
age: int
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Class `jmux.Streamed`
|
|
507
|
+
|
|
508
|
+
A marker class used with `typing.Annotated` to indicate that a string field should be streamed character-by-character rather than awaited as a complete value.
|
|
357
509
|
|
|
358
|
-
|
|
510
|
+
```python
|
|
511
|
+
from typing import Annotated
|
|
512
|
+
from jmux import StreamableBaseModel, Streamed
|
|
513
|
+
|
|
514
|
+
class MyModel(StreamableBaseModel):
|
|
515
|
+
content: Annotated[str, Streamed]
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
When generating JMux classes, fields annotated with `Streamed` will be converted to `StreamableValues[str]` instead of `AwaitableValue[str]`.
|
|
519
|
+
|
|
520
|
+
## License
|
|
521
|
+
|
|
522
|
+
This project is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file for details.
|
|
359
523
|
|
|
360
524
|
## Contributions
|
|
361
525
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
jmux/__init__.py,sha256=ji94yf4_4CyqNfLmJWoJn9WXWJyiRhJGmUqFOiHYdFc,256
|
|
2
|
+
jmux/awaitable.py,sha256=MV7kltI_3D-OzNrTSfiRi8xmARl0Wu8db0QhQ4-zom0,9038
|
|
3
|
+
jmux/base.py,sha256=v1T6_lYicX05a_KbIy5v3ZbnuR_PQmefN0ruaP5DOnM,107
|
|
4
|
+
jmux/cli.py,sha256=wfAcZKMwY_e7Zfd3XecTVgCURC43PEM77k4eZ6_dgIE,1611
|
|
5
|
+
jmux/decoder.py,sha256=YoCkBKposMx1PA78BIVawgFw96QsqTZaOW4gNCSkI7I,2197
|
|
6
|
+
jmux/demux.py,sha256=R2vazLOtfh4zvQKoj3_92RgdqG-zaG3CkQMBz72fAIE,38800
|
|
7
|
+
jmux/error.py,sha256=VZJYivt8RPfjcF2bs-T7_UkH3dVA3xH-xGbZggQV14k,4665
|
|
8
|
+
jmux/generator.py,sha256=clKTZHh-VNsRJarEa8MBT3-zXYe-8CZnI6QbJbRPo-M,11408
|
|
9
|
+
jmux/helpers.py,sha256=zOlw1Yk7-sdKAeasswRRcuUOTEBAUbymoAGwBTMaOjg,2902
|
|
10
|
+
jmux/pda.py,sha256=19joQd0DD5OAmwRpp2jVVbtiFXnjv5P_1mZm87-QOeY,922
|
|
11
|
+
jmux/types.py,sha256=a5Xyx_8A3gsZkscII1_7E6oyu4VQ-oI4_osTnPLi_xs,1649
|
|
12
|
+
jmux-0.1.0.dist-info/licenses/LICENSE,sha256=y0qnwaAe4bEqzNPyq4M_VZA2I2mQly8MawajyZhqw0k,1169
|
|
13
|
+
jmux-0.1.0.dist-info/METADATA,sha256=DbM4_Qf6cGXxo5vSiZ7JOkuVadsZ1s62lsUwPL_UeyE,19004
|
|
14
|
+
jmux-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
15
|
+
jmux-0.1.0.dist-info/entry_points.txt,sha256=8YYzNePAj4-3v1s6IJ_esFhgg9TZZL2fRYXMbSgmAho,39
|
|
16
|
+
jmux-0.1.0.dist-info/top_level.txt,sha256=TF2N6kHqLghfOkCiNlCueMDX4l5rPn_5MSPNtYrS1-o,5
|
|
17
|
+
jmux-0.1.0.dist-info/RECORD,,
|
jmux-0.0.6.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
jmux/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
jmux/awaitable.py,sha256=otX4vbWzVmIwxvMDWOtaIvS9do8YJtbls9xFjwH9Yw0,8534
|
|
3
|
-
jmux/decoder.py,sha256=YoCkBKposMx1PA78BIVawgFw96QsqTZaOW4gNCSkI7I,2197
|
|
4
|
-
jmux/demux.py,sha256=R2vazLOtfh4zvQKoj3_92RgdqG-zaG3CkQMBz72fAIE,38800
|
|
5
|
-
jmux/error.py,sha256=VZJYivt8RPfjcF2bs-T7_UkH3dVA3xH-xGbZggQV14k,4665
|
|
6
|
-
jmux/helpers.py,sha256=zOlw1Yk7-sdKAeasswRRcuUOTEBAUbymoAGwBTMaOjg,2902
|
|
7
|
-
jmux/pda.py,sha256=19joQd0DD5OAmwRpp2jVVbtiFXnjv5P_1mZm87-QOeY,922
|
|
8
|
-
jmux/types.py,sha256=a5Xyx_8A3gsZkscII1_7E6oyu4VQ-oI4_osTnPLi_xs,1649
|
|
9
|
-
jmux-0.0.6.dist-info/licenses/LICENSE,sha256=y0qnwaAe4bEqzNPyq4M_VZA2I2mQly8MawajyZhqw0k,1169
|
|
10
|
-
jmux-0.0.6.dist-info/METADATA,sha256=cJJfsEjvnpvffxjEgdwbsTQFxB8uqIArJApCYRjTETo,13330
|
|
11
|
-
jmux-0.0.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
-
jmux-0.0.6.dist-info/top_level.txt,sha256=TF2N6kHqLghfOkCiNlCueMDX4l5rPn_5MSPNtYrS1-o,5
|
|
13
|
-
jmux-0.0.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|