polyxml 0.5.1__tar.gz → 0.7.0__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.
Files changed (24) hide show
  1. {polyxml-0.5.1 → polyxml-0.7.0}/Cargo.lock +4 -4
  2. {polyxml-0.5.1 → polyxml-0.7.0}/Cargo.toml +1 -1
  3. {polyxml-0.5.1 → polyxml-0.7.0}/PKG-INFO +1 -1
  4. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/src/parser.rs +12 -3
  5. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/src/serializer.rs +11 -0
  6. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-python/src/lib.rs +14 -0
  7. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-python/tests/test_polyxml.py +160 -0
  8. {polyxml-0.5.1 → polyxml-0.7.0}/pyproject.toml +1 -1
  9. {polyxml-0.5.1 → polyxml-0.7.0}/python/polyxml/__init__.py +93 -6
  10. {polyxml-0.5.1 → polyxml-0.7.0}/README.md +0 -0
  11. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/Cargo.toml +0 -0
  12. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/README.md +0 -0
  13. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/benches/core_benchmarks.rs +0 -0
  14. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/src/converters.rs +0 -0
  15. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/src/error.rs +0 -0
  16. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/src/lib.rs +0 -0
  17. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/src/schema.rs +0 -0
  18. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/src/value.rs +0 -0
  19. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/tests/test_core.rs +0 -0
  20. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-core/tests/test_security.rs +0 -0
  21. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-python/Cargo.toml +0 -0
  22. {polyxml-0.5.1 → polyxml-0.7.0}/crates/polyxml-python/README.md +0 -0
  23. {polyxml-0.5.1 → polyxml-0.7.0}/python/polyxml/AGENT_GUIDE.md +0 -0
  24. {polyxml-0.5.1 → polyxml-0.7.0}/python/polyxml/py.typed +0 -0
