jmux 0.0.5__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 CHANGED
@@ -0,0 +1,11 @@
1
+ from jmux.awaitable import AwaitableValue, StreamableValues
2
+ from jmux.base import StreamableBaseModel, Streamed
3
+ from jmux.demux import JMux
4
+
5
+ __all__ = [
6
+ "AwaitableValue",
7
+ "JMux",
8
+ "Streamed",
9
+ "StreamableBaseModel",
10
+ "StreamableValues",
11
+ ]
jmux/awaitable.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from asyncio import Event, Queue
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 an asyncio.Queue to store the items and allows for putting items into the
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._queue = Queue[T | None]()
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._queue.put(item)
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
- + " is already closed."
165
+ + " is already closed."
158
166
  )
159
167
  self._closed = True
160
- await self._queue.put(None)
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
- while True:
199
- item = await self._queue.get()
200
- if item is None and self._closed:
201
- break
202
- if item is None:
203
- raise ValueError("Received None item, but the sink is not closed.")
204
- yield item
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
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class StreamableBaseModel(BaseModel):
5
+ pass
6
+
7
+
8
+ class Streamed:
9
+ pass
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/decoder.py CHANGED
@@ -32,26 +32,40 @@ class StringEscapeDecoder:
32
32
  def __init__(self):
33
33
  self._buffer = ""
34
34
  self._string_escape = False
35
+ self._is_parsing_unicode = False
36
+ self._unicode_buffer = ""
35
37
 
36
38
  def push(self, ch: str) -> str | None:
39
+ if self._is_parsing_unicode:
40
+ self._unicode_buffer += ch
41
+ if len(self._unicode_buffer) == 4:
42
+ code_point = int(self._unicode_buffer, 16)
43
+ decoded_char = chr(code_point)
44
+ self._buffer += decoded_char
45
+ self._is_parsing_unicode = False
46
+ self._unicode_buffer = ""
47
+ return decoded_char
48
+ return None
49
+
37
50
  if self._string_escape:
38
51
  self._string_escape = False
39
52
  if ch == "u":
40
- self.is_parsing_unicode = True
41
- self.unicode_buffer = ""
42
- return
53
+ self._is_parsing_unicode = True
54
+ self._unicode_buffer = ""
55
+ return None
43
56
  escaped_char = self.escape_map.get(ch, ch)
44
57
  self._buffer += escaped_char
45
58
  return escaped_char
46
59
 
47
60
  if ch == "\\":
48
61
  self._string_escape = True
62
+ return None
49
63
  else:
50
64
  self._buffer += ch
51
65
  return ch
52
66
 
53
67
  def is_terminating_quote(self, ch: str) -> bool:
54
- if self._string_escape:
68
+ if self._string_escape or self._is_parsing_unicode:
55
69
  return False
56
70
  if ch == '"':
57
71
  return True
@@ -60,6 +74,8 @@ class StringEscapeDecoder:
60
74
  def reset(self) -> None:
61
75
  self._buffer = ""
62
76
  self._string_escape = False
77
+ self._is_parsing_unicode = False
78
+ self._unicode_buffer = ""
63
79
 
64
80
  @property
65
81
  def buffer(self) -> str:
