Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 7 additions & 42 deletions json2xml/backend_selector.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,10 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Protocol

# Shared with the Python serializer deliberately: the root-name gate below is only
# correct while it uses the exact predicate Python's name resolver starts from.
from .dicttoxml import _is_fast_valid_xml_name


@dataclass(frozen=True, slots=True)
class ConversionRequest:
"""Normalized conversion request shared across backend adapters."""

obj: Any
root: bool
custom_root: str
ids: list[int] | None
attr_type: bool
item_wrap: bool
item_func: Any
cdata: bool
xml_namespaces: dict[str, Any] | None
list_headers: bool
xpath_format: bool
max_output_bytes: int | None = None
indent: str | None = None
from .dicttoxml import SerializerConfig, _is_fast_valid_xml_name


class BackendAdapter(Protocol):
Expand All @@ -34,10 +14,10 @@ class BackendAdapter(Protocol):
def name(self) -> str:
raise NotImplementedError # pragma: no cover

def can_handle(self, request: ConversionRequest) -> bool:
def can_handle(self, request: SerializerConfig) -> bool:
raise NotImplementedError # pragma: no cover

def render(self, request: ConversionRequest) -> bytes:
def render(self, request: SerializerConfig) -> bytes:
raise NotImplementedError # pragma: no cover


Expand All @@ -47,7 +27,7 @@ class BackendSelector:
def __init__(self, *backends: BackendAdapter) -> None:
self._backends = backends

def render(self, request: ConversionRequest) -> bytes:
def render(self, request: SerializerConfig) -> bytes:
for backend in self._backends:
if backend.can_handle(request):
return backend.render(request)
Expand All @@ -56,9 +36,9 @@ def render(self, request: ConversionRequest) -> bytes:

# Types the Rust backend renders byte-identically to the Python serializer. Subclasses are
# excluded on purpose: Python classifies them through its isinstance fallbacks, which the
# native writer does not reproduce.
# native writer does not reproduce. Tuples are excluded because Python applies its
# list-shape rules to them while the native writer only recognizes lists.
_RUST_SCALAR_TYPES = frozenset({str, bool, int, float, type(None)})
_RUST_CONTAINER_TYPES = frozenset({dict, list, tuple})


def _rust_renders_key_identically(key: Any) -> bool:
Expand Down Expand Up @@ -105,23 +85,8 @@ def rust_renders_identically(obj: Any) -> bool:
return False
stack.append(child)
continue
if value_type in _RUST_CONTAINER_TYPES:
if value_type is list:
stack.extend(value)
continue
return False
return True


def has_special_keys(obj: Any) -> bool:
"""Return True when the payload uses Python-only special key semantics."""
if isinstance(obj, dict):
return any(
(isinstance(key, str) and (key.startswith("@") or key.endswith("@flat")))
or has_special_keys(value)
for key, value in obj.items()
)

if isinstance(obj, list):
return any(has_special_keys(item) for item in obj)

return False
83 changes: 37 additions & 46 deletions json2xml/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@

Flags:
-w, --wrapper string Wrapper element name (default "all")
-r, --root Include root element (default true)
-p, --pretty Pretty print output (default false)
-t, --type Include type attributes (default true)
-i, --item-wrap Wrap list items in <item> elements (default true)
-r, --root Include root element (default true, --no-root disables)
-p, --pretty Pretty print output (default false, --no-pretty disables)
-t, --type Include type attributes (default true, --no-type disables)
-i, --item-wrap Wrap list items in <item> elements (default true,
--no-item-wrap disables)
-x, --xpath Use XPath 3.1 json-to-xml format
-o, --output string Output file (default: stdout)
-u, --url string Read JSON from URL
Expand Down Expand Up @@ -186,19 +187,32 @@ def convert(self, data: JSONValue, options: CLIConversionOptions) -> str | bytes
return xml_output

def write_output(self, output: str | bytes, output_file: str | None) -> None:
if isinstance(output, bytes):
output = output.decode("utf-8")
text = output.decode("utf-8") if isinstance(output, bytes) else output
# Both destinations get the same text: the document plus exactly one final
# newline, whether the serializer emitted none, one, or several.
text = text.rstrip("\n") + "\n"

if output_file:
try:
with open(output_file, "w", encoding="utf-8") as file_obj:
file_obj.write(output)
except OSError as error:
print(f"Error writing to file: {error}", file=sys.stderr)
sys.exit(1)
if not output_file:
self._write_stdout(text)
return

print(output)
try:
Path(output_file).write_text(text, encoding="utf-8")
except OSError as error:
exit_with_error(f"Error writing to file: {error}")

@staticmethod
def _write_stdout(text: str) -> None:
"""Write UTF-8 bytes so stdout matches the file output byte for byte."""
buffer = getattr(sys.stdout, "buffer", None)
if buffer is None:
# A text-only replacement such as io.StringIO has no byte layer.
sys.stdout.write(text)
return

sys.stdout.flush()
buffer.write(text.encode("utf-8"))
buffer.flush()


_APP = CLIApplication()
Expand Down Expand Up @@ -281,61 +295,38 @@ def create_parser() -> argparse.ArgumentParser:
default="all",
help='Wrapper element name (default: "all")',
)
# Each toggle also accepts its --no-* form; the last occurrence wins.
conv_group.add_argument(
"-r",
"--root",
dest="root",
action="store_true",
action=argparse.BooleanOptionalAction,
default=True,
help="Include root element (default: true)",
)
conv_group.add_argument(
"--no-root",
dest="root",
action="store_false",
help="Exclude root element",
help="Include root element",
)
conv_group.add_argument(
"-p",
"--pretty",
dest="pretty",
action="store_true",
action=argparse.BooleanOptionalAction,
default=False,
help="Pretty print output (default: false)",
)
conv_group.add_argument(
"--no-pretty",
dest="pretty",
action="store_false",
help="Disable pretty printing",
help="Pretty print output",
)
conv_group.add_argument(
"-t",
"--type",
dest="attr_type",
action="store_true",
action=argparse.BooleanOptionalAction,
default=True,
help="Include type attributes (default: true)",
)
conv_group.add_argument(
"--no-type",
dest="attr_type",
action="store_false",
help="Exclude type attributes",
help="Include type attributes",
)
conv_group.add_argument(
"-i",
"--item-wrap",
dest="item_wrap",
action="store_true",
action=argparse.BooleanOptionalAction,
default=True,
help="Wrap list items in <item> elements (default: true)",
)
conv_group.add_argument(
"--no-item-wrap",
dest="item_wrap",
action="store_false",
help="Don't wrap list items",
help="Wrap list items in <item> elements",
)
conv_group.add_argument(
"-x",
Expand Down
Loading
Loading