@@ -395,7 +395,7 @@ dependencies = [
395
395
 
396
396
  [[package]]
397
397
  name = "polyxml"
398
- version = "0.5.1"
398
+ version = "0.7.0"
399
399
  dependencies = [
400
400
  "criterion",
401
401
  "lexical-core",
@@ -408,14 +408,14 @@ dependencies = [
408
408
 
409
409
  [[package]]
410
410
  name = "polyxml-c"
411
- version = "0.5.1"
411
+ version = "0.7.0"
412
412
  dependencies = [
413
413
  "polyxml",
414
414
  ]
415
415
 
416
416
  [[package]]
417
417
  name = "polyxml-js"
418
- version = "0.5.1"
418
+ version = "0.7.0"
419
419
  dependencies = [
420
420
  "napi",
421
421
  "napi-build",
@@ -425,7 +425,7 @@ dependencies = [
425
425
 
426
426
  [[package]]
427
427
  name = "polyxml-python"
428
- version = "0.5.1"
428
+ version = "0.7.0"
429
429
  dependencies = [
430
430
  "lexical-core",
431
431
  "polyxml",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/polyxml-core", "crates/polyxml-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.5.1"
6
+ version = "0.7.0"
7
7
  edition = "2021"
8
8
  rust-version = "1.80"
9
9
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyxml
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -69,9 +69,18 @@ impl StackFrame {
69
69
  if let Some(ref text_buf) = self.frame_text_buf {
70
70
  if !text_buf.is_empty() {
71
71
  let field = &self.schema.fields[text_idx];
72
- if let ValueType::Scalar(ref st) = field.val_type {
73
- let val = ValueConverter::parse_scalar(st, text_buf, &field.name)?;
74
- obj.insert(field.name.clone(), val);
72
+ match &field.val_type {
73
+ ValueType::Scalar(st) => {
74
+ let val = ValueConverter::parse_scalar(st, text_buf, &field.name)?;
75
+ obj.insert(field.name.clone(), val);
76
+ }
77
+ ValueType::List(inner) => {
78
+ if let ValueType::Scalar(st) = &**inner {
79
+ let val = ValueConverter::parse_scalar(st, text_buf, &field.name)?;
80
+ obj.insert(field.name.clone(), PolyValue::List(vec![val]));
81
+ }
82
+ }
83
+ _ => {}
75
84
  }
76
85
  }
77
86
  }
@@ -93,6 +93,17 @@ impl XmlSerializer {
93
93
  .write_event(Event::Text(BytesText::new(text_content)))
94
94
  .map_err(|e| PolyXmlError::SerializationError(e.to_string()))?;
95
95
  }
96
+ } else if let PolyValue::List(items) = val {
97
+ for item in items {
98
+ let mut item_buf = [0u8; lexical_core::BUFFER_SIZE];
99
+ if let Some(text_content) = Self::format_scalar_to(item, &mut item_buf) {
100
+ if !text_content.is_empty() {
101
+ writer
102
+ .write_event(Event::Text(BytesText::new(text_content)))
103
+ .map_err(|e| PolyXmlError::SerializationError(e.to_string()))?;
104
+ }
105
+ }
106
+ }
96
107
  }
97
108
  }
98
109
  }
@@ -216,6 +216,13 @@ fn extract_schema_from_class<'py>(
216
216
  match t_str.as_str() {
217
217
  "Attribute" => kind = FieldKind::Attribute,
218
218
  "Text" => kind = FieldKind::Text,
219
+ "Wildcard" => {
220
+ if let Ok(m_mixed) = m.get_item("mixed") {
221
+ if m_mixed.extract::<bool>().unwrap_or(false) {
222
+ kind = FieldKind::Text;
223
+ }
224
+ }
225
+ }
219
226
  _ => kind = FieldKind::Element,
220
227
  }
221
228
  }
@@ -264,6 +271,13 @@ fn extract_schema_from_class<'py>(
264
271
  match t_str.as_str() {
265
272
  "Attribute" => kind = FieldKind::Attribute,
266
273
  "Text" => kind = FieldKind::Text,
274
+ "Wildcard" => {
275
+ if let Ok(m_mixed) = meta.get_item("mixed") {
276
+ if m_mixed.extract::<bool>().unwrap_or(false) {
277
+ kind = FieldKind::Text;
278
+ }
279
+ }
280
+ }
267
281
  _ => kind = FieldKind::Element,
268
282
  }
269
283
  }
@@ -1,9 +1,12 @@
1
1
  import io
2
2
  import pathlib
3
+ from collections import UserString
3
4
  from dataclasses import dataclass, field
4
5
  from decimal import Decimal
5
6
  from enum import Enum
7
+ from xml.etree.ElementTree import QName
6
8
 
9
+ import msgspec
7
10
  import pytest
8
11
  from pydantic import BaseModel, Field
9
12
 
@@ -402,3 +405,160 @@ def test_binary_deserialization_unsupported_type():
402
405
  payload = polyxml.dumps_binary({"a": 1})
403
406
  with pytest.raises(NotImplementedError, match="PolyXML binary deserializer cannot deserialize"):
404
407
  polyxml.loads_binary(payload, UnhandledClass)
408
+
409
+
410
+ class StatusEnum(Enum):
411
+ ACTIVE = "active"
412
+ INACTIVE = "inactive"
413
+
414
+
415
+ class CustomDuration(UserString):
416
+ pass
417
+
418
+
419
+ class FromStringHolder:
420
+ def __init__(self, val: str):
421
+ self.val = val
422
+
423
+ @classmethod
424
+ def from_string(cls, val: str):
425
+ return cls(val)
426
+
427
+ def __str__(self):
428
+ return self.val
429
+
430
+ def __eq__(self, other):
431
+ return isinstance(other, FromStringHolder) and self.val == other.val
432
+
433
+
434
+ @dataclass
435
+ class RichXmlItem:
436
+ status: StatusEnum
437
+ qname: QName
438
+ duration: CustomDuration
439
+ holder: FromStringHolder
440
+
441
+
442
+ def test_binary_serialization_xml_types():
443
+ orig = RichXmlItem(
444
+ status=StatusEnum.ACTIVE,
445
+ qname=QName("http://example.com", "elem"),
446
+ duration=CustomDuration("P1Y2M3D"),
447
+ holder=FromStringHolder("parsed_value"),
448
+ )
449
+ payload = polyxml.dumps_binary(orig)
450
+ decoded = polyxml.loads_binary(payload, RichXmlItem)
451
+ assert decoded.status == StatusEnum.ACTIVE
452
+ assert decoded.qname == QName("http://example.com", "elem")
453
+ assert decoded.duration == CustomDuration("P1Y2M3D")
454
+ assert decoded.holder == FromStringHolder("parsed_value")
455
+
456
+
457
+ def test_binary_serialization_tag_class_dataclass():
458
+ item = SimpleItem(id=99, name="TaggedItem", price=49.99, active=True)
459
+ payload = polyxml.dumps_binary(item, tag_class=True)
460
+
461
+ # Loads without passing target_type
462
+ decoded_auto = polyxml.loads_binary(payload)
463
+ assert isinstance(decoded_auto, SimpleItem)
464
+ assert decoded_auto.id == 99
465
+ assert decoded_auto.name == "TaggedItem"
466
+
467
+ # Loads with explicit target_type on tagged payload
468
+ decoded_explicit = polyxml.loads_binary(payload, SimpleItem)
469
+ assert isinstance(decoded_explicit, SimpleItem)
470
+ assert decoded_explicit.id == 99
471
+
472
+
473
+ def test_binary_serialization_tag_class_pydantic():
474
+ device = PydanticDevice(serial="SN-9999", model="AeroEdge", power=250.0)
475
+ payload = polyxml.dumps_binary(device, tag_class=True)
476
+ decoded = polyxml.loads_binary(payload)
477
+ assert isinstance(decoded, PydanticDevice)
478
+ assert decoded.serial == "SN-9999"
479
+ assert decoded.model == "AeroEdge"
480
+ assert decoded.power == 250.0
481
+
482
+
483
+ def test_binary_serialization_tag_class_primitive():
484
+ payload = polyxml.dumps_binary("plain_string", tag_class=True)
485
+ decoded = polyxml.loads_binary(payload)
486
+ assert decoded == "plain_string"
487
+
488
+
489
+ def test_binary_serialization_hooks_direct():
490
+ from polyxml import _dec_hook, _enc_hook
491
+
492
+ assert _enc_hook(StatusEnum.ACTIVE) == "active"
493
+ assert _dec_hook(Decimal, "19.99") == Decimal("19.99")
494
+ assert _dec_hook(StatusEnum, "active") == StatusEnum.ACTIVE
495
+
496
+
497
+ def test_binary_serialization_resolve_class():
498
+ from polyxml import _resolve_class
499
+
500
+ assert _resolve_class("") is None
501
+ assert _resolve_class("NoColonHere") is None
502
+ assert _resolve_class("nonexistent.module:FakeClass") is None
503
+ assert _resolve_class("polyxml:dumps_binary") is polyxml.dumps_binary
504
+ # Hits the _CLASS_CACHE branch
505
+ assert _resolve_class("polyxml:dumps_binary") is polyxml.dumps_binary
506
+
507
+
508
+ def test_binary_serialization_tagged_unresolvable_class():
509
+ # Tagged payload with a class that cannot be resolved
510
+ raw_inner = msgspec.msgpack.encode({"key": "value"})
511
+ tagged_data = msgspec.msgpack.encode(("nonexistent.module:FakeClass", raw_inner))
512
+
513
+ decoded = polyxml.loads_binary(tagged_data)
514
+ assert decoded == {"key": "value"}
515
+
516
+
517
+ def test_binary_serialization_tagged_payload_decode_error():
518
+ # Explicit target_type on tagged payload where inner payload fails to decode
519
+ raw_inner = msgspec.msgpack.encode({"unexpected": "structure"})
520
+ tagged_data = msgspec.msgpack.encode(("nonexistent:Fake", raw_inner))
521
+
522
+ with pytest.raises(msgspec.ValidationError):
523
+ polyxml.loads_binary(tagged_data, SimpleItem)
524
+
525
+
526
+ def test_binary_serialization_target_type_non_tagged_decode_errors():
527
+ # Target type passed with invalid msgpack bytes
528
+ with pytest.raises(msgspec.DecodeError):
529
+ polyxml.loads_binary(b"\xc1", SimpleItem)
530
+
531
+ # Target type passed with valid non-tagged msgpack that fails validation
532
+ with pytest.raises(msgspec.ValidationError):
533
+ polyxml.loads_binary(msgspec.msgpack.encode(123), SimpleItem)
534
+
535
+
536
+ def test_binary_serialization_loads_corrupted_or_non_tagged():
537
+ # Plain non-tagged list of two items where second is not bytes
538
+ data = msgspec.msgpack.encode(["not_a_class_tag", 12345])
539
+ decoded = polyxml.loads_binary(data)
540
+ assert decoded == ["not_a_class_tag", 12345]
541
+
542
+ # Non-decodable data to custom type
543
+ with pytest.raises(msgspec.DecodeError):
544
+ polyxml.loads_binary(b"\xc1")
545
+
546
+
547
+ def test_mixed_content_wildcard_dataclass():
548
+ @dataclass
549
+ class MixedNode:
550
+ lang: str | None = field(default=None, metadata={"type": "Attribute"})
551
+ content: list[object] = field(
552
+ default_factory=list,
553
+ metadata={"type": "Wildcard", "mixed": True},
554
+ )
555
+
556
+ xml = '<MixedNode lang="en">Sample Text Content</MixedNode>'
557
+ decoded = polyxml.deserialize(xml.encode("utf-8"), MixedNode)
558
+ assert decoded.lang == "en"
559
+ assert decoded.content == ["Sample Text Content"]
560
+
561
+ serialized = polyxml.serialize(decoded)
562
+ assert b"Sample Text Content" in serialized
563
+ assert b'lang="en"' in serialized
564
+
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "polyxml"
7
- version = "0.5.1"
7
+ version = "0.7.0"
8
8
  description = "High-performance, polyglot XML data-binding engine built in Rust"
9
9
  authors = [
10
10
  { name = "Bailey Nguyen", email = "bailey.tan.nguyen@gmail.com" }
@@ -24,10 +24,15 @@ QUICKSTART FOR AI AGENTS & DEVELOPERS:
24
24
  See `AGENT_GUIDE.md` or https://nth-bailey.github.io/PolyXML/ for complete documentation.
25
25
  """
26
26
 
27
+ import importlib
27
28
  import pathlib
29
+ from collections import UserString
28
30
  from collections.abc import Iterator
31
+ from dataclasses import is_dataclass
29
32
  from decimal import Decimal
33
+ from enum import Enum
30
34
  from typing import IO
35
+ from xml.etree.ElementTree import QName
31
36
 
32
37
  from polyxml._polyxml import ( # type: ignore[import-not-found]
33
38
  deserialize as _deserialize,
@@ -120,11 +125,38 @@ except ImportError: # pragma: no cover
120
125
  msgspec = None # type: ignore[assignment]
121
126
  _HAS_MSGSPEC = False
122
127
 
128
+ _CLASS_CACHE: dict[str, type[object]] = {}
123
129
 
124
- def _enc_hook(obj: object) -> str:
130
+
131
+ def _resolve_class(class_identifier: str) -> type[object] | None:
132
+ if not class_identifier:
133
+ return None
134
+ if class_identifier in _CLASS_CACHE:
135
+ return _CLASS_CACHE[class_identifier]
136
+ if ":" in class_identifier:
137
+ module_name, class_name = class_identifier.rsplit(":", 1)
138
+ try:
139
+ mod = importlib.import_module(module_name)
140
+ resolved = getattr(mod, class_name)
141
+ _CLASS_CACHE[class_identifier] = resolved
142
+ return resolved
143
+ except (ImportError, AttributeError):
144
+ return None
145
+ return None
146
+
147
+
148
+ def _enc_hook(obj: object) -> object:
125
149
  if isinstance(obj, pathlib.Path | Decimal):
126
150
  return str(obj)
127
- if hasattr(obj, "from_string") and hasattr(obj, "val"):
151
+ if isinstance(obj, Enum):
152
+ return obj.value
153
+ if isinstance(obj, QName):
154
+ return str(obj)
155
+ if isinstance(obj, UserString):
156
+ return str(obj)
157
+ if hasattr(obj, "model_dump") and callable(obj.model_dump):
158
+ return obj.model_dump()
159
+ if hasattr(obj, "from_string"):
128
160
  return str(obj)
129
161
  raise NotImplementedError(
130
162
  f"PolyXML binary serializer cannot serialize object of type {type(obj).__name__}"
@@ -134,18 +166,30 @@ def _enc_hook(obj: object) -> str:
134
166
  def _dec_hook(target_type: type[object], obj: object) -> object:
135
167
  if target_type is pathlib.Path:
136
168
  return pathlib.Path(str(obj))
169
+ if target_type is Decimal:
170
+ return Decimal(str(obj))
171
+ if isinstance(target_type, type) and issubclass(target_type, Enum):
172
+ return target_type(obj)
173
+ if target_type is QName or (isinstance(target_type, type) and issubclass(target_type, QName)):
174
+ return QName(str(obj))
175
+ if hasattr(target_type, "model_validate") and callable(target_type.model_validate):
176
+ return target_type.model_validate(obj)
137
177
  if hasattr(target_type, "from_string"):
138
178
  return target_type.from_string(str(obj))
179
+ if isinstance(target_type, type) and issubclass(target_type, UserString):
180
+ return target_type(str(obj))
139
181
  raise NotImplementedError(
140
182
  f"PolyXML binary deserializer cannot deserialize object into type {target_type.__name__}"
141
183
  )
142
184
 
143
185
 
144
- def dumps_binary(obj: object) -> bytes:
186
+ def dumps_binary(obj: object, *, tag_class: bool = False) -> bytes:
145
187
  """Serialize a model or dataclass into a high-throughput binary MessagePack buffer.
146
188
 
147
189
  Args:
148
190
  obj: Python dataclass, Pydantic model, or object.
191
+ tag_class: When True, embeds the class identifier in the binary payload
192
+ allowing loads_binary to reconstruct the exact model class dynamically.
149
193
 
150
194
  Returns:
151
195
  Compact binary MessagePack bytes.
@@ -158,6 +202,16 @@ def dumps_binary(obj: object) -> bytes:
158
202
  "PolyXML binary serialization requires 'msgspec'. "
159
203
  "Install it via: pip install 'polyxml[msgpack]' or pip install msgspec"
160
204
  )
205
+ if tag_class:
206
+ if is_dataclass(obj) or hasattr(obj.__class__, "model_fields"):
207
+ cls = obj.__class__
208
+ identifier = f"{cls.__module__}:{cls.__qualname__}"
209
+ _CLASS_CACHE[identifier] = cls
210
+ payload = msgspec.msgpack.encode(obj, enc_hook=_enc_hook)
211
+ return msgspec.msgpack.encode((identifier, payload))
212
+ payload = msgspec.msgpack.encode(obj, enc_hook=_enc_hook)
213
+ return msgspec.msgpack.encode(("", payload))
214
+
161
215
  return msgspec.msgpack.encode(obj, enc_hook=_enc_hook)
162
216
 
163
217
 
@@ -166,7 +220,9 @@ def loads_binary[T](data: bytes, target_type: type[T] | None = None) -> T | obje
166
220
 
167
221
  Args:
168
222
  data: Binary MessagePack bytes to decode.
169
- target_type: Optional target dataclass or model class. If omitted, returns dynamic structure.
223
+ target_type: Optional target dataclass or model class. If omitted and the
224
+ payload was serialized with tag_class=True, dynamically reconstructs the
225
+ original model instance. Otherwise returns a dynamic dictionary/structure.
170
226
 
171
227
  Returns:
172
228
  The deserialized model instance or object.
@@ -180,8 +236,39 @@ def loads_binary[T](data: bytes, target_type: type[T] | None = None) -> T | obje
180
236
  "Install it via: pip install 'polyxml[msgpack]' or pip install msgspec"
181
237
  )
182
238
  if target_type is not None:
183
- return msgspec.msgpack.decode(data, type=target_type, dec_hook=_dec_hook)
184
- return msgspec.msgpack.decode(data, dec_hook=_dec_hook)
239
+ try:
240
+ return msgspec.msgpack.decode(data, type=target_type, dec_hook=_dec_hook)
241
+ except (msgspec.DecodeError, msgspec.ValidationError):
242
+ try:
243
+ decoded = msgspec.msgpack.decode(data)
244
+ if (
245
+ isinstance(decoded, (list, tuple))
246
+ and len(decoded) == 2
247
+ and isinstance(decoded[0], str)
248
+ and isinstance(decoded[1], (bytes, bytearray))
249
+ ):
250
+ return msgspec.msgpack.decode(decoded[1], type=target_type, dec_hook=_dec_hook)
251
+ except Exception:
252
+ pass
253
+ raise
254
+
255
+ try:
256
+ decoded = msgspec.msgpack.decode(data)
257
+ if (
258
+ isinstance(decoded, (list, tuple))
259
+ and len(decoded) == 2
260
+ and isinstance(decoded[0], str)
261
+ and isinstance(decoded[1], (bytes, bytearray))
262
+ ):
263
+ tag, payload = decoded
264
+ if tag:
265
+ resolved_cls = _resolve_class(tag)
266
+ if resolved_cls is not None:
267
+ return msgspec.msgpack.decode(payload, type=resolved_cls, dec_hook=_dec_hook)
268
+ return msgspec.msgpack.decode(payload, dec_hook=_dec_hook)
269
+ return decoded
270
+ except Exception:
271
+ return msgspec.msgpack.decode(data, dec_hook=_dec_hook)
185
272
 
186
273
 
187
274
  __all__ = [
File without changes
File without changes