Skip to content
Draft
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
5 changes: 3 additions & 2 deletions src/pydantify_common/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .model import PydantifyModel
from .model import PydantifyModel, XMLPydantifyModel
from .helper import model_dump_xml_string, NETCONF_BASE_NS

__all__ = ["PydantifyModel"]
__all__ = ["PydantifyModel", "XMLPydantifyModel", "model_dump_xml_string", "NETCONF_BASE_NS"]
25 changes: 21 additions & 4 deletions src/pydantify_common/helper.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
from lxml import etree
from .model import XMLPydantifyModel
from typing import Any

NETCONF_BASE_NS = "urn:ietf:params:xml:ns:netconf:base:1.0"


def model_dump_xml_string(
model: XMLPydantifyModel, *, pretty_print: bool = False, data_root: bool = False
) -> str:
data = model.model_dump_xml()
"""
Serialize model to XML string.

Args:
model: The XMLPydantifyModel to serialize
pretty_print: If True, format with indentation
data_root: If True, wrap in <data xmlns="...netconf..."> root

Returns:
XML string representation
"""
xml_element = model.model_dump_xml()

if data_root:
# Add `<data xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">` root element
pass
data_elem = etree.Element(
f"{{{NETCONF_BASE_NS}}}data", nsmap={None: NETCONF_BASE_NS}
)
data_elem.append(xml_element)
xml_element = data_elem

return etree.tostring(data, encoding=str, pretty_print=pretty_print)
return etree.tostring(xml_element, encoding=str, pretty_print=pretty_print)
151 changes: 149 additions & 2 deletions src/pydantify_common/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,152 @@ class XMLPydantifyModel(PydantifyModel):
namespace: ClassVar[str]
prefix: ClassVar[str]

def model_dump_xml(self) -> etree.Element:
pass
def model_dump_xml(self, parent_namespace: str | None = None) -> etree._Element:
"""
Serialize model to lxml Element with namespace support.

Args:
parent_namespace: The namespace of the parent element. Used to determine
whether to declare xmlns attribute on this element.

Returns:
lxml Element representing this model.
"""
# Check if this is a wrapper model (single XMLPydantifyModel field)
# If so, delegate to that child model
wrapper_child = self._get_wrapper_child()
if wrapper_child is not None:
return wrapper_child.model_dump_xml(parent_namespace=parent_namespace)

# 1. Get local name from first field's alias
local_name = self._get_xml_local_name()

# 2. Build qualified tag using Clark notation
tag = f"{{{self.namespace}}}{local_name}"

# 3. Only add nsmap if namespace differs from parent
nsmap = None
if self.namespace != parent_namespace:
nsmap = {None: self.namespace} # Default namespace declaration

# 4. Create element
element = etree.Element(tag, nsmap=nsmap)

# 5. Iterate through model fields and add children
self._serialize_fields_to_element(element)

return element

def _get_wrapper_child(self) -> "XMLPydantifyModel | None":
"""
Check if this model is a "wrapper" with a single XMLPydantifyModel field.

Returns:
The child XMLPydantifyModel if this is a wrapper, otherwise None.
"""
fields = self.__class__.model_fields
if len(fields) != 1:
return None

field_name = next(iter(fields.keys()))
value = getattr(self, field_name)

if isinstance(value, XMLPydantifyModel):
return value

return None

def _serialize_fields_to_element(self, element: etree._Element) -> None:
"""
Serialize all model fields as child elements.

Args:
element: The parent element to add children to.
"""
for field_name, field_info in self.__class__.model_fields.items():
value = getattr(self, field_name)
if value is None:
continue

# Get local name from alias (e.g., "config:name" → "name")
alias = field_info.alias or field_name
child_local_name = alias.split(":")[-1] if ":" in alias else alias

if isinstance(value, XMLPydantifyModel):
# Nested model - create element from field alias and namespace from child
child_elem = self._create_child_model_element(
child_local_name, value, self.namespace
)
element.append(child_elem)
elif isinstance(value, list):
for item in value:
if isinstance(item, XMLPydantifyModel):
child_elem = self._create_child_model_element(
child_local_name, item, self.namespace
)
element.append(child_elem)
else:
# Primitive list item
child_tag = f"{{{self.namespace}}}{child_local_name}"
child_elem = etree.SubElement(element, child_tag)
child_elem.text = str(item)
else:
# Primitive value
child_tag = f"{{{self.namespace}}}{child_local_name}"
child_elem = etree.SubElement(element, child_tag)
child_elem.text = str(value)

def _create_child_model_element(
self,
local_name: str,
child_model: "XMLPydantifyModel",
parent_namespace: str
) -> etree._Element:
"""
Create an element for a child XMLPydantifyModel using the parent's field alias
for the element name.

Args:
local_name: The local name for the element (from parent's field alias).
child_model: The child XMLPydantifyModel to serialize.
parent_namespace: The namespace of the parent element.

Returns:
lxml Element with the child's content.
"""
# Use child's namespace for the tag
tag = f"{{{child_model.namespace}}}{local_name}"

# Only add nsmap if child's namespace differs from parent's
nsmap = None
if child_model.namespace != parent_namespace:
nsmap = {None: child_model.namespace}

# Create element
element = etree.Element(tag, nsmap=nsmap)

# Let the child model fill in its children
child_model._serialize_fields_to_element(element)

return element

def _get_xml_local_name(self) -> str:
"""
Extract the local name for this model's XML element.

Uses the first field's alias to extract the local name.
Falls back to the lowercase class name if no fields exist.
"""
# Try to get local name from first field's alias
if self.__class__.model_fields:
first_field_info = next(iter(self.__class__.model_fields.values()))
alias = first_field_info.alias
if alias:
# Extract prefix from "prefix:localname" pattern
# e.g., "configuration:devicename" → "configuration"
parts = alias.split(":")
if len(parts) >= 1:
return parts[0]

# Fallback to lowercase class name
return self.__class__.__name__.lower()
2 changes: 1 addition & 1 deletion tests/examples/with_augment/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Annotated, List, ClassVar

from pydantic import ConfigDict, Field, RootModel
from pydantic import ConfigDict, Field
from pydantify_common.model import XMLPydantifyModel


Expand Down
2 changes: 1 addition & 1 deletion tests/examples/with_import_uses/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Annotated, List, ClassVar

from pydantic import ConfigDict, Field, RootModel
from pydantic import ConfigDict, Field
from pydantify_common.model import XMLPydantifyModel


Expand Down
1 change: 0 additions & 1 deletion tests/test_examples.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from pathlib import Path
from pydantify_common.model import XMLPydantifyModel
from pydantify_common.helper import model_dump_xml_string
from lxml import etree

Expand Down
Loading