jmux/demux.py CHANGED
@@ -47,6 +47,8 @@ from jmux.types import (
47
47
  BOOLEAN_OPEN,
48
48
  COLON,
49
49
  COMMA,
50
+ EXPECT_KEY_IN_ROOT,
51
+ EXPECT_VALUE_IN_ARRAY,
50
52
  FLOAT_ALLOWED,
51
53
  INTERGER_ALLOWED,
52
54
  JSON_FALSE,
@@ -199,60 +201,74 @@ class JMux(ABC):
199
201
  ForbiddenTypeHintsError: If a type hint is not allowed.
200
202
  ObjectMissmatchedError: If the JMux class does not match the Pydantic model.
201
203
  """
202
- for attr_name, type_alias in get_type_hints(cls).items():
203
- jmux_main_type_set, jmux_subtype_set = extract_types_from_generic_alias(
204
- type_alias
205
- )
206
-
207
- MaybePydanticType = get_type_hints(pydantic_model).get(attr_name, None)
208
- if MaybePydanticType is None:
209
- if NoneType in jmux_subtype_set:
210
- continue
211
- else:
212
- raise MissingAttributeError(
213
- object_name=pydantic_model.__name__,
214
- attribute=attr_name,
215
- )
204
+ try:
205
+ for attr_name, type_alias in get_type_hints(cls).items():
206
+ jmux_main_type_set, jmux_subtype_set = extract_types_from_generic_alias(
207
+ type_alias
208
+ )
216
209
 
217
- pydantic_main_type_set, pydantic_subtype_set = (
218
- extract_types_from_generic_alias(MaybePydanticType)
219
- )
220
- cls._assert_only_allowed_types(
221
- jmux_main_type_set,
222
- jmux_subtype_set,
223
- pydantic_main_type_set,
224
- pydantic_subtype_set,
225
- )
226
- cls._assert_correct_set_combinations(
227
- jmux_main_type_set,
228
- pydantic_main_type_set,
229
- pydantic_subtype_set,
230
- )
210
+ MaybePydanticType = get_type_hints(pydantic_model).get(attr_name, None)
211
+ if MaybePydanticType is None:
212
+ if NoneType in jmux_subtype_set:
213
+ continue
214
+ else:
215
+ raise MissingAttributeError(
216
+ object_name=pydantic_model.__name__,
217
+ attribute=attr_name,
218
+ )
231
219
 
232
- if StreamableValues in jmux_main_type_set:
233
- cls._assert_is_allowed_streamable_values(
234
- jmux_subtype_set,
235
- pydantic_subtype_set,
236
- pydantic_main_type_set,
237
- pydantic_model,
238
- attr_name,
220
+ pydantic_main_type_set, pydantic_subtype_set = (
221
+ extract_types_from_generic_alias(MaybePydanticType)
239
222
  )
240
- elif AwaitableValue in jmux_main_type_set:
241
- cls._assert_is_allowed_awaitable_value(
223
+ cls._assert_only_allowed_types(
224
+ jmux_main_type_set,
242
225
  jmux_subtype_set,
243
- pydantic_subtype_set,
244
226
  pydantic_main_type_set,
245
- pydantic_model,
246
- attr_name,
227
+ pydantic_subtype_set,
247
228
  )
248
- else:
249
- raise ObjectMissmatchedError(
250
- jmux_model=cls.__name__,
251
- pydantic_model=pydantic_model.__name__,
252
- attribute=attr_name,
253
- message="Unexpected main type on JMux",
229
+ cls._assert_correct_set_combinations(
230
+ jmux_main_type_set,
231
+ pydantic_main_type_set,
232
+ pydantic_subtype_set,
254
233
  )
255
234
 
235
+ if StreamableValues in jmux_main_type_set:
236
+ cls._assert_is_allowed_streamable_values(
237
+ jmux_subtype_set,
238
+ pydantic_subtype_set,
239
+ pydantic_main_type_set,
240
+ pydantic_model,
241
+ attr_name,
242
+ )
243
+ elif AwaitableValue in jmux_main_type_set:
244
+ cls._assert_is_allowed_awaitable_value(
245
+ jmux_subtype_set,
246
+ pydantic_subtype_set,
247
+ pydantic_main_type_set,
248
+ pydantic_model,
249
+ attr_name,
250
+ )
251
+ else:
252
+ raise ObjectMissmatchedError(
253
+ jmux_model=cls.__name__,
254
+ pydantic_model=pydantic_model.__name__,
255
+ attribute=attr_name,
256
+ message="Unexpected main type on JMux",
257
+ )
258
+ except TypeError as e:
259
+ raise ForbiddenTypeHintsError(message=f"Unexpected type hint: {e}") from e
260
+ except MissingAttributeError as e:
261
+ raise e
262
+ except ObjectMissmatchedError as e:
263
+ raise e
264
+ except Exception as e:
265
+ raise ObjectMissmatchedError(
266
+ jmux_model=cls.__name__,
267
+ pydantic_model=pydantic_model.__name__,
268
+ attribute=attr_name,
269
+ message=f"Unexpected main type on JMux: {e}",
270
+ )
271
+
256
272
  @classmethod
257
273
  def _assert_correct_set_combinations(
258
274
  cls,
@@ -481,18 +497,27 @@ class JMux(ABC):
481
497
  # CONTEXT: Root
482
498
  case M.ROOT:
483
499
  match self._pda.state:
484
- case S.EXPECT_KEY:
500
+ case _ if self._pda.state in EXPECT_KEY_IN_ROOT:
485
501
  if ch in JSON_WHITESPACE:
486
502
  pass
487
503
  elif ch == '"':
488
504
  self._pda.set_state(S.PARSING_KEY)
489
505
  self._decoder.reset()
506
+ elif ch in OBJECT_CLOSE:
507
+ if self._pda.state is S.EXPECT_KEY_AFTER_COMMA:
508
+ raise UnexpectedCharacterError(
509
+ ch,
510
+ self._pda.stack,
511
+ self._pda.state,
512
+ "Trailing comma in object is not allowed.",
513
+ )
514
+ await self._finalize()
490
515
  else:
491
516
  raise UnexpectedCharacterError(
492
517
  ch,
493
518
  self._pda.stack,
494
519
  self._pda.state,
495
- "Char needs to be '\"' or JSON whitespaces",
520
+ "Char needs to be '\"', '}' or JSON whitespaces",
496
521
  )
497
522
 
498
523
  case S.PARSING_KEY:
@@ -537,6 +562,14 @@ class JMux(ABC):
537
562
  "Expected '[' or '\"' for 'StreamableValues'",
538
563
  )
539
564
  elif ch in ARRAY_OPEN:
565
+ if self._sink.current_sink_type == SinkType.AWAITABLE_VALUE:
566
+ raise UnexpectedCharacterError(
567
+ ch,
568
+ self._pda.stack,
569
+ self._pda.state,
570
+ "Trying to parse 'array' but sink type is "
571
+ "'AwaitableValue'.",
572
+ )
540
573
  self._pda.set_state(S.EXPECT_VALUE)
541
574
  self._pda.push(M.ARRAY)
542
575
  else:
@@ -579,12 +612,12 @@ class JMux(ABC):
579
612
  await self._parse_primitive()
580
613
  await self._sink.close()
581
614
  self._decoder.reset()
582
- if ch in JSON_WHITESPACE:
583
- self._pda.set_state(S.EXPECT_COMMA_OR_EOC)
584
- else:
585
- self._pda.set_state(S.EXPECT_KEY)
586
- if ch in OBJECT_CLOSE:
615
+ if ch in COMMA:
616
+ self._pda.set_state(S.EXPECT_KEY_AFTER_COMMA)
617
+ elif ch in OBJECT_CLOSE:
587
618
  await self._finalize()
619
+ else:
620
+ self._pda.set_state(S.EXPECT_COMMA_OR_EOC)
588
621
  else:
589
622
  self._assert_primitive_character_allowed_in_state(ch)
590
623
  self._decoder.push(ch)
@@ -593,7 +626,7 @@ class JMux(ABC):
593
626
  if ch in JSON_WHITESPACE:
594
627
  pass
595
628
  elif ch in COMMA:
596
- self._pda.set_state(S.EXPECT_KEY)
629
+ self._pda.set_state(S.EXPECT_KEY_AFTER_COMMA)
597
630
  elif ch in OBJECT_CLOSE:
598
631
  await self._finalize()
599
632
  else:
@@ -622,12 +655,19 @@ class JMux(ABC):
622
655
  )
623
656
 
624
657
  match self._pda.state:
625
- case S.EXPECT_VALUE:
658
+ case _ if self._pda.state in EXPECT_VALUE_IN_ARRAY:
626
659
  if ch in JSON_WHITESPACE:
627
660
  pass
628
661
  elif await self._handle_common__expect_value(ch):
629
662
  pass
630
663
  elif ch in ARRAY_CLOSE:
664
+ if self._pda.state is S.EXPECT_VALUE_AFTER_COMMA:
665
+ raise UnexpectedCharacterError(
666
+ ch,
667
+ self._pda.stack,
668
+ self._pda.state,
669
+ "Trailing comma in array is not allowed.",
670
+ )
631
671
  await self._close_context(S.EXPECT_COMMA_OR_EOC)
632
672
  else:
633
673
  raise UnexpectedCharacterError(
@@ -670,9 +710,11 @@ class JMux(ABC):
670
710
  await self._parse_primitive()
671
711
  self._decoder.reset()
672
712
  if ch in COMMA:
673
- self._pda.set_state(S.EXPECT_VALUE)
713
+ self._pda.set_state(S.EXPECT_VALUE_AFTER_COMMA)
674
714
  elif ch in ARRAY_CLOSE:
675
715
  await self._close_context(S.EXPECT_COMMA_OR_EOC)
716
+ else:
717
+ self._pda.set_state(S.EXPECT_COMMA_OR_EOC)
676
718
  else:
677
719
  self._assert_primitive_character_allowed_in_state(ch)
678
720
  self._decoder.push(ch)
@@ -681,7 +723,7 @@ class JMux(ABC):
681
723
  if ch in JSON_WHITESPACE:
682
724
  pass
683
725
  elif ch in COMMA:
684
- self._pda.set_state(S.EXPECT_VALUE)
726
+ self._pda.set_state(S.EXPECT_VALUE_AFTER_COMMA)
685
727
  elif ch in ARRAY_CLOSE:
686
728
  await self._close_context(S.EXPECT_COMMA_OR_EOC)
687
729
  else:
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)
jmux/types.py CHANGED
@@ -9,8 +9,10 @@ class State(Enum):
9
9
  ERROR = "error"
10
10
  # expect
11
11
  EXPECT_KEY = "expect_key"
12
+ EXPECT_KEY_AFTER_COMMA = "expect_key_after_comma"
12
13
  EXPECT_COLON = "expect_colon"
13
14
  EXPECT_VALUE = "expect_value"
15
+ EXPECT_VALUE_AFTER_COMMA = "expect_value_after_comma"
14
16
  EXPECT_COMMA_OR_EOC = "expect_comma_or_eoc"
15
17
  # parsing
16
18
  PARSING_KEY = "parsing_key"
@@ -29,6 +31,9 @@ PARSING_PRIMITIVE_STATES: Set[State] = {
29
31
  State.PARSING_NULL,
30
32
  }
31
33
 
34
+ EXPECT_KEY_IN_ROOT = {State.EXPECT_KEY, State.EXPECT_KEY_AFTER_COMMA}
35
+ EXPECT_VALUE_IN_ARRAY = {State.EXPECT_VALUE, State.EXPECT_VALUE_AFTER_COMMA}
36
+
32
37
 
33
38
  class Mode(Enum):
34
39
  ROOT = "$"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jmux
3
- Version: 0.0.5
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 `asyncio`, JMux is perfect for modern, high-performance Python applications.
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
- Here’s a conceptual example of how you might integrate `jmux` with an LLM call, such as one made with `litellm`:
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 asyncio
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 asyncio.sleep(0.01) # Simulate network latency
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
- await asyncio.gather(
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
- asyncio.run(process_llm_response())
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 asyncio
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
- await asyncio.gather(produce(), consume())
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
- asyncio.run(main())
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
- ## License
494
+ ### Class `jmux.StreamableBaseModel`
353
495
 
354
- This project is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file for details.
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
- ## Planned Improvements
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
- - Add support for older Python versions
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,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jmux = jmux.cli:main
@@ -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=Y6KVryRDLvGV5nBsneXpTvC0WUGhR5Z89Dvqz4HMAgg,1562
4
- jmux/demux.py,sha256=OuUaNwvKI6WJxdzBzbBgx0yPq1kn4jSttuB5lvwzztk,36422
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=CJhFS9RVgR0cDBNJR8ROAFnxzG4YTYpNZ90hyD2SxsY,1389
9
- jmux-0.0.5.dist-info/licenses/LICENSE,sha256=y0qnwaAe4bEqzNPyq4M_VZA2I2mQly8MawajyZhqw0k,1169
10
- jmux-0.0.5.dist-info/METADATA,sha256=31D2JB7C5kqeislSLEg3WRkyGXO5cx3mY34SAJd1ntU,13330
11
- jmux-0.0.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
- jmux-0.0.5.dist-info/top_level.txt,sha256=TF2N6kHqLghfOkCiNlCueMDX4l5rPn_5MSPNtYrS1-o,5
13
- jmux-0.0.5.dist-info/RECORD,,
File without changes