json2xml 7.0.0__tar.gz → 7.1.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 (54) hide show
  1. {json2xml-7.0.0 → json2xml-7.1.0}/HISTORY.rst +21 -0
  2. {json2xml-7.0.0 → json2xml-7.1.0}/PKG-INFO +2 -2
  3. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml/__init__.py +1 -1
  4. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml/backend_selector.py +7 -42
  5. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml/cli.py +46 -49
  6. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml/dicttoxml.py +63 -41
  7. json2xml-7.1.0/json2xml/dicttoxml_fast.py +278 -0
  8. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml/json2xml.py +11 -2
  9. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml/utils.py +11 -3
  10. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml.egg-info/PKG-INFO +2 -2
  11. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml.egg-info/requires.txt +1 -1
  12. {json2xml-7.0.0 → json2xml-7.1.0}/pyproject.toml +2 -2
  13. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_backend_selector.py +6 -16
  14. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_cli.py +115 -1
  15. json2xml-7.1.0/tests/test_dicttoxml_fast_fallback.py +352 -0
  16. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_dicttoxml_unit.py +38 -0
  17. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_json2xml.py +48 -0
  18. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_rust_dicttoxml.py +33 -25
  19. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_rust_python_parity.py +68 -3
  20. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_utils.py +4 -3
  21. json2xml-7.0.0/json2xml/dicttoxml_fast.py +0 -248
  22. json2xml-7.0.0/tests/test_dicttoxml_fast_fallback.py +0 -207
  23. {json2xml-7.0.0 → json2xml-7.1.0}/AUTHORS.rst +0 -0
  24. {json2xml-7.0.0 → json2xml-7.1.0}/CONTRIBUTING.rst +0 -0
  25. {json2xml-7.0.0 → json2xml-7.1.0}/LICENSE +0 -0
  26. {json2xml-7.0.0 → json2xml-7.1.0}/MANIFEST.in +0 -0
  27. {json2xml-7.0.0 → json2xml-7.1.0}/README.rst +0 -0
  28. {json2xml-7.0.0 → json2xml-7.1.0}/docs/Makefile +0 -0
  29. {json2xml-7.0.0 → json2xml-7.1.0}/docs/authors.rst +0 -0
  30. {json2xml-7.0.0 → json2xml-7.1.0}/docs/benchmarks.rst +0 -0
  31. {json2xml-7.0.0 → json2xml-7.1.0}/docs/conf.py +0 -0
  32. {json2xml-7.0.0 → json2xml-7.1.0}/docs/contributing.rst +0 -0
  33. {json2xml-7.0.0 → json2xml-7.1.0}/docs/history.rst +0 -0
  34. {json2xml-7.0.0 → json2xml-7.1.0}/docs/index.rst +0 -0
  35. {json2xml-7.0.0 → json2xml-7.1.0}/docs/installation.rst +0 -0
  36. {json2xml-7.0.0 → json2xml-7.1.0}/docs/json2xml.rst +0 -0
  37. {json2xml-7.0.0 → json2xml-7.1.0}/docs/make.bat +0 -0
  38. {json2xml-7.0.0 → json2xml-7.1.0}/docs/modules.rst +0 -0
  39. {json2xml-7.0.0 → json2xml-7.1.0}/docs/readme.rst +0 -0
  40. {json2xml-7.0.0 → json2xml-7.1.0}/docs/rust_memory_benchmark.rst +0 -0
  41. {json2xml-7.0.0 → json2xml-7.1.0}/docs/usage.rst +0 -0
  42. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml/types.py +0 -0
  43. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml.egg-info/SOURCES.txt +0 -0
  44. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml.egg-info/dependency_links.txt +0 -0
  45. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml.egg-info/entry_points.txt +0 -0
  46. {json2xml-7.0.0 → json2xml-7.1.0}/json2xml.egg-info/top_level.txt +0 -0
  47. {json2xml-7.0.0 → json2xml-7.1.0}/setup.cfg +0 -0
  48. {json2xml-7.0.0 → json2xml-7.1.0}/setup.py +0 -0
  49. {json2xml-7.0.0 → json2xml-7.1.0}/tests/__init__.py +0 -0
  50. {json2xml-7.0.0 → json2xml-7.1.0}/tests/conftest.py +0 -0
  51. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_benchmark_multi_python.py +0 -0
  52. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_benchmark_security_hardening.py +0 -0
  53. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_dict2xml.py +0 -0
  54. {json2xml-7.0.0 → json2xml-7.1.0}/tests/test_missing_coverage.py +0 -0
@@ -1,4 +1,25 @@
1
1
 
2
+ 7.1.0 / 2026-09-03
3
+ ==================
4
+
5
+ * perf: enforce conversion depth and item limits inside the native payload walk, cutting a 2,000-record accelerated conversion from 7.1 ms to 1.4 ms
6
+ * fix: keep tuples on the Python serializer; the native backend rendered nested tuples differently under ``item_wrap=False`` and ``list_headers=True``
7
+ * fix: write identical CLI output to stdout and ``--output`` files, as UTF-8 bytes ending in exactly one newline
8
+ * fix: accept numbers and other scalars in ``dicttoxml_fast.escape_xml`` and ``wrap_cdata`` on both backends
9
+ * fix: raise ``InvalidDataError`` from ``Json2xml.to_xml`` for values the serializer rejects, such as a Mapping that is not a dict
10
+ * fix: classify ``datetime.time`` in the direct ``convert_kv`` helpers the same way the serializer does
11
+ * fix: report unreadable files distinctly from invalid JSON in ``readfromjson``
12
+ * refactor: load the optional Rust backend through one function that keeps working with ``json2xml-rs`` 0.5.0, and log under ``json2xml.dicttoxml_fast``
13
+ * refactor: share ``SerializerConfig`` between the serializer and the backend selector, add ``dicttoxml.serialize()``, and remove ``ConversionRequest`` and ``has_special_keys``
14
+ * refactor: declare CLI toggles with ``BooleanOptionalAction`` and derive type attributes from one exact-type table
15
+ * chore: release ``json2xml-rs`` 0.6.0 first and require it from ``json2xml[fast]``
16
+
17
+ 7.0.1 / 2026-08-28
18
+ ==================
19
+
20
+ * fix: validate explicitly provided empty ``--string``, ``--url``, and file-path inputs instead of silently falling back to stdin
21
+
22
+
2
23
  7.0.0 / 2026-08-23
3
24
  ==================
4
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json2xml
3
- Version: 7.0.0
3
+ Version: 7.1.0
4
4
  Summary: Simple Python Library to convert JSON to XML
5
5
  Author-email: Vinit Kumar <mail@vinitkumar.me>
6
6
  License: Apache Software License 2.0
@@ -34,7 +34,7 @@ Requires-Dist: setuptools>=80.9.0; extra == "dev"
34
34
  Requires-Dist: pygments>=2.20.0; extra == "dev"
35
35
  Requires-Dist: xmltodict>=0.14.2; extra == "dev"
36
36
  Provides-Extra: fast
37
- Requires-Dist: json2xml-rs>=0.5.0; extra == "fast"
37
+ Requires-Dist: json2xml-rs>=0.6.0; extra == "fast"
38
38
  Dynamic: license-file
39
39
 
40
40
  .. image:: ./json2xml-hero.png
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """Vinit Kumar"""
4
4
  __email__ = "mail@vinitkumar.me"
5
- __version__ = "7.0.0"
5
+ __version__ = "7.1.0"
@@ -1,30 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
4
3
  from typing import Any, Protocol
5
4
 
6
5
  # Shared with the Python serializer deliberately: the root-name gate below is only
7
6
  # correct while it uses the exact predicate Python's name resolver starts from.
8
- from .dicttoxml import _is_fast_valid_xml_name
9
-
10
-
11
- @dataclass(frozen=True, slots=True)
12
- class ConversionRequest:
13
- """Normalized conversion request shared across backend adapters."""
14
-
15
- obj: Any
16
- root: bool
17
- custom_root: str
18
- ids: list[int] | None
19
- attr_type: bool
20
- item_wrap: bool
21
- item_func: Any
22
- cdata: bool
23
- xml_namespaces: dict[str, Any] | None
24
- list_headers: bool
25
- xpath_format: bool
26
- max_output_bytes: int | None = None
27
- indent: str | None = None
7
+ from .dicttoxml import SerializerConfig, _is_fast_valid_xml_name
28
8
 
29
9
 
30
10
  class BackendAdapter(Protocol):
@@ -34,10 +14,10 @@ class BackendAdapter(Protocol):
34
14
  def name(self) -> str:
35
15
  raise NotImplementedError # pragma: no cover
36
16
 
37
- def can_handle(self, request: ConversionRequest) -> bool:
17
+ def can_handle(self, request: SerializerConfig) -> bool:
38
18
  raise NotImplementedError # pragma: no cover
39
19
 
40
- def render(self, request: ConversionRequest) -> bytes:
20
+ def render(self, request: SerializerConfig) -> bytes:
41
21
  raise NotImplementedError # pragma: no cover
42
22
 
43
23
 
@@ -47,7 +27,7 @@ class BackendSelector:
47
27
  def __init__(self, *backends: BackendAdapter) -> None:
48
28
  self._backends = backends
49
29
 
50
- def render(self, request: ConversionRequest) -> bytes:
30
+ def render(self, request: SerializerConfig) -> bytes:
51
31
  for backend in self._backends:
52
32
  if backend.can_handle(request):
53
33
  return backend.render(request)
@@ -56,9 +36,9 @@ class BackendSelector:
56
36
 
57
37
  # Types the Rust backend renders byte-identically to the Python serializer. Subclasses are
58
38
  # excluded on purpose: Python classifies them through its isinstance fallbacks, which the
59
- # native writer does not reproduce.
39
+ # native writer does not reproduce. Tuples are excluded because Python applies its
40
+ # list-shape rules to them while the native writer only recognizes lists.
60
41
  _RUST_SCALAR_TYPES = frozenset({str, bool, int, float, type(None)})
61
- _RUST_CONTAINER_TYPES = frozenset({dict, list, tuple})
62
42
 
63
43
 
64
44
  def _rust_renders_key_identically(key: Any) -> bool:
@@ -105,23 +85,8 @@ def rust_renders_identically(obj: Any) -> bool:
105
85
  return False
106
86
  stack.append(child)
107
87
  continue
108
- if value_type in _RUST_CONTAINER_TYPES:
88
+ if value_type is list:
109
89
  stack.extend(value)
110
90
  continue
111
91
  return False
112
92
  return True
113
-
114
-
115
- def has_special_keys(obj: Any) -> bool:
116
- """Return True when the payload uses Python-only special key semantics."""
117
- if isinstance(obj, dict):
118
- return any(
119
- (isinstance(key, str) and (key.startswith("@") or key.endswith("@flat")))
120
- or has_special_keys(value)
121
- for key, value in obj.items()
122
- )
123
-
124
- if isinstance(obj, list):
125
- return any(has_special_keys(item) for item in obj)
126
-
127
- return False
@@ -7,10 +7,11 @@ Usage:
7
7
 
8
8
  Flags:
9
9
  -w, --wrapper string Wrapper element name (default "all")
10
- -r, --root Include root element (default true)
11
- -p, --pretty Pretty print output (default false)
12
- -t, --type Include type attributes (default true)
13
- -i, --item-wrap Wrap list items in <item> elements (default true)
10
+ -r, --root Include root element (default true, --no-root disables)
11
+ -p, --pretty Pretty print output (default false, --no-pretty disables)
12
+ -t, --type Include type attributes (default true, --no-type disables)
13
+ -i, --item-wrap Wrap list items in <item> elements (default true,
14
+ --no-item-wrap disables)
14
15
  -x, --xpath Use XPath 3.1 json-to-xml format
15
16
  -o, --output string Output file (default: stdout)
16
17
  -u, --url string Read JSON from URL
@@ -109,13 +110,14 @@ class CLIApplication:
109
110
  """Thin command adapter around input resolution, conversion, and output."""
110
111
 
111
112
  def read_input(self, options: CLIConversionOptions) -> JSONValue:
112
- if options.url:
113
+ # None means omitted; explicit empty values still require source validation.
114
+ if options.url is not None:
113
115
  try:
114
116
  return readfromurl(options.url)
115
117
  except URLReadError as error:
116
118
  exit_with_error(f"Error reading from URL: {error}")
117
119
 
118
- if options.string:
120
+ if options.string is not None:
119
121
  try:
120
122
  return readfromstring(options.string)
121
123
  except StringReadError as error:
@@ -125,9 +127,14 @@ class CLIApplication:
125
127
  f"({error})"
126
128
  )
127
129
 
128
- if options.input_file:
130
+ if options.input_file is not None:
129
131
  if options.input_file == "-":
130
132
  return read_from_stdin()
133
+ if not options.input_file:
134
+ exit_with_error(
135
+ "Error: Empty JSON file path. "
136
+ "Pass a JSON file, use - for stdin, or provide --string/--url."
137
+ )
131
138
  if not Path(options.input_file).is_file():
132
139
  exit_with_error(
133
140
  f"Error: JSON file not found: {options.input_file}. "
@@ -180,19 +187,32 @@ class CLIApplication:
180
187
  return xml_output
181
188
 
182
189
  def write_output(self, output: str | bytes, output_file: str | None) -> None:
183
- if isinstance(output, bytes):
184
- output = output.decode("utf-8")
190
+ text = output.decode("utf-8") if isinstance(output, bytes) else output
191
+ # Both destinations get the same text: the document plus exactly one final
192
+ # newline, whether the serializer emitted none, one, or several.
193
+ text = text.rstrip("\n") + "\n"
185
194
 
186
- if output_file:
187
- try:
188
- with open(output_file, "w", encoding="utf-8") as file_obj:
189
- file_obj.write(output)
190
- except OSError as error:
191
- print(f"Error writing to file: {error}", file=sys.stderr)
192
- sys.exit(1)
195
+ if not output_file:
196
+ self._write_stdout(text)
197
+ return
198
+
199
+ try:
200
+ Path(output_file).write_text(text, encoding="utf-8")
201
+ except OSError as error:
202
+ exit_with_error(f"Error writing to file: {error}")
203
+
204
+ @staticmethod
205
+ def _write_stdout(text: str) -> None:
206
+ """Write UTF-8 bytes so stdout matches the file output byte for byte."""
207
+ buffer = getattr(sys.stdout, "buffer", None)
208
+ if buffer is None:
209
+ # A text-only replacement such as io.StringIO has no byte layer.
210
+ sys.stdout.write(text)
193
211
  return
194
212
 
195
- print(output)
213
+ sys.stdout.flush()
214
+ buffer.write(text.encode("utf-8"))
215
+ buffer.flush()
196
216
 
197
217
 
198
218
  _APP = CLIApplication()
@@ -275,61 +295,38 @@ Examples:
275
295
  default="all",
276
296
  help='Wrapper element name (default: "all")',
277
297
  )
298
+ # Each toggle also accepts its --no-* form; the last occurrence wins.
278
299
  conv_group.add_argument(
279
300
  "-r",
280
301
  "--root",
281
302
  dest="root",
282
- action="store_true",
303
+ action=argparse.BooleanOptionalAction,
283
304
  default=True,
284
- help="Include root element (default: true)",
285
- )
286
- conv_group.add_argument(
287
- "--no-root",
288
- dest="root",
289
- action="store_false",
290
- help="Exclude root element",
305
+ help="Include root element",
291
306
  )
292
307
  conv_group.add_argument(
293
308
  "-p",
294
309
  "--pretty",
295
310
  dest="pretty",
296
- action="store_true",
311
+ action=argparse.BooleanOptionalAction,
297
312
  default=False,
298
- help="Pretty print output (default: false)",
299
- )
300
- conv_group.add_argument(
301
- "--no-pretty",
302
- dest="pretty",
303
- action="store_false",
304
- help="Disable pretty printing",
313
+ help="Pretty print output",
305
314
  )
306
315
  conv_group.add_argument(
307
316
  "-t",
308
317
  "--type",
309
318
  dest="attr_type",
310
- action="store_true",
319
+ action=argparse.BooleanOptionalAction,
311
320
  default=True,
312
- help="Include type attributes (default: true)",
313
- )
314
- conv_group.add_argument(
315
- "--no-type",
316
- dest="attr_type",
317
- action="store_false",
318
- help="Exclude type attributes",
321
+ help="Include type attributes",
319
322
  )
320
323
  conv_group.add_argument(
321
324
  "-i",
322
325
  "--item-wrap",
323
326
  dest="item_wrap",
324
- action="store_true",
327
+ action=argparse.BooleanOptionalAction,
325
328
  default=True,
326
- help="Wrap list items in <item> elements (default: true)",
327
- )
328
- conv_group.add_argument(
329
- "--no-item-wrap",
330
- dest="item_wrap",
331
- action="store_false",
332
- help="Don't wrap list items",
329
+ help="Wrap list items in <item> elements",
333
330
  )
334
331
  conv_group.add_argument(
335
332
  "-x",
@@ -9,7 +9,7 @@ from fractions import Fraction
9
9
  from functools import lru_cache
10
10
  from io import BytesIO
11
11
  from random import SystemRandom
12
- from typing import Any, Union, cast
12
+ from typing import Any, TypeAlias, Union, cast
13
13
 
14
14
  __lazy_modules__ = ["defusedxml.minidom"]
15
15
 
@@ -141,6 +141,10 @@ def get_unique_id(element: str) -> str:
141
141
  return make_id(element)
142
142
 
143
143
 
144
+ # Only the truthiness of ``ids`` is ever consulted, so any value is a valid argument:
145
+ # the historical list form, a bool, or an int all enable generated id attributes.
146
+ IdsOption: TypeAlias = object
147
+
144
148
  ELEMENT = Union[
145
149
  str,
146
150
  int,
@@ -214,6 +218,19 @@ def _classify(value: Any) -> int:
214
218
  return _KIND_UNSUPPORTED
215
219
 
216
220
 
221
+ # Type attribute values for native JSON types, keyed by exact type like _EXACT_KINDS.
222
+ _EXACT_TYPE_NAMES: dict[type, str] = {
223
+ type(None): "null",
224
+ str: "str",
225
+ int: "int",
226
+ float: "float",
227
+ bool: "bool",
228
+ dict: "dict",
229
+ list: "list",
230
+ tuple: "list",
231
+ }
232
+
233
+
217
234
  def get_xml_type(val: Any) -> str:
218
235
  """
219
236
  Get the XML type of a given value.
@@ -224,21 +241,10 @@ def get_xml_type(val: Any) -> str:
224
241
  Returns:
225
242
  str: The XML type.
226
243
  """
227
- if val is None:
228
- return "null"
229
- val_type = type(val)
230
- if val_type is str:
231
- return "str"
232
- if val_type is int:
233
- return "int"
234
- if val_type is float:
235
- return "float"
236
- if val_type is bool:
237
- return "bool"
238
- if val_type is dict:
239
- return "dict"
240
- if val_type is list or val_type is tuple:
241
- return "list"
244
+ name = _EXACT_TYPE_NAMES.get(type(val))
245
+ if name is not None:
246
+ return name
247
+ # Subclasses and other Number implementations take the historical fallback names.
242
248
  if isinstance(val, str):
243
249
  return "str"
244
250
  if _is_number(val):
@@ -514,7 +520,7 @@ def _append_xpath31(
514
520
 
515
521
  def convert(
516
522
  obj: Any,
517
- ids: Any,
523
+ ids: IdsOption,
518
524
  attr_type: bool,
519
525
  item_func: Callable[[str], str],
520
526
  cdata: bool,
@@ -616,7 +622,7 @@ def list2xml_str(
616
622
 
617
623
  def convert_dict(
618
624
  obj: dict[str, Any],
619
- ids: list[str],
625
+ ids: IdsOption,
620
626
  parent: str,
621
627
  attr_type: bool,
622
628
  item_func: Callable[[str], str],
@@ -647,7 +653,7 @@ def convert_dict(
647
653
 
648
654
  def convert_list(
649
655
  items: Sequence[Any],
650
- ids: list[str] | None,
656
+ ids: IdsOption,
651
657
  parent: str,
652
658
  attr_type: bool,
653
659
  item_func: Callable[[str], str],
@@ -679,7 +685,7 @@ def convert_list(
679
685
  def _append_convert(
680
686
  output: _XMLWriter,
681
687
  obj: Any,
682
- ids: Any,
688
+ ids: IdsOption,
683
689
  attr_type: bool,
684
690
  item_func: Callable[[str], str],
685
691
  cdata: bool,
@@ -755,7 +761,7 @@ def _append_dict2xml_str(
755
761
  list_headers: bool = False,
756
762
  ) -> None:
757
763
  """Append a dict element using the same shape as dict2xml_str."""
758
- ids: list[str] = []
764
+ ids: IdsOption = None
759
765
  attr = dict(attr)
760
766
 
761
767
  if attr_type:
@@ -821,7 +827,7 @@ def _append_dict2xml_str(
821
827
  def _append_rawitem(
822
828
  output: _XMLWriter,
823
829
  rawitem: Any,
824
- ids: list[str],
830
+ ids: IdsOption,
825
831
  attr_type: bool,
826
832
  item_func: Callable[[str], str],
827
833
  cdata: bool,
@@ -863,7 +869,7 @@ def _append_list2xml_str(
863
869
  item_wrap: bool,
864
870
  list_headers: bool = False,
865
871
  ) -> None:
866
- ids: list[str] = []
872
+ ids: IdsOption = None
867
873
  attr = dict(attr)
868
874
  if attr_type:
869
875
  attr["type"] = get_xml_type(item)
@@ -908,7 +914,7 @@ def _append_list2xml_str(
908
914
  def _append_convert_dict(
909
915
  output: _XMLWriter,
910
916
  obj: dict[str, Any],
911
- ids: list[str],
917
+ ids: IdsOption,
912
918
  parent: str,
913
919
  attr_type: bool,
914
920
  item_func: Callable[[str], str],
@@ -977,7 +983,7 @@ def _append_convert_dict(
977
983
  def _append_convert_list(
978
984
  output: _XMLWriter,
979
985
  items: Sequence[Any],
980
- ids: list[str] | None,
986
+ ids: IdsOption,
981
987
  parent: str,
982
988
  attr_type: bool,
983
989
  item_func: Callable[[str], str],
@@ -1067,9 +1073,18 @@ def _append_convert_list(
1067
1073
  raise TypeError(f"Unsupported data type: {item} ({type(item).__name__})")
1068
1074
 
1069
1075
 
1076
+ _DATE_LIKE_TYPES = (datetime.datetime, datetime.date, datetime.time)
1077
+
1078
+
1070
1079
  def convert_kv(
1071
1080
  key: str,
1072
- val: str | int | float | numbers.Number | datetime.datetime | datetime.date,
1081
+ val: str
1082
+ | int
1083
+ | float
1084
+ | numbers.Number
1085
+ | datetime.datetime
1086
+ | datetime.date
1087
+ | datetime.time,
1073
1088
  attr_type: bool,
1074
1089
  attr: dict[str, Any] | None = None,
1075
1090
  cdata: bool = False,
@@ -1078,10 +1093,8 @@ def convert_kv(
1078
1093
  attr = dict(attr) if attr else {}
1079
1094
  key, attr = make_valid_xml_name(key, attr)
1080
1095
 
1081
- # Convert datetime to isoformat string
1082
- if hasattr(val, "isoformat") and isinstance(
1083
- val, (datetime.datetime, datetime.date)
1084
- ):
1096
+ # Date-like values serialize as ISO text, matching the main serializer path.
1097
+ if isinstance(val, _DATE_LIKE_TYPES):
1085
1098
  val = val.isoformat()
1086
1099
 
1087
1100
  if attr_type:
@@ -1094,15 +1107,19 @@ def convert_kv(
1094
1107
 
1095
1108
  def convert_kv_valid_name(
1096
1109
  key: str,
1097
- val: str | int | float | numbers.Number | datetime.datetime | datetime.date,
1110
+ val: str
1111
+ | int
1112
+ | float
1113
+ | numbers.Number
1114
+ | datetime.datetime
1115
+ | datetime.date
1116
+ | datetime.time,
1098
1117
  attr_type: bool,
1099
1118
  attr: dict[str, Any],
1100
1119
  cdata: bool = False,
1101
1120
  ) -> str:
1102
1121
  """Converts a scalar into an XML element when the caller already validated the key."""
1103
- if hasattr(val, "isoformat") and isinstance(
1104
- val, (datetime.datetime, datetime.date)
1105
- ):
1122
+ if isinstance(val, _DATE_LIKE_TYPES):
1106
1123
  val = val.isoformat()
1107
1124
 
1108
1125
  attr_string = (
@@ -1168,12 +1185,12 @@ def convert_none_valid_name(key: str, attr_type: bool, attr: dict[str, Any]) ->
1168
1185
 
1169
1186
  @dataclass(frozen=True, slots=True)
1170
1187
  class SerializerConfig:
1171
- """Normalized options for the pure Python serializer engine."""
1188
+ """Normalized conversion options shared by the backend selector and this engine."""
1172
1189
 
1173
- obj: ELEMENT
1190
+ obj: Any
1174
1191
  root: bool
1175
1192
  custom_root: str
1176
- ids: list[int] | None
1193
+ ids: IdsOption
1177
1194
  attr_type: bool
1178
1195
  item_wrap: bool
1179
1196
  item_func: Callable[[str], str]
@@ -1312,7 +1329,7 @@ def dicttoxml(
1312
1329
  obj: ELEMENT,
1313
1330
  root: bool = True,
1314
1331
  custom_root: str = "root",
1315
- ids: list[int] | None = None,
1332
+ ids: IdsOption = None,
1316
1333
  attr_type: bool = True,
1317
1334
  item_wrap: bool = True,
1318
1335
  item_func: Callable[[str], str] = default_item_func,
@@ -1337,9 +1354,9 @@ def dicttoxml(
1337
1354
  Default is 'root'
1338
1355
  allows you to specify a custom root element.
1339
1356
 
1340
- :param bool ids:
1341
- Default is False
1342
- specifies whether elements get unique ids.
1357
+ :param ids:
1358
+ Default is None
1359
+ any truthy value gives elements unique ids.
1343
1360
 
1344
1361
  :param bool attr_type:
1345
1362
  Default is True
@@ -1484,4 +1501,9 @@ def dicttoxml(
1484
1501
  max_output_bytes=max_output_bytes,
1485
1502
  indent=indent,
1486
1503
  )
1504
+ return serialize(config)
1505
+
1506
+
1507
+ def serialize(config: SerializerConfig) -> bytes:
1508
+ """Render an already normalized configuration; see :func:`dicttoxml`."""
1487
1509
  return _SerializerEngine(config).render()