fixing docs
This commit is contained in:
parent
ab207d8f0b
commit
a1e1b9a1c0
15 changed files with 1775 additions and 198 deletions
|
|
@ -1,158 +1,226 @@
|
||||||
# agentserver/bus.py
|
"""
|
||||||
# Refactored January 01, 2026 – MessageBus with run() pump and out-of-band shutdown
|
bus.py — The central MessageBus and pump for AgentServer v2.1
|
||||||
|
|
||||||
|
This is the beating heart of the organism:
|
||||||
|
- Owns all pipelines (one per listener + permanent system pipeline)
|
||||||
|
- Maintains the routing table (root_tag → Listener(s))
|
||||||
|
- Orchestrates ingress from sockets/gateways
|
||||||
|
- Dispatches prepared messages to handlers
|
||||||
|
- Processes handler responses (multi-payload extraction, provenance injection)
|
||||||
|
- Guarantees thread continuity and diagnostic injection
|
||||||
|
|
||||||
|
Fully aligned with:
|
||||||
|
- listener-class-v2.1.md
|
||||||
|
- configuration-v2.1.md
|
||||||
|
- message-pump-v2.1.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
from dataclasses import dataclass
|
||||||
from typing import AsyncIterator, Callable, Dict, Optional, Awaitable
|
from typing import Callable, Awaitable, List
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from agentserver.xml_listener import XMLListener
|
from agentserver.message_bus.message_state import MessageState, HandlerMetadata
|
||||||
from agentserver.utils.message import repair_and_canonicalize, XmlTamperError
|
from agentserver.message_bus.steps.repair import repair_step
|
||||||
|
from agentserver.message_bus.steps.c14n import c14n_step
|
||||||
|
from agentserver.message_bus.steps.envelope_validation import envelope_validation_step
|
||||||
|
from agentserver.message_bus.steps.payload_extraction import payload_extraction_step
|
||||||
|
from agentserver.message_bus.steps.thread_assignment import thread_assignment_step
|
||||||
|
from agentserver.message_bus.steps.xsd_validation import xsd_validation_step
|
||||||
|
from agentserver.message_bus.steps.deserialization import deserialization_step
|
||||||
|
from agentserver.message_bus.steps.routing_resolution import routing_resolution_step
|
||||||
|
|
||||||
# Constants for Internal Physics
|
# Type alias for pipeline steps
|
||||||
ENV_NS = "https://xml-pipeline.org/ns/envelope/1"
|
PipelineStep = Callable[[MessageState], Awaitable[MessageState]]
|
||||||
ENV = f"{{{ENV_NS}}}"
|
|
||||||
LOG_TAG = "{https://xml-pipeline.org/ns/logger/1}log"
|
|
||||||
|
|
||||||
logger = logging.getLogger("agentserver.bus")
|
|
||||||
|
@dataclass
|
||||||
|
class Listener:
|
||||||
|
"""Registered capability — defined in listener.py, referenced here."""
|
||||||
|
name: str
|
||||||
|
payload_class: type
|
||||||
|
handler: Callable
|
||||||
|
description: str
|
||||||
|
is_agent: bool = False
|
||||||
|
peers: list[str] | None = None
|
||||||
|
broadcast: bool = False
|
||||||
|
pipeline: "Pipeline" | None = None
|
||||||
|
schema: etree.XMLSchema | None = None # cached at registration
|
||||||
|
|
||||||
|
|
||||||
|
class Pipeline:
|
||||||
|
"""One dedicated pipeline per listener (plus system pipeline)."""
|
||||||
|
def __init__(self, steps: List[PipelineStep]):
|
||||||
|
self.steps = steps
|
||||||
|
|
||||||
|
async def process(self, initial_state: MessageState) -> None:
|
||||||
|
"""Run the full ordered pipeline on a message."""
|
||||||
|
state = initial_state
|
||||||
|
for step in self.steps:
|
||||||
|
try:
|
||||||
|
state = await step(state)
|
||||||
|
if state.error:
|
||||||
|
break
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
state.error = f"Pipeline step {step.__name__} crashed: {exc}"
|
||||||
|
break
|
||||||
|
|
||||||
|
# After all steps — dispatch if routable
|
||||||
|
if state.target_listeners:
|
||||||
|
await MessageBus.get_instance().dispatcher(state)
|
||||||
|
else:
|
||||||
|
# Fall back to system pipeline for diagnostics
|
||||||
|
await MessageBus.get_instance().system_pipeline.process(state)
|
||||||
|
|
||||||
|
|
||||||
class MessageBus:
|
class MessageBus:
|
||||||
"""The sovereign message carrier.
|
"""Singleton message bus — the pump."""
|
||||||
|
_instance: "MessageBus" | None = None
|
||||||
|
|
||||||
- Routes canonical XML trees by root tag and <to/> meta.
|
def __init__(self):
|
||||||
- Pure dispatch: tree → optional response tree.
|
self.routing_table: dict[str, List[Listener]] = {} # root_tag → listener(s)
|
||||||
- Active pump via run(): handles serialization and egress.
|
self.listeners: dict[str, Listener] = {} # name → Listener
|
||||||
- Out-of-band shutdown via asyncio.Event (fast-path, flood-immune).
|
self.system_pipeline = Pipeline(self._build_system_steps())
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, log_hook: Callable[[etree._Element], None]):
|
@classmethod
|
||||||
# root_tag -> {agent_name -> XMLListener}
|
def get_instance(cls) -> "MessageBus":
|
||||||
self.listeners: Dict[str, Dict[str, XMLListener]] = {}
|
if cls._instance is None:
|
||||||
# Global lookup for directed <to/> routing
|
cls._instance = MessageBus()
|
||||||
self.global_names: Dict[str, XMLListener] = {}
|
return cls._instance
|
||||||
|
|
||||||
# The Sovereign Witness hook
|
# ------------------------------------------------------------------ #
|
||||||
self._log_hook = log_hook
|
# Default step lists
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
# Out-of-band shutdown signal (set only by AgentServer on privileged command)
|
def _build_default_listener_steps(self) -> List[PipelineStep]:
|
||||||
self.shutdown_event = asyncio.Event()
|
return [
|
||||||
|
repair_step,
|
||||||
async def register_listener(self, listener: XMLListener) -> None:
|
c14n_step,
|
||||||
"""Register an organ. Enforces global identity uniqueness."""
|
envelope_validation_step,
|
||||||
if listener.agent_name in self.global_names:
|
payload_extraction_step,
|
||||||
raise ValueError(f"Identity collision: {listener.agent_name}")
|
thread_assignment_step,
|
||||||
|
xsd_validation_step,
|
||||||
self.global_names[listener.agent_name] = listener
|
deserialization_step,
|
||||||
for tag in listener.listens_to:
|
routing_resolution_step,
|
||||||
tag_dict = self.listeners.setdefault(tag, {})
|
|
||||||
tag_dict[listener.agent_name] = listener
|
|
||||||
|
|
||||||
logger.info(f"Registered organ: {listener.agent_name}")
|
|
||||||
|
|
||||||
async def deliver_bytes(self, raw_xml: bytes, client_id: Optional[str] = None) -> None:
|
|
||||||
"""Air Lock: ingest raw bytes, repair/canonicalize, inject into core."""
|
|
||||||
try:
|
|
||||||
envelope_tree = repair_and_canonicalize(raw_xml)
|
|
||||||
await self.dispatch(envelope_tree, client_id)
|
|
||||||
except XmlTamperError as e:
|
|
||||||
logger.warning(f"Air Lock Reject: {e}")
|
|
||||||
|
|
||||||
async def dispatch(
|
|
||||||
self,
|
|
||||||
envelope_tree: etree._Element,
|
|
||||||
client_id: Optional[str] = None,
|
|
||||||
) -> etree._Element | None:
|
|
||||||
"""Pure routing heart. Returns validated response tree or None."""
|
|
||||||
# 1. WITNESS – every canonical envelope is seen
|
|
||||||
self._log_hook(envelope_tree)
|
|
||||||
|
|
||||||
# 2. Extract envelope metadata
|
|
||||||
meta = envelope_tree.find(f"{ENV}meta")
|
|
||||||
if meta is None:
|
|
||||||
return None
|
|
||||||
from_name = meta.findtext(f"{ENV}from")
|
|
||||||
to_name = meta.findtext(f"{ENV}to")
|
|
||||||
thread_id = meta.findtext(f"{ENV}thread_id") or meta.findtext(f"{ENV}thread")
|
|
||||||
|
|
||||||
# Find payload (first non-meta child)
|
|
||||||
payload_elem = next((c for c in envelope_tree if c.tag != f"{ENV}meta"), None)
|
|
||||||
if payload_elem is None:
|
|
||||||
return None
|
|
||||||
payload_tag = payload_elem.tag
|
|
||||||
|
|
||||||
# 3. AUTONOMIC REFLEX: explicit <log/>
|
|
||||||
if payload_tag == LOG_TAG:
|
|
||||||
self._log_hook(envelope_tree) # extra vent
|
|
||||||
# Minimal ack envelope
|
|
||||||
ack = etree.Element(f"{ENV}message")
|
|
||||||
meta_ack = etree.SubElement(ack, f"{ENV}meta")
|
|
||||||
etree.SubElement(meta_ack, f"{ENV}from").text = "system"
|
|
||||||
if from_name:
|
|
||||||
etree.SubElement(meta_ack, f"{ENV}to").text = from_name
|
|
||||||
if thread_id:
|
|
||||||
etree.SubElement(meta_ack, f"{ENV}thread_id").text = thread_id
|
|
||||||
etree.SubElement(ack, "logged", status="success")
|
|
||||||
return ack
|
|
||||||
|
|
||||||
# 4. ROUTING
|
|
||||||
listeners_for_tag = self.listeners.get(payload_tag, {})
|
|
||||||
response_tree: Optional[etree._Element] = None
|
|
||||||
responding_agent_name = "unknown"
|
|
||||||
|
|
||||||
if to_name:
|
|
||||||
# Directed
|
|
||||||
target = listeners_for_tag.get(to_name) or self.global_names.get(to_name)
|
|
||||||
if target:
|
|
||||||
responding_agent_name = target.agent_name
|
|
||||||
response_tree = await target.handle(envelope_tree, thread_id, from_name or client_id)
|
|
||||||
else:
|
|
||||||
# Broadcast – first non-None wins (current policy)
|
|
||||||
tasks = [
|
|
||||||
l.handle(envelope_tree, thread_id, from_name or client_id)
|
|
||||||
for l in listeners_for_tag.values()
|
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
for listener, result in zip(listeners_for_tag.values(), results):
|
|
||||||
if isinstance(result, etree._Element):
|
|
||||||
responding_agent_name = listener.agent_name
|
|
||||||
response_tree = result
|
|
||||||
break # first-wins
|
|
||||||
|
|
||||||
# 5. IDENTITY INSPECTION – prevent spoofing
|
def _build_system_steps(self) -> List[PipelineStep]:
|
||||||
if response_tree is not None:
|
"""Shorter, fixed steps — no XSD/deserialization."""
|
||||||
actual_from = response_tree.findtext(f"{ENV}meta/{ENV}from")
|
return [
|
||||||
if actual_from != responding_agent_name:
|
repair_step,
|
||||||
logger.critical(
|
c14n_step,
|
||||||
f"IDENTITY THEFT BLOCKED: expected {responding_agent_name}, got {actual_from}"
|
envelope_validation_step,
|
||||||
|
payload_extraction_step,
|
||||||
|
thread_assignment_step,
|
||||||
|
# system-specific handler that emits <huh>, boot, etc.
|
||||||
|
self.system_handler_step,
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Registration (called from listener.py)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def register_listener(self, listener: Listener) -> None:
|
||||||
|
root_tag = f"{listener.name.lower()}.{listener.payload_class.__name__.lower()}"
|
||||||
|
|
||||||
|
if root_tag in self.routing_table and not listener.broadcast:
|
||||||
|
raise ValueError(f"Root tag collision: {root_tag} already registered by {self.routing_table[root_tag][0].name}")
|
||||||
|
|
||||||
|
# Build dedicated pipeline
|
||||||
|
steps = self._build_default_listener_steps()
|
||||||
|
# Inject listener-specific schema for xsd_validation_step
|
||||||
|
for step in steps:
|
||||||
|
if step.__name__ == "xsd_validation_step":
|
||||||
|
# We'll modify state.metadata in pipeline construction instead
|
||||||
|
pass
|
||||||
|
listener.pipeline = Pipeline(steps)
|
||||||
|
|
||||||
|
# Insert into routing
|
||||||
|
self.routing_table.setdefault(root_tag, []).append(listener)
|
||||||
|
self.listeners[listener.name] = listener
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Dispatcher — dumb fire-and-await
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
async def dispatcher(self, state: MessageState) -> None:
|
||||||
|
if not state.target_listeners:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata = HandlerMetadata(
|
||||||
|
thread_id=state.thread_id or "",
|
||||||
|
from_id=state.from_id or "unknown",
|
||||||
|
own_name=state.target_listeners[0].name if state.target_listeners[0].is_agent else None,
|
||||||
|
is_self_call=(state.from_id == state.target_listeners[0].name) if state.from_id else False,
|
||||||
)
|
)
|
||||||
return None
|
|
||||||
|
|
||||||
return response_tree
|
if len(state.target_listeners) == 1:
|
||||||
|
listener = state.target_listeners[0]
|
||||||
|
await self._process_single_handler(state, listener, metadata)
|
||||||
|
else:
|
||||||
|
# Broadcast — fire all in parallel, process responses as they complete
|
||||||
|
tasks = [
|
||||||
|
self._process_single_handler(state, listener, metadata)
|
||||||
|
for listener in state.target_listeners
|
||||||
|
]
|
||||||
|
for future in asyncio.as_completed(tasks):
|
||||||
|
await future
|
||||||
|
|
||||||
async def run(
|
async def _process_single_handler(self, state: MessageState, listener: Listener, metadata: HandlerMetadata) -> None:
|
||||||
self,
|
|
||||||
inbound: AsyncIterator[etree._Element],
|
|
||||||
outbound: Callable[[bytes], Awaitable[None]],
|
|
||||||
client_id: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Active pump for a connection. Handles serialization and egress."""
|
|
||||||
try:
|
try:
|
||||||
async for envelope_tree in inbound:
|
response_bytes = await listener.handler(state.payload, metadata)
|
||||||
if self.shutdown_event.is_set():
|
|
||||||
break
|
|
||||||
|
|
||||||
response_tree = await self.dispatch(envelope_tree, client_id)
|
if response_bytes is None or not isinstance(response_bytes, bytes):
|
||||||
if response_tree is not None:
|
response_bytes = b"<huh>Handler failed to return valid bytes — missing return or wrong type</huh>"
|
||||||
serialized = etree.tostring(
|
|
||||||
response_tree, encoding="utf-8", pretty_print=True
|
payloads = await self._multi_payload_extract(response_bytes)
|
||||||
|
|
||||||
|
for payload_bytes in payloads:
|
||||||
|
new_state = MessageState(
|
||||||
|
raw_bytes=payload_bytes,
|
||||||
|
thread_id=state.thread_id,
|
||||||
|
from_id=listener.name,
|
||||||
)
|
)
|
||||||
await outbound(serialized)
|
# Route the new payload through normal pipelines
|
||||||
finally:
|
root_tag = self._derive_root_tag(payload_bytes)
|
||||||
# Optional final courtesy message on clean exit
|
targets = self.routing_table.get(root_tag)
|
||||||
goodbye = b"<message xmlns='https://xml-pipeline.org/ns/envelope/1'><goodbye reason='connection-closed'/></message>"
|
if targets:
|
||||||
|
new_state.target_listeners = targets
|
||||||
|
await targets[0].pipeline.process(new_state)
|
||||||
|
else:
|
||||||
|
await self.system_pipeline.process(new_state)
|
||||||
|
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
error_state = MessageState(
|
||||||
|
raw_bytes=b"<huh>Handler crashed</huh>",
|
||||||
|
thread_id=state.thread_id,
|
||||||
|
from_id=listener.name,
|
||||||
|
error=f"Handler {listener.name} crashed: {exc}",
|
||||||
|
)
|
||||||
|
await self.system_pipeline.process(error_state)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Helper methods
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
async def _multi_payload_extract(self, raw_bytes: bytes) -> List[bytes]:
|
||||||
|
# Same logic as before — dummy wrap, repair, extract all root elements
|
||||||
|
# (implementation can be moved to a shared util later)
|
||||||
|
# For now, placeholder — we'll flesh this out in response_processing.py
|
||||||
|
return [raw_bytes] # temporary — will be full extraction
|
||||||
|
|
||||||
|
def _derive_root_tag(self, payload_bytes: bytes) -> str:
|
||||||
|
# Quick parse to get root tag — used only for routing extracted payloads
|
||||||
try:
|
try:
|
||||||
await outbound(goodbye)
|
tree = etree.fromstring(payload_bytes)
|
||||||
|
tag = tree.tag
|
||||||
|
if tag.startswith("{"):
|
||||||
|
return tag.split("}", 1)[1] # strip namespace
|
||||||
|
return tag
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # connection already gone
|
return ""
|
||||||
|
|
||||||
|
async def system_handler_step(self, state: MessageState) -> MessageState:
|
||||||
|
# Emit <huh> or boot message — placeholder for now
|
||||||
|
state.error = state.error or "Unhandled by any listener"
|
||||||
|
return state
|
||||||
45
agentserver/message_bus/steps/deserialization.py
Normal file
45
agentserver/message_bus/steps/deserialization.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""
|
||||||
|
deserialization.py — Convert validated payload_tree into typed dataclass instance.
|
||||||
|
|
||||||
|
After xsd_validation_step confirms the payload conforms to the listener's contract,
|
||||||
|
this step uses the xmlable library to deserialize the lxml Element into the
|
||||||
|
registered @xmlify dataclass.
|
||||||
|
|
||||||
|
The resulting instance is placed in state.payload and handed to the handler.
|
||||||
|
|
||||||
|
Part of AgentServer v2.1 message pump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from xmlable import from_xml # from the xmlable library
|
||||||
|
from agentserver.message_bus.message_state import MessageState
|
||||||
|
|
||||||
|
|
||||||
|
async def deserialization_step(state: MessageState) -> MessageState:
|
||||||
|
"""
|
||||||
|
Deserialize the validated payload_tree into the listener's dataclass.
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- state.payload_tree valid against listener XSD
|
||||||
|
- state.metadata["payload_class"] set to the target dataclass (set at registration)
|
||||||
|
|
||||||
|
On success: state.payload = dataclass instance
|
||||||
|
On failure: state.error set with clear message
|
||||||
|
"""
|
||||||
|
if state.payload_tree is None:
|
||||||
|
state.error = "deserialization_step: no payload_tree (previous step failed)"
|
||||||
|
return state
|
||||||
|
|
||||||
|
payload_class = state.metadata.get("payload_class")
|
||||||
|
if payload_class is None:
|
||||||
|
state.error = "deserialization_step: no payload_class in metadata (listener misconfigured)"
|
||||||
|
return state
|
||||||
|
|
||||||
|
try:
|
||||||
|
# xmlable.from_xml handles namespace-aware deserialization
|
||||||
|
instance = from_xml(payload_class, state.payload_tree)
|
||||||
|
state.payload = instance
|
||||||
|
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
state.error = f"deserialization_step failed: {exc}"
|
||||||
|
|
||||||
|
return state
|
||||||
50
agentserver/message_bus/steps/routing_resolution.py
Normal file
50
agentserver/message_bus/steps/routing_resolution.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""
|
||||||
|
routing_resolution.py — Resolve routing based on derived root tag.
|
||||||
|
|
||||||
|
This is the final preparation step before dispatch.
|
||||||
|
It computes the root tag from the deserialized payload and looks it up in the
|
||||||
|
global routing table (root_tag → list[Listener]).
|
||||||
|
|
||||||
|
On success: state.target_listeners is set
|
||||||
|
On failure: state.error is set → message falls to system pipeline for <huh>
|
||||||
|
|
||||||
|
Part of AgentServer v2.1 message pump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from agentserver.message_bus.message_state import MessageState
|
||||||
|
from agentserver.message_bus.bus import MessageBus
|
||||||
|
|
||||||
|
|
||||||
|
async def routing_resolution_step(state: MessageState) -> MessageState:
|
||||||
|
"""
|
||||||
|
Resolve which listener(s) should handle this payload.
|
||||||
|
|
||||||
|
Root tag = f"{from_id.lower()}.{payload_class_name.lower()}"
|
||||||
|
(from_id is trustworthy — injected by pump)
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Normal unique routing (one listener)
|
||||||
|
- Broadcast (multiple listeners if broadcast: true and same root tag)
|
||||||
|
|
||||||
|
If no match → error, falls to system pipeline.
|
||||||
|
"""
|
||||||
|
if state.payload is None:
|
||||||
|
state.error = "routing_resolution_step: no deserialized payload (previous step failed)"
|
||||||
|
return state
|
||||||
|
|
||||||
|
if state.from_id is None:
|
||||||
|
state.error = "routing_resolution_step: missing from_id (provenance error)"
|
||||||
|
return state
|
||||||
|
|
||||||
|
payload_class_name = type(state.payload).__name__.lower()
|
||||||
|
root_tag = f"{state.from_id.lower()}.{payload_class_name}"
|
||||||
|
|
||||||
|
bus = MessageBus.get_instance()
|
||||||
|
targets = bus.routing_table.get(root_tag)
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
state.error = f"routing_resolution_step: unknown capability root tag '{root_tag}'"
|
||||||
|
return state
|
||||||
|
|
||||||
|
state.target_listeners = targets
|
||||||
|
return state
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
"""
|
"""
|
||||||
payload_extraction.py — Extract the inner payload from the validated <message> envelope.
|
xsd_validation.py — Validate the extracted payload against the listener-specific XSD.
|
||||||
|
|
||||||
After envelope_validation_step confirms a correct outer <message> envelope,
|
After payload_extraction_step isolates the payload_tree and provenance,
|
||||||
this step removes the envelope elements (<thread>, <from>, optional <to>, etc.)
|
this step validates the payload against the XSD that was auto-generated
|
||||||
and isolates the single child element that is the actual payload.
|
from the listener's @xmlify dataclass at registration time.
|
||||||
|
|
||||||
The payload is expected to be exactly one root element (the capability-specific XML).
|
The XSD is cached and pre-loaded. The schema object is injected into
|
||||||
If zero or multiple payload roots are found, we set a clear error — this protects
|
state.metadata["schema"] when the listener's pipeline is built.
|
||||||
against malformed or ambiguous messages.
|
|
||||||
|
Failure here means the payload violates the declared contract — we collect
|
||||||
|
detailed errors for diagnostics.
|
||||||
|
|
||||||
Part of AgentServer v2.1 message pump.
|
Part of AgentServer v2.1 message pump.
|
||||||
"""
|
"""
|
||||||
|
|
@ -15,77 +17,43 @@ Part of AgentServer v2.1 message pump.
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
from agentserver.message_bus.message_state import MessageState
|
from agentserver.message_bus.message_state import MessageState
|
||||||
|
|
||||||
# Envelope namespace for easy reference
|
|
||||||
_ENVELOPE_NS = "https://xml-pipeline.org/ns/envelope/v1"
|
|
||||||
_MESSAGE_TAG = f"{{{ _ENVELOPE_NS }}}message"
|
|
||||||
|
|
||||||
|
async def xsd_validation_step(state: MessageState) -> MessageState:
|
||||||
async def payload_extraction_step(state: MessageState) -> MessageState:
|
|
||||||
"""
|
"""
|
||||||
Extract the single payload element from the validated envelope.
|
Validate state.payload_tree against the listener's cached XSD schema.
|
||||||
|
|
||||||
Expected structure:
|
Requires:
|
||||||
<message xmlns="https://xml-pipeline.org/ns/envelope/v1">
|
- state.payload_tree set
|
||||||
<thread>uuid</thread>
|
- state.metadata["schema"] containing a pre-loaded etree.XMLSchema
|
||||||
<from>sender</from>
|
|
||||||
<!-- optional <to>receiver</to> -->
|
|
||||||
<payload_root> ← this is the one we want
|
|
||||||
...
|
|
||||||
</payload_root>
|
|
||||||
</message>
|
|
||||||
|
|
||||||
On success: state.payload_tree is set to the payload Element.
|
On success: payload is guaranteed to match the contract
|
||||||
On failure: state.error is set with a clear diagnostic.
|
On failure: state.error contains detailed validation messages
|
||||||
"""
|
"""
|
||||||
if state.envelope_tree is None:
|
if state.payload_tree is None:
|
||||||
state.error = "payload_extraction_step: no envelope_tree (previous step failed)"
|
state.error = "xsd_validation_step: no payload_tree (previous extraction failed)"
|
||||||
return state
|
return state
|
||||||
|
|
||||||
# Basic sanity — root must be <message> in correct namespace (already checked by schema,
|
schema = state.metadata.get("schema")
|
||||||
# but we double-check for defence in depth)
|
if schema is None:
|
||||||
if state.envelope_tree.tag != _MESSAGE_TAG:
|
state.error = "xsd_validation_step: no XSD schema in metadata (listener pipeline misconfigured)"
|
||||||
state.error = f"payload_extraction_step: root tag is not <message> in envelope namespace"
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
# Find all direct children that are not envelope control elements
|
if not isinstance(schema, etree.XMLSchema):
|
||||||
# Envelope control elements are: thread, from, to (optional)
|
state.error = "xsd_validation_step: metadata['schema'] is not an XMLSchema object"
|
||||||
payload_candidates = [
|
|
||||||
child
|
|
||||||
for child in state.envelope_tree
|
|
||||||
if not (
|
|
||||||
child.tag in {
|
|
||||||
f"{{{ _ENVELOPE_NS }}}thread",
|
|
||||||
f"{{{ _ENVELOPE_NS }}}from",
|
|
||||||
f"{{{ _ENVELOPE_NS }}}to",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(payload_candidates) == 0:
|
|
||||||
state.error = "payload_extraction_step: no payload element found inside <message>"
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
if len(payload_candidates) > 1:
|
try:
|
||||||
state.error = (
|
# assertValid raises DocumentInvalid with full error log
|
||||||
"payload_extraction_step: multiple payload roots found — "
|
schema.assertValid(state.payload_tree)
|
||||||
"exactly one capability payload element is allowed"
|
|
||||||
)
|
|
||||||
return state
|
|
||||||
|
|
||||||
# Success — exactly one payload element
|
except etree.DocumentInvalid:
|
||||||
payload_element = payload_candidates[0]
|
# Collect all errors for clear diagnostics
|
||||||
|
error_lines = []
|
||||||
|
for error in schema.error_log:
|
||||||
|
error_lines.append(f"{error.level_name}: {error.message} (line {error.line})")
|
||||||
|
state.error = "xsd_validation_step: payload failed contract validation\n" + "\n".join(error_lines)
|
||||||
|
|
||||||
# Optional: capture provenance from envelope for later use
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
# (these will be trustworthy because envelope was validated)
|
state.error = f"xsd_validation_step: unexpected error during validation: {exc}"
|
||||||
thread_elem = state.envelope_tree.find(f"{{{ _ENVELOPE_NS }}}thread")
|
|
||||||
from_elem = state.envelope_tree.find(f"{{{ _ENVELOPE_NS }}}from")
|
|
||||||
|
|
||||||
if thread_elem is not None and thread_elem.text:
|
|
||||||
state.thread_id = thread_elem.text.strip()
|
|
||||||
|
|
||||||
if from_elem is not None and from_elem.text:
|
|
||||||
state.from_id = from_elem.text.strip()
|
|
||||||
|
|
||||||
state.payload_tree = payload_element
|
|
||||||
|
|
||||||
return state
|
return state
|
||||||
20
structure.md
20
structure.md
|
|
@ -19,7 +19,16 @@ xml-pipeline/
|
||||||
│ ├── message_bus/
|
│ ├── message_bus/
|
||||||
│ │ ├── steps/
|
│ │ ├── steps/
|
||||||
│ │ │ ├── __init__.py
|
│ │ │ ├── __init__.py
|
||||||
│ │ │ └── repair_step.py
|
│ │ │ ├── c14n.py
|
||||||
|
│ │ │ ├── deserialization.py
|
||||||
|
│ │ │ ├── envelope_validation.py
|
||||||
|
│ │ │ ├── payload_extraction.py
|
||||||
|
│ │ │ ├── repair.py
|
||||||
|
│ │ │ ├── routing_resolution.py
|
||||||
|
│ │ │ ├── test_c14n.py
|
||||||
|
│ │ │ ├── test_repair.py
|
||||||
|
│ │ │ ├── thread_assignment.py
|
||||||
|
│ │ │ └── xsd_validation.py
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── bus.py
|
│ │ ├── bus.py
|
||||||
│ │ ├── config.py
|
│ │ ├── config.py
|
||||||
|
|
@ -48,14 +57,23 @@ xml-pipeline/
|
||||||
│ │ └── token-scheduling-issues.md
|
│ │ └── token-scheduling-issues.md
|
||||||
│ ├── configuration.md
|
│ ├── configuration.md
|
||||||
│ ├── core-principles-v2.1.md
|
│ ├── core-principles-v2.1.md
|
||||||
|
│ ├── doc_cross_check.md
|
||||||
|
│ ├── handler-contract-v2.1.md
|
||||||
│ ├── listener-class-v2.1.md
|
│ ├── listener-class-v2.1.md
|
||||||
│ ├── message-pump-v2.1.md
|
│ ├── message-pump-v2.1.md
|
||||||
|
│ ├── primitives.md
|
||||||
│ ├── self-grammar-generation.md
|
│ ├── self-grammar-generation.md
|
||||||
│ └── why-not-json.md
|
│ └── why-not-json.md
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── scripts/
|
│ ├── scripts/
|
||||||
│ │ └── generate_organism_key.py
|
│ │ └── generate_organism_key.py
|
||||||
│ └── __init__.py
|
│ └── __init__.py
|
||||||
|
├── xml_pipeline.egg-info/
|
||||||
|
│ ├── PKG-INFO
|
||||||
|
│ ├── SOURCES.txt
|
||||||
|
│ ├── dependency_links.txt
|
||||||
|
│ ├── requires.txt
|
||||||
|
│ └── top_level.txt
|
||||||
├── LICENSE
|
├── LICENSE
|
||||||
├── README.md
|
├── README.md
|
||||||
├── __init__.py
|
├── __init__.py
|
||||||
|
|
|
||||||
0
third_party/xmlable/__init__.py
vendored
Normal file
0
third_party/xmlable/__init__.py
vendored
Normal file
261
third_party/xmlable/_errors.py
vendored
Normal file
261
third_party/xmlable/_errors.py
vendored
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""
|
||||||
|
Colourful & descriptive errors for xmlable
|
||||||
|
- Clear messages
|
||||||
|
- Trace for parsing
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from termcolor import colored
|
||||||
|
from termcolor.termcolor import Color
|
||||||
|
|
||||||
|
from xmlable._utils import typename, AnyType
|
||||||
|
|
||||||
|
|
||||||
|
def trace_note(trace: list[str], arrow_c: Color, node_c: Color):
|
||||||
|
return colored(" > ", arrow_c).join(
|
||||||
|
map(lambda x: colored(x, node_c), trace)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class XErrorCtx:
|
||||||
|
trace: list[str]
|
||||||
|
|
||||||
|
def next(self, node: str):
|
||||||
|
return XErrorCtx(trace=self.trace + [node])
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Custom backtrace to point to location in the file
|
||||||
|
class XError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
short: str,
|
||||||
|
what: str,
|
||||||
|
why: str,
|
||||||
|
ctx: XErrorCtx | None = None,
|
||||||
|
notes: Iterable[str] = [],
|
||||||
|
):
|
||||||
|
super().__init__(colored(short, "red", attrs=["blink"]))
|
||||||
|
self.add_note(colored("What: " + what, "blue"))
|
||||||
|
self.add_note(colored("Why: " + why, "yellow"))
|
||||||
|
if ctx is not None:
|
||||||
|
self.add_note(
|
||||||
|
colored("Where: ", "magenta")
|
||||||
|
+ trace_note(ctx.trace, "light_magenta", "light_cyan")
|
||||||
|
)
|
||||||
|
for note in notes:
|
||||||
|
self.add_note(note)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorTypes:
|
||||||
|
@staticmethod
|
||||||
|
def NonXMlifiedType(t_name: str) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Non XMlified Type",
|
||||||
|
what=f"You attempted to use {t_name} in an xmlified class, but {t_name} is not xmlified",
|
||||||
|
why=f"All types used in an xmlified class must be xmlified",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def InvalidData(ctx: XErrorCtx, val: Any, t_name: str) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Invalid Data",
|
||||||
|
what=f"Could not validate {val} as a valid {t_name}",
|
||||||
|
why=f"Produced xml must be valid",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ParseFailure(
|
||||||
|
ctx: XErrorCtx, text: str | None, t_name: str, caught: Exception
|
||||||
|
) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Parse Failure",
|
||||||
|
what=f"Failed to parse {text} as a {t_name} with error: \n {caught}",
|
||||||
|
why=f"This error implies the xml is not validated against the current xsd, or there is a bug in this type's parser",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def UnexpectedTag(
|
||||||
|
ctx: XErrorCtx, expected_name: str, struct_name: str, tag_found: str
|
||||||
|
) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Unexpected Tag",
|
||||||
|
what=f"Expected {expected_name} but found {tag_found}",
|
||||||
|
why=f"This is a {struct_name} that contains 0..n elements of {expected_name} and no other elements",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def IncorrectType(
|
||||||
|
ctx: XErrorCtx, expected_len: int, struct_name: str, val: Any, name: str
|
||||||
|
) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Incorrect Type",
|
||||||
|
what=f"You have provided {len(val)} values {val} for {name}, but {name} is a {struct_name} that takes only {expected_len} values",
|
||||||
|
why=f"In order to generate xml, the values provided need to be the correct types",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def IncorrectElementTag(
|
||||||
|
ctx: XErrorCtx,
|
||||||
|
struct_name: str,
|
||||||
|
tag_name: str,
|
||||||
|
elem_index: int,
|
||||||
|
tag_expected: str,
|
||||||
|
tag_found: str,
|
||||||
|
) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Incorrect Element Tag",
|
||||||
|
what=f"While parsing {struct_name} {tag_name} we expected element {elem_index} to be {tag_expected}, but found {tag_found}",
|
||||||
|
why=f"The xml representation for {struct_name} requires the correct names in the correct order",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def DuplicateItem(
|
||||||
|
ctx: XErrorCtx, struct_name: str, tag: str, item: str
|
||||||
|
) -> XError:
|
||||||
|
return XError(
|
||||||
|
short=f"Duplicate item in {struct_name}",
|
||||||
|
what=f"In {tag} the item {item} is present more than once",
|
||||||
|
why=f"A set can only contain unique items",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def InvalidDictionaryItem(
|
||||||
|
ctx: XErrorCtx,
|
||||||
|
expected_tag: str,
|
||||||
|
expected_key: str,
|
||||||
|
expected_val: str,
|
||||||
|
dict_tag: str,
|
||||||
|
item_tag: str,
|
||||||
|
) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Invalid item in dictionary",
|
||||||
|
what=f"An unexpected item with {dict_tag} is in dictionary {item_tag}",
|
||||||
|
why=f"Each item must have tag {expected_tag} with children {expected_key} and {expected_val}",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def InvalidVariant(
|
||||||
|
ctx: XErrorCtx,
|
||||||
|
name: str,
|
||||||
|
expected_types: list[AnyType],
|
||||||
|
found_type: AnyType | None,
|
||||||
|
found_value: Any,
|
||||||
|
) -> XError:
|
||||||
|
types = " | ".join(map(str, expected_types))
|
||||||
|
return XError(
|
||||||
|
short=f"Datatype not in Union",
|
||||||
|
what=f"{name} is a union of {types}, which does not contain {found_type} (you provided: {found_value})",
|
||||||
|
why=f"... uuuh, its a union?",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def MultipleVariants(ctx: XErrorCtx, variant_names: list[str]) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Multiple union variants present",
|
||||||
|
what=f"variants {', '.join(variant_names)} are present",
|
||||||
|
why=f"A union can only be one variant at a time",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ParseInvalidVariant(
|
||||||
|
ctx: XErrorCtx, tag: str, named_variants: list[str], found_variant: str
|
||||||
|
) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Invalid Variant",
|
||||||
|
what=f"The union {tag} can contain variants {', '.join(named_variants)}, but you have used {found_variant}",
|
||||||
|
why=f"Only valid variants can be parsed",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def NoneIsSome(ctx: XErrorCtx, name: str, val: Any) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="None object is not None",
|
||||||
|
what=f"{name} contains value {val} which is not None",
|
||||||
|
why="A None type object can only contain none",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def NotADataclass(cls: AnyType) -> XError:
|
||||||
|
cls_name: str = typename(cls)
|
||||||
|
return XError(
|
||||||
|
short="Non-Dataclass",
|
||||||
|
what=f"{cls_name} is not a dataclass",
|
||||||
|
why=f"xmlify uses dataclasses to get fields",
|
||||||
|
ctx=XErrorCtx([cls_name]),
|
||||||
|
notes=[f"\nTry:\n@xmlify\n@dataclass\nclass {cls_name}:"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ReservedAttribute(cls: AnyType, attr_name: str) -> XError:
|
||||||
|
cls_name: str = typename(cls)
|
||||||
|
return XError(
|
||||||
|
short=f"Reserved Attribute",
|
||||||
|
what=f"{cls_name}.{attr_name} is used by xmlify, so it cannot be a field of the class",
|
||||||
|
why=f"xmlify aguments {cls_name} by adding methods it can then use for xsd, xml generation and parsing",
|
||||||
|
ctx=XErrorCtx([cls_name]),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def CommentAttribute(cls: AnyType) -> XError:
|
||||||
|
cls_name: str = typename(cls)
|
||||||
|
return XError(
|
||||||
|
short=f"Comment Attribute",
|
||||||
|
what=f"xmlifed classes cannot use comment as an attribute",
|
||||||
|
why=f"comment is used as a tag name for comments by lxml, so comments inserted on xml generation could conflict",
|
||||||
|
ctx=XErrorCtx([cls_name]),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def NonMemberTag(
|
||||||
|
ctx: XErrorCtx, cls: AnyType, tag: str, name: str
|
||||||
|
) -> XError:
|
||||||
|
cls_name: str = typename(cls)
|
||||||
|
return XError(
|
||||||
|
short="Non member tag",
|
||||||
|
what=f"In {tag} {cls_name}.{name} could not be found.",
|
||||||
|
why=f"All members, including {cls_name}.{name} must be present",
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def MissingAttribute(
|
||||||
|
cls: AnyType, required_attrs: set[str], missing_attr: str
|
||||||
|
) -> XError:
|
||||||
|
cls_name: str = typename(cls)
|
||||||
|
return XError(
|
||||||
|
short="Missing Attribute",
|
||||||
|
what=f"The attribute {missing_attr} is missing from {cls_name}",
|
||||||
|
why=f"To be manual_xmlified the attributes: {', '.join(required_attrs)} are required. Try using help(IXmlify)",
|
||||||
|
ctx=XErrorCtx([cls_name]),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def DependencyCycle(cycle: list[AnyType]) -> XError:
|
||||||
|
return XError(
|
||||||
|
short="Dependency Cycle in XSD",
|
||||||
|
what=f"There is a cycle: {'<-'.join(map(str, cycle))}",
|
||||||
|
why="The XSDs for classes are written to the .xsd file in dependency order",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def NotXmlified(cls: AnyType) -> XError:
|
||||||
|
cls_name: str = typename(cls)
|
||||||
|
return XError(
|
||||||
|
short="Not Xmlified",
|
||||||
|
what=f"{cls_name} is not xmlified, and hence cannot have an associated parser",
|
||||||
|
why=f"the .xsd(...) method is required to write_xsd",
|
||||||
|
notes=[f"To fix, try:\n@xmlify\n@dataclass\nclass {cls_name}: ..."],
|
||||||
|
)
|
||||||
67
third_party/xmlable/_io.py
vendored
Normal file
67
third_party/xmlable/_io.py
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""
|
||||||
|
Easy file IO for users
|
||||||
|
- Need to make it obvious when an xml has been overwritten
|
||||||
|
- Easy parsing from a file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
from termcolor import colored
|
||||||
|
from lxml.objectify import parse as objectify_parse
|
||||||
|
from lxml.etree import _ElementTree
|
||||||
|
|
||||||
|
from xmlable._utils import typename
|
||||||
|
from xmlable._xobject import is_xmlified
|
||||||
|
from xmlable._errors import ErrorTypes
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(file_path: str | Path, tree: _ElementTree):
|
||||||
|
print(
|
||||||
|
colored(f"Overwriting {file_path}", "red", attrs=["blink"]), end="..."
|
||||||
|
)
|
||||||
|
with open(file=file_path, mode="wb") as f:
|
||||||
|
tree.write(f, xml_declaration=True, encoding="utf-8", pretty_print=True)
|
||||||
|
print(colored(f"Complete!", "green", attrs=["blink"]))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_file(cls: type, file_path: str | Path) -> Any:
|
||||||
|
"""
|
||||||
|
Parse a file, validate and produce instance of cls
|
||||||
|
INV: cls must be an xmlified class
|
||||||
|
"""
|
||||||
|
if not is_xmlified(cls):
|
||||||
|
raise ErrorTypes.NotXmlified(cls)
|
||||||
|
with open(file=file_path, mode="r") as f:
|
||||||
|
return cls.parse(objectify_parse(f).getroot()) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def write_xsd(
|
||||||
|
file_path: str | Path,
|
||||||
|
cls: type,
|
||||||
|
namespaces: dict[str, str] = {},
|
||||||
|
imports: dict[str, str] = {},
|
||||||
|
):
|
||||||
|
if not is_xmlified(cls):
|
||||||
|
raise ErrorTypes.NonXMlifiedType(typename(cls))
|
||||||
|
else:
|
||||||
|
write_file(file_path, cls.xsd(namespaces=namespaces, imports=imports)) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_template(
|
||||||
|
file_path: str | Path, cls: type, schema_name: str | None = None
|
||||||
|
):
|
||||||
|
if not is_xmlified(cls):
|
||||||
|
raise ErrorTypes.NonXMlifiedType(typename(cls))
|
||||||
|
else:
|
||||||
|
schema_id: str = (
|
||||||
|
schema_name if schema_name is not None else typename(cls)
|
||||||
|
)
|
||||||
|
write_file(file_path, cls.xml(schema_id)) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_value(file_path: str | Path, val: Any):
|
||||||
|
cls = type(val)
|
||||||
|
if not is_xmlified(cls):
|
||||||
|
raise ErrorTypes.NonXMlifiedType(typename(cls))
|
||||||
|
else:
|
||||||
|
write_file(file_path, val.xml_value()) # type: ignore[attr-defined]
|
||||||
33
third_party/xmlable/_lxml_helpers.py
vendored
Normal file
33
third_party/xmlable/_lxml_helpers.py
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""
|
||||||
|
Helper functions for wrangling to lxml library
|
||||||
|
- Includes the XMLSchema used
|
||||||
|
"""
|
||||||
|
|
||||||
|
from lxml.objectify import ObjectifiedElement
|
||||||
|
from lxml.etree import _Element
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
XMLURL = r"http://www.w3.org/2001/XMLSchema"
|
||||||
|
XMLSchema = r"{http://www.w3.org/2001/XMLSchema}"
|
||||||
|
|
||||||
|
|
||||||
|
def with_text(e: _Element, text: str) -> _Element:
|
||||||
|
e.text = text
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def with_children(parent: _Element, children: Iterable[_Element]) -> _Element:
|
||||||
|
for child in children:
|
||||||
|
parent.append(child)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
|
def with_child(parent: _Element, child: _Element) -> _Element:
|
||||||
|
return with_children(parent, [child])
|
||||||
|
|
||||||
|
|
||||||
|
def children(obj: ObjectifiedElement) -> Iterable[ObjectifiedElement]:
|
||||||
|
def not_comment(child_obj: ObjectifiedElement):
|
||||||
|
return child_obj.tag != "comment"
|
||||||
|
|
||||||
|
return filter(not_comment, obj.getchildren()) # type: ignore[arg-type, operator]
|
||||||
137
third_party/xmlable/_manual.py
vendored
Normal file
137
third_party/xmlable/_manual.py
vendored
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""
|
||||||
|
The @manual_xmlify decorator used to add the .xsd, .xml, .xml_value and .parse
|
||||||
|
methods to a class that already has .xsd_dependencies, .xsd_forward and
|
||||||
|
.get_xobject
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from lxml.etree import _Element, Element, _ElementTree, ElementTree
|
||||||
|
from lxml.objectify import ObjectifiedElement
|
||||||
|
|
||||||
|
from xmlable._utils import typename, AnyType, ordered_iter
|
||||||
|
from xmlable._lxml_helpers import with_children, XMLSchema
|
||||||
|
from xmlable._errors import XError, XErrorCtx, ErrorTypes
|
||||||
|
|
||||||
|
|
||||||
|
def validate_manual_class(cls: AnyType):
|
||||||
|
attrs = {"get_xobject", "xsd_forward", "xsd_dependencies"}
|
||||||
|
for attr in attrs:
|
||||||
|
if not hasattr(cls, attr):
|
||||||
|
raise ErrorTypes.MissingAttribute(cls, attrs, attr)
|
||||||
|
|
||||||
|
|
||||||
|
def type_cycle(from_type: AnyType) -> list[AnyType]:
|
||||||
|
# INV: it is an xmlified type for a user define structure
|
||||||
|
cycle: list[AnyType] = []
|
||||||
|
|
||||||
|
def visit_dep(curr: AnyType) -> bool:
|
||||||
|
if curr == from_type or any(
|
||||||
|
visit_dep(dep) for dep in ordered_iter(curr.xsd_dependencies()) # type: ignore[attr-defined]
|
||||||
|
):
|
||||||
|
cycle.append(curr)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
assert visit_dep(from_type)
|
||||||
|
cycle.append(from_type)
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
|
||||||
|
def manual_xmlify(cls: type) -> type:
|
||||||
|
"""
|
||||||
|
Generate the following methods:
|
||||||
|
```
|
||||||
|
def xsd(
|
||||||
|
id: str = cls_name,
|
||||||
|
namespaces: dict[str, str] = {},
|
||||||
|
imports: dict[str, str] = {},
|
||||||
|
) -> _ElementTree:
|
||||||
|
# ...
|
||||||
|
|
||||||
|
def xml(schema_name: str = cls_name) -> _ElementTree:
|
||||||
|
# ...
|
||||||
|
|
||||||
|
def xml_value(self, id: str = cls_name) -> _ElementTree:
|
||||||
|
# ...
|
||||||
|
|
||||||
|
def parse(obj: ObjectifiedElement) -> Any:
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_manual_class(cls)
|
||||||
|
cls_name = typename(cls)
|
||||||
|
|
||||||
|
cls_xobject = cls.get_xobject() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def xsd(
|
||||||
|
id: str = cls_name,
|
||||||
|
namespaces: dict[str, str] = {},
|
||||||
|
imports: dict[str, str] = {},
|
||||||
|
) -> _ElementTree:
|
||||||
|
# Get dependencies (user classes that need to be declared before)
|
||||||
|
visited: set[AnyType] = set()
|
||||||
|
dec_order: list[AnyType] = []
|
||||||
|
|
||||||
|
def toposort(
|
||||||
|
curr: AnyType, visited: set[AnyType], dec_order: list[AnyType]
|
||||||
|
):
|
||||||
|
if curr in visited:
|
||||||
|
raise ErrorTypes.DependencyCycle(type_cycle(curr))
|
||||||
|
visited.add(curr)
|
||||||
|
deps = curr.xsd_dependencies() # type: ignore[attr-defined]
|
||||||
|
for d in ordered_iter(deps):
|
||||||
|
if d not in visited:
|
||||||
|
toposort(d, visited, dec_order)
|
||||||
|
dec_order.append(curr)
|
||||||
|
|
||||||
|
toposort(cls, visited, dec_order)
|
||||||
|
|
||||||
|
# Create forward declarations, potentially adding to namespaces
|
||||||
|
decs: list[_Element] = [dec.xsd_forward(namespaces) for dec in dec_order] # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# generate main element (can add to namespaces)
|
||||||
|
main_element = cls_xobject.xsd_out(id, add_ns=namespaces)
|
||||||
|
|
||||||
|
return ElementTree(
|
||||||
|
with_children(
|
||||||
|
Element(
|
||||||
|
f"{XMLSchema}schema",
|
||||||
|
id=id,
|
||||||
|
elementFormDefault="qualified",
|
||||||
|
nsmap=namespaces,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
Element(
|
||||||
|
f"{XMLSchema}import",
|
||||||
|
namespace=ns,
|
||||||
|
schemaLocation=sloc,
|
||||||
|
)
|
||||||
|
for ns, sloc in imports.items()
|
||||||
|
]
|
||||||
|
+ decs
|
||||||
|
+ [main_element],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml(schema_name: str = cls_name) -> _ElementTree:
|
||||||
|
return ElementTree(cls_xobject.xml_temp(schema_name))
|
||||||
|
|
||||||
|
def xml_value(self, id: str = cls_name) -> _ElementTree:
|
||||||
|
return ElementTree(cls_xobject.xml_out(id, self, XErrorCtx([id])))
|
||||||
|
|
||||||
|
def parse(obj: ObjectifiedElement) -> Any:
|
||||||
|
return cls_xobject.xml_in(obj, XErrorCtx([obj.tag]))
|
||||||
|
|
||||||
|
cls.xsd = xsd # type: ignore[attr-defined]
|
||||||
|
cls.xml = xml # type: ignore[attr-defined]
|
||||||
|
setattr(cls, "xml_value", xml_value) # needs to use self to get values
|
||||||
|
cls.parse = parse # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
return cls
|
||||||
|
except XError as e:
|
||||||
|
# NOTE: Trick to remove dirty 'internal' traceback, and raise from
|
||||||
|
# xmlify (makes more sense to user than seeing internals)
|
||||||
|
e.__traceback__ = None
|
||||||
|
raise e
|
||||||
71
third_party/xmlable/_user.py
vendored
Normal file
71
third_party/xmlable/_user.py
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""
|
||||||
|
The IXmlify interface
|
||||||
|
- Contains the methods needed to make get_xobject work
|
||||||
|
- Allows type checking of user's implementations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from lxml.etree import _Element
|
||||||
|
from xmlable._xobject import XObject
|
||||||
|
from xmlable._utils import AnyType
|
||||||
|
|
||||||
|
|
||||||
|
class IXmlify(ABC):
|
||||||
|
"""
|
||||||
|
A useful interface for ensuring the attributes required for
|
||||||
|
@manual_xmlify are present
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abstractmethod
|
||||||
|
def get_xobject() -> XObject:
|
||||||
|
"""
|
||||||
|
produces an xobject encapsulates the:
|
||||||
|
- xsd usage (e.g <xs:element name="..." type="thisclass!"/>)
|
||||||
|
- xml template
|
||||||
|
- xml value
|
||||||
|
- parsing
|
||||||
|
|
||||||
|
```
|
||||||
|
@manual_xmlify
|
||||||
|
class Foo(IXmlify):
|
||||||
|
def get_xobject() -> XObject:
|
||||||
|
class MyObj(XObject):
|
||||||
|
# ... definitions
|
||||||
|
|
||||||
|
return MyObj
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abstractmethod
|
||||||
|
def xsd_forward(add_ns: dict[str, str]) -> _Element:
|
||||||
|
"""
|
||||||
|
Produces the forward declaration
|
||||||
|
- xsd definition of the class's type
|
||||||
|
```
|
||||||
|
@manual_xmlify
|
||||||
|
class Foo(IXmlify):
|
||||||
|
def xsd_forward(add_ns: dict[str, str]) -> _Element:
|
||||||
|
return Element('{XMLSchema}complexType', name="Foo", ...)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abstractmethod
|
||||||
|
def xsd_dependencies() -> set[AnyType]:
|
||||||
|
"""
|
||||||
|
The user classes that need to be before this first
|
||||||
|
|
||||||
|
For example:
|
||||||
|
```
|
||||||
|
@manual_xmlify
|
||||||
|
class A(IXMlify):
|
||||||
|
# xobject uses Foo and Bar
|
||||||
|
def xsd_depedencies() -> set[type]:
|
||||||
|
return {Foo, Bar}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
pass
|
||||||
63
third_party/xmlable/_utils.py
vendored
Normal file
63
third_party/xmlable/_utils.py
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""
|
||||||
|
Basic Utilities
|
||||||
|
Includes common helper functions for this project
|
||||||
|
- Handling optionals
|
||||||
|
- getting members by string name
|
||||||
|
- typenames
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Callable, TypeVar, TypeAlias, Type, Iterable
|
||||||
|
from types import GenericAlias
|
||||||
|
|
||||||
|
AnyType: TypeAlias = Type | GenericAlias
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def some_or(data: T | None, alt: T):
|
||||||
|
return data if data is not None else alt
|
||||||
|
|
||||||
|
|
||||||
|
N = TypeVar("N")
|
||||||
|
M = TypeVar("M")
|
||||||
|
|
||||||
|
|
||||||
|
def some_or_apply(data: N, fn: Callable[[N], M], alt: M):
|
||||||
|
return fn(data) if data is not None else alt
|
||||||
|
|
||||||
|
|
||||||
|
def get(obj: Any, attr: str) -> Any:
|
||||||
|
return obj.__getattribute__(attr)
|
||||||
|
|
||||||
|
|
||||||
|
def opt_get(obj: Any, attr: str) -> Any | None:
|
||||||
|
try:
|
||||||
|
return obj.__getattribute__(attr)
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
X = TypeVar("X")
|
||||||
|
Y = TypeVar("Y")
|
||||||
|
|
||||||
|
|
||||||
|
def firstkey(d: dict[X, Y], val: Y) -> X | None:
|
||||||
|
for k, v in d.items():
|
||||||
|
if v == val:
|
||||||
|
return k
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def typename(t: AnyType) -> str:
|
||||||
|
if t is None:
|
||||||
|
return "None"
|
||||||
|
else:
|
||||||
|
return t.__name__
|
||||||
|
|
||||||
|
|
||||||
|
Z = TypeVar("Z")
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_iter(types: Iterable[Z]) -> list[Z]:
|
||||||
|
return sorted(list(types), key=str)
|
||||||
156
third_party/xmlable/_xmlify.py
vendored
Normal file
156
third_party/xmlable/_xmlify.py
vendored
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""XMLable
|
||||||
|
A decorator to allow creation of xml config based on python dataclasses
|
||||||
|
|
||||||
|
Given a dataclass:
|
||||||
|
- Produce an xsd schema based on the class
|
||||||
|
- Produce an xml template based on the class
|
||||||
|
- Given any instance of the class, make a best-effort attempt at turning it into
|
||||||
|
a filled xml
|
||||||
|
- Create a parser for parsing the xml
|
||||||
|
"""
|
||||||
|
|
||||||
|
from humps import pascalize
|
||||||
|
from dataclasses import fields, is_dataclass
|
||||||
|
from typing import Any, dataclass_transform, cast
|
||||||
|
from lxml.objectify import ObjectifiedElement
|
||||||
|
from lxml.etree import Element, _Element
|
||||||
|
|
||||||
|
from xmlable._utils import get, typename, AnyType
|
||||||
|
from xmlable._errors import XError, XErrorCtx, ErrorTypes
|
||||||
|
from xmlable._manual import manual_xmlify
|
||||||
|
from xmlable._lxml_helpers import with_children, with_child, XMLSchema
|
||||||
|
from xmlable._xobject import XObject, gen_xobject
|
||||||
|
|
||||||
|
|
||||||
|
def validate_class(cls: AnyType):
|
||||||
|
"""
|
||||||
|
Validate tha the class can be xmlified
|
||||||
|
- Must be a dataclass
|
||||||
|
- Cannot have any members called 'comment' (lxml parses comments as this tag)
|
||||||
|
- Cannot have
|
||||||
|
"""
|
||||||
|
if not is_dataclass(cls):
|
||||||
|
raise ErrorTypes.NotADataclass(cls)
|
||||||
|
|
||||||
|
reserved_attrs = ["get_xobject", "xsd_forward", "xsd_dependencies"]
|
||||||
|
|
||||||
|
# TODO: cleanup repetition
|
||||||
|
for f in fields(cls):
|
||||||
|
if f.name in reserved_attrs:
|
||||||
|
raise ErrorTypes.ReservedAttribute(cls, f.name)
|
||||||
|
elif f.name == "comment":
|
||||||
|
raise ErrorTypes.CommentAttribute(cls)
|
||||||
|
|
||||||
|
# JUSTIFY: Could potentially have added other attributes (of the class,
|
||||||
|
# rather than a field of an instance as provided by dataclass
|
||||||
|
# fields)
|
||||||
|
for reserved in reserved_attrs:
|
||||||
|
if hasattr(cls, reserved):
|
||||||
|
raise ErrorTypes.ReservedAttribute(cls, reserved)
|
||||||
|
if hasattr(cls, "comment"):
|
||||||
|
raise ErrorTypes.CommentAttribute(cls)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass_transform()
|
||||||
|
def xmlify(cls: type) -> AnyType:
|
||||||
|
try:
|
||||||
|
validate_class(cls)
|
||||||
|
|
||||||
|
cls_name = typename(cls)
|
||||||
|
forward_decs = cast(set[AnyType], {cls})
|
||||||
|
meta_xobjects = [
|
||||||
|
(
|
||||||
|
pascalize(f.name),
|
||||||
|
f,
|
||||||
|
gen_xobject(cast(AnyType, f.type), forward_decs),
|
||||||
|
)
|
||||||
|
for f in fields(cls)
|
||||||
|
]
|
||||||
|
|
||||||
|
class UserXObject(XObject):
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return Element(
|
||||||
|
f"{XMLSchema}element",
|
||||||
|
name=name,
|
||||||
|
type=cls_name,
|
||||||
|
attrib=attribs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
xobj.xml_temp(pascal_name)
|
||||||
|
for pascal_name, _, xobj in meta_xobjects
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
xobj.xml_out(
|
||||||
|
pascal_name,
|
||||||
|
get(val, m.name),
|
||||||
|
ctx.next(pascal_name),
|
||||||
|
)
|
||||||
|
for pascal_name, m, xobj in meta_xobjects
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> Any:
|
||||||
|
parsed: dict[str, Any] = {}
|
||||||
|
for pascal_name, m, xobj in meta_xobjects:
|
||||||
|
if (m_obj := get(obj, pascal_name)) is not None:
|
||||||
|
parsed[m.name] = xobj.xml_in(
|
||||||
|
m_obj, ctx.next(pascal_name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ErrorTypes.NonMemberTag(ctx, cls, obj.tag, m.name)
|
||||||
|
return cls(**parsed)
|
||||||
|
|
||||||
|
cls_xobject = UserXObject()
|
||||||
|
|
||||||
|
# JUSTIFY: Why are xsd forward & dependencies not part of xobject?
|
||||||
|
# - xobject covers the use (not forward decs)
|
||||||
|
# - we want to present error messages to the user containing
|
||||||
|
# their types, so xsd dependencies are in terms of python
|
||||||
|
# types, rather than xobjects
|
||||||
|
# - forward and dependencies do not apply to the basic types,
|
||||||
|
# only user types
|
||||||
|
|
||||||
|
def xsd_forward(add_ns: dict[str, str]) -> _Element:
|
||||||
|
return with_child(
|
||||||
|
Element(f"{XMLSchema}complexType", name=cls_name),
|
||||||
|
with_children(
|
||||||
|
Element(f"{XMLSchema}sequence"),
|
||||||
|
[
|
||||||
|
xobj.xsd_out(pascal_name, attribs={}, add_ns=add_ns)
|
||||||
|
for pascal_name, m, xobj in meta_xobjects
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def xsd_dependencies() -> set[AnyType]:
|
||||||
|
return forward_decs
|
||||||
|
|
||||||
|
def get_xobject():
|
||||||
|
return cls_xobject
|
||||||
|
|
||||||
|
# helper methods for gen_xobject, and other dataclasses to generate their
|
||||||
|
# x methods
|
||||||
|
cls.xsd_forward = xsd_forward # type: ignore[attr-defined]
|
||||||
|
cls.xsd_dependencies = xsd_dependencies # type: ignore[attr-defined]
|
||||||
|
cls.get_xobject = get_xobject # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
return manual_xmlify(cls)
|
||||||
|
except XError as e:
|
||||||
|
# NOTE: Trick to remove dirty 'internal' traceback, and raise from
|
||||||
|
# xmlify (makes more sense to user than seeing internals)
|
||||||
|
e.__traceback__ = None
|
||||||
|
raise e
|
||||||
640
third_party/xmlable/_xobject.py
vendored
Normal file
640
third_party/xmlable/_xobject.py
vendored
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
"""XObjects
|
||||||
|
XObjects are an intermediate representation for python types -> xsd/xml
|
||||||
|
- Produced by @xmlify decorated classes, and by gen_xobject
|
||||||
|
- Associated xsd, xml and parsing
|
||||||
|
"""
|
||||||
|
|
||||||
|
from humps import pascalize
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import NoneType, UnionType
|
||||||
|
from lxml.objectify import ObjectifiedElement
|
||||||
|
from lxml.etree import Element, Comment, _Element
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable, Type, get_args, TypeAlias, cast
|
||||||
|
from types import GenericAlias
|
||||||
|
|
||||||
|
from xmlable._utils import get, typename, firstkey, AnyType
|
||||||
|
from xmlable._errors import XErrorCtx, ErrorTypes
|
||||||
|
from xmlable._lxml_helpers import (
|
||||||
|
with_text,
|
||||||
|
with_child,
|
||||||
|
with_children,
|
||||||
|
XMLSchema,
|
||||||
|
XMLURL,
|
||||||
|
children,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class XObject(ABC):
|
||||||
|
"""Any XObject wraps the xsd generation,
|
||||||
|
We can map types to XObjects to get the xsd, template xml, etc
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
"""Generate the xsd schema for the object"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
"""
|
||||||
|
Generate commented output for the xml representation
|
||||||
|
- Contains no values, only comments
|
||||||
|
- Not valid xml (can contain nested comments, comments instead of values)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> Any:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BasicObj(XObject):
|
||||||
|
"""
|
||||||
|
An xobject for a simple type (e.g string, int)
|
||||||
|
"""
|
||||||
|
|
||||||
|
type_str: str
|
||||||
|
convert_fn: Callable[[Any], str]
|
||||||
|
validate_fn: Callable[[Any], bool]
|
||||||
|
parse_fn: Callable[[ObjectifiedElement], Any]
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, Any] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
# NOTE: namespace cringe:
|
||||||
|
# - lxml will deal with qualifying namespaces for the name of the
|
||||||
|
# element, but not for attributes
|
||||||
|
# - XMLSchema type attributes must be qualified
|
||||||
|
if (prefix := firstkey(add_ns, XMLURL)) is not None:
|
||||||
|
return Element(
|
||||||
|
f"{XMLSchema}element",
|
||||||
|
name=name,
|
||||||
|
type=f"{prefix}:{self.type_str}",
|
||||||
|
attrib=attribs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# add new namespace, resolve conflicts with extra 's'
|
||||||
|
new_ns = "xs"
|
||||||
|
while new_ns in add_ns:
|
||||||
|
new_ns += "s"
|
||||||
|
add_ns[new_ns] = XMLURL
|
||||||
|
return Element(
|
||||||
|
f"{XMLSchema}element",
|
||||||
|
name=name,
|
||||||
|
type=f"{new_ns}:{self.type_str}",
|
||||||
|
attrib=attribs,
|
||||||
|
nsmap={new_ns: XMLURL},
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return with_text(Element(name), f"Fill me with an {self.type_str}")
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
if not self.validate_fn(val):
|
||||||
|
raise ErrorTypes.InvalidData(ctx, val, self.type_str)
|
||||||
|
return with_text(Element(name), self.convert_fn(val))
|
||||||
|
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> Any:
|
||||||
|
try:
|
||||||
|
return self.parse_fn(obj)
|
||||||
|
except Exception as e:
|
||||||
|
raise ErrorTypes.ParseFailure(ctx, obj.text, self.type_str, e)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListObj(XObject):
|
||||||
|
"""
|
||||||
|
An ordered list of objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
item_xobject: XObject
|
||||||
|
list_elem_name: str
|
||||||
|
struct_name: str = "List"
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return with_child(
|
||||||
|
Element(f"{XMLSchema}element", name=name, attrib=attribs),
|
||||||
|
with_children(
|
||||||
|
Element(f"{XMLSchema}complexType"),
|
||||||
|
[
|
||||||
|
Comment(f"This is a {self.struct_name}"),
|
||||||
|
with_child(
|
||||||
|
Element(f"{XMLSchema}sequence"),
|
||||||
|
self.item_xobject.xsd_out(
|
||||||
|
self.list_elem_name,
|
||||||
|
{"minOccurs": "0", "maxOccurs": "unbounded"},
|
||||||
|
add_ns,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
Comment(f"This is a {self.struct_name}"),
|
||||||
|
self.item_xobject.xml_temp(self.list_elem_name),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
if len(val) > 0:
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
self.item_xobject.xml_out(
|
||||||
|
self.list_elem_name,
|
||||||
|
item_val,
|
||||||
|
ctx.next(f"{self.list_elem_name}[{i}]"),
|
||||||
|
)
|
||||||
|
for i, item_val in enumerate(val)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return with_child(
|
||||||
|
Element(name), Comment(f"Empty {self.struct_name}!")
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> list[Any]:
|
||||||
|
parsed = []
|
||||||
|
for i, child in enumerate(children(obj)):
|
||||||
|
if child.tag != self.list_elem_name:
|
||||||
|
raise ErrorTypes.UnexpectedTag(
|
||||||
|
ctx, self.list_elem_name, self.struct_name, child.tag
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed.append(
|
||||||
|
self.item_xobject.xml_in(
|
||||||
|
child, ctx.next(f"{self.list_elem_name}[{i}]")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructObj(XObject):
|
||||||
|
"""An order list of key-value pairs""" # TODO: make objects variable length tuple
|
||||||
|
|
||||||
|
objects: list[tuple[str, XObject]]
|
||||||
|
struct_name: str = "Struct"
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return with_child(
|
||||||
|
Element(f"{XMLSchema}element", name=name, attrib=attribs),
|
||||||
|
with_child(
|
||||||
|
Element(f"{XMLSchema}complexType"),
|
||||||
|
with_children(
|
||||||
|
Element(f"{XMLSchema}sequence"),
|
||||||
|
[Comment(f"This is a {self.struct_name}")]
|
||||||
|
+ [
|
||||||
|
xobj.xsd_out(member, {}, add_ns)
|
||||||
|
for member, xobj in self.objects
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[Comment(f"This is a {self.struct_name}")]
|
||||||
|
+ [xobj.xml_temp(member) for member, xobj in self.objects],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
if len(val) != len(self.objects):
|
||||||
|
raise ErrorTypes.IncorrectType(
|
||||||
|
ctx, len(self.objects), self.struct_name, val, name
|
||||||
|
)
|
||||||
|
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
xobj.xml_out(member, v, ctx.next(member))
|
||||||
|
for (member, xobj), v in zip(self.objects, val)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_in(
|
||||||
|
self, obj: ObjectifiedElement, ctx: XErrorCtx
|
||||||
|
) -> list[tuple[str, Any]]:
|
||||||
|
parsed = []
|
||||||
|
for i, (child, (name, xobj)) in enumerate(
|
||||||
|
zip(children(obj), self.objects)
|
||||||
|
):
|
||||||
|
if child.tag != name:
|
||||||
|
raise ErrorTypes.IncorrectElementTag(
|
||||||
|
ctx, self.struct_name, obj.tag, i, name, child.tag
|
||||||
|
)
|
||||||
|
parsed.append((name, xobj.xml_in(child, ctx.next(name))))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class TupleObj(XObject):
|
||||||
|
"""An anonymous struct"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
objects: tuple[XObject, ...],
|
||||||
|
elem_gen: Callable[[int], str] = lambda i: f"Item-{i+1}",
|
||||||
|
):
|
||||||
|
self.elem_gen = elem_gen
|
||||||
|
self.struct: StructObj = StructObj(
|
||||||
|
[(self.elem_gen(i), xobj) for i, xobj in enumerate(objects)],
|
||||||
|
struct_name="Tuple",
|
||||||
|
)
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return self.struct.xsd_out(name, attribs, add_ns)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return self.struct.xml_temp(name)
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
return self.struct.xml_out(name, val, ctx)
|
||||||
|
|
||||||
|
def xml_in(
|
||||||
|
self, obj: ObjectifiedElement, ctx: XErrorCtx
|
||||||
|
) -> tuple[Any, ...]:
|
||||||
|
# Assumes the objects are in the correct order
|
||||||
|
return tuple(zip(*self.struct.xml_in(obj, ctx)))[1] # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
class SetOBj(XObject):
|
||||||
|
"""An unordered collection of unique elements"""
|
||||||
|
|
||||||
|
def __init__(self, inner: XObject, elem_name: str = "setitem"):
|
||||||
|
self.list = ListObj(inner, elem_name, struct_name="set")
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return self.list.xsd_out(name, attribs, add_ns)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return self.list.xml_temp(name)
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
return self.list.xml_out(name, list(val), ctx)
|
||||||
|
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> set[Any]:
|
||||||
|
parsed: set[Any] = set()
|
||||||
|
for item in self.list.xml_in(obj, ctx):
|
||||||
|
if item in parsed:
|
||||||
|
raise ErrorTypes.DuplicateItem(ctx, "set", obj.tag, item)
|
||||||
|
parsed.add(item)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DictObj(XObject):
|
||||||
|
"""An unordered collection of key-value pair elements"""
|
||||||
|
|
||||||
|
key_xobject: XObject
|
||||||
|
val_xobject: XObject
|
||||||
|
key_name: str = "Key"
|
||||||
|
val_name: str = "Val"
|
||||||
|
item_name: str = "Item"
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return with_child(
|
||||||
|
Element(f"{XMLSchema}element", name=name, attrib=attribs),
|
||||||
|
with_children(
|
||||||
|
Element(f"{XMLSchema}complexType"),
|
||||||
|
[
|
||||||
|
Comment("this is a dictionary!"),
|
||||||
|
with_child(
|
||||||
|
Element(f"{XMLSchema}sequence"),
|
||||||
|
with_child(
|
||||||
|
Element(
|
||||||
|
f"{XMLSchema}element",
|
||||||
|
name=self.item_name,
|
||||||
|
minOccurs="0",
|
||||||
|
maxOccurs="unbounded",
|
||||||
|
),
|
||||||
|
with_child(
|
||||||
|
Element(f"{XMLSchema}complexType"),
|
||||||
|
with_children(
|
||||||
|
Element(f"{XMLSchema}sequence"),
|
||||||
|
[
|
||||||
|
self.key_xobject.xsd_out(
|
||||||
|
self.key_name, {}, add_ns
|
||||||
|
),
|
||||||
|
self.val_xobject.xsd_out(
|
||||||
|
self.val_name, {}, add_ns
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
Comment("This is a dictionary"),
|
||||||
|
with_children(
|
||||||
|
Element(self.item_name),
|
||||||
|
[
|
||||||
|
self.key_xobject.xml_temp(self.key_name),
|
||||||
|
self.val_xobject.xml_temp(self.val_name),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
item_ctx = ctx.next(self.item_name)
|
||||||
|
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
with_children(
|
||||||
|
Element(self.item_name),
|
||||||
|
[
|
||||||
|
self.key_xobject.xml_out(
|
||||||
|
self.key_name, k, item_ctx.next(name)
|
||||||
|
),
|
||||||
|
self.val_xobject.xml_out(
|
||||||
|
self.val_name, v, item_ctx.next(name)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for k, v in val.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> dict[Any, Any]:
|
||||||
|
parsed = {}
|
||||||
|
for child in children(obj):
|
||||||
|
if child.tag != self.item_name:
|
||||||
|
raise ErrorTypes.InvalidDictionaryItem(
|
||||||
|
ctx,
|
||||||
|
self.item_name,
|
||||||
|
self.key_name,
|
||||||
|
self.val_name,
|
||||||
|
child.tag,
|
||||||
|
obj.tag,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
child_ctx = ctx.next(self.item_name)
|
||||||
|
k = self.key_xobject.xml_in(
|
||||||
|
get(child, self.key_name), child_ctx.next(self.key_name)
|
||||||
|
)
|
||||||
|
v = self.val_xobject.xml_in(
|
||||||
|
get(child, self.val_name), child_ctx.next(self.val_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if k in parsed:
|
||||||
|
raise ErrorTypes.DuplicateItem(
|
||||||
|
ctx, "dictionary", obj.tag, k
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed[k] = v
|
||||||
|
# TODO: Check for other tags? Fail better?
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_type(v: Any) -> AnyType:
|
||||||
|
"""Determine the type of some value, using primitive types
|
||||||
|
- If empty container, only provide top container type
|
||||||
|
INV: only generic types for v are {tuple, list, dict, set}
|
||||||
|
"""
|
||||||
|
t = type(v)
|
||||||
|
if t in {int, float, str, bool, NoneType}:
|
||||||
|
return t
|
||||||
|
elif t == dict and len(v) > 0:
|
||||||
|
t0, t1 = next(iter(v.items()))
|
||||||
|
return dict[resolve_type(t0), resolve_type(t1)] # type: ignore[misc, index, no-any-return]
|
||||||
|
elif t == list and len(v) > 0:
|
||||||
|
return list[resolve_type(v[0])] # type: ignore[misc, index, no-any-return]
|
||||||
|
elif t == set and len(v) > 0:
|
||||||
|
return set[resolve_type(next(iter(v)))] # type: ignore[misc, index, no-any-return]
|
||||||
|
elif t == tuple and len(v) > 0:
|
||||||
|
return tuple[*(resolve_type(vi) for vi in v)] # type: ignore[misc, no-any-return]
|
||||||
|
else:
|
||||||
|
# INV: non-generic type
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UnionObj(XObject):
|
||||||
|
"""A variant, can be one of several different types"""
|
||||||
|
|
||||||
|
xobjects: dict[AnyType, XObject]
|
||||||
|
elem_gen: Callable[[AnyType], str] = lambda t: pascalize(typename(t))
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return with_child(
|
||||||
|
Element(f"{XMLSchema}element", name=name, attrib=attribs),
|
||||||
|
with_children(
|
||||||
|
Element(f"{XMLSchema}complexType"),
|
||||||
|
[
|
||||||
|
Comment("this is a union!"),
|
||||||
|
with_children(
|
||||||
|
Element(f"{XMLSchema}sequence"),
|
||||||
|
[
|
||||||
|
xobj.xsd_out(
|
||||||
|
self.elem_gen(t), {"minOccurs": "0"}, add_ns
|
||||||
|
)
|
||||||
|
for t, xobj in self.xobjects.items()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return with_children(
|
||||||
|
Element(name),
|
||||||
|
[
|
||||||
|
Comment(
|
||||||
|
"This is a union, the following variants are possible, only one can be present"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
xobj.xml_temp(self.elem_gen(t))
|
||||||
|
for t, xobj in self.xobjects.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
t = resolve_type(val)
|
||||||
|
|
||||||
|
if (val_xobj := self.xobjects.get(t)) is not None:
|
||||||
|
variant_name = self.elem_gen(t)
|
||||||
|
return with_child(
|
||||||
|
Element(name),
|
||||||
|
val_xobj.xml_out(variant_name, val, ctx.next(variant_name)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ErrorTypes.InvalidVariant(
|
||||||
|
ctx, name, list(self.xobjects.keys()), t, val
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> Any:
|
||||||
|
named = {self.elem_gen(t): xobj for t, xobj in self.xobjects.items()}
|
||||||
|
variants = list(children(obj))
|
||||||
|
|
||||||
|
if len(variants) != 1:
|
||||||
|
raise ErrorTypes.MultipleVariants(ctx, [v.tag for v in variants])
|
||||||
|
|
||||||
|
variant = variants[0]
|
||||||
|
if (xobj := named.get(variant.tag)) is not None:
|
||||||
|
return xobj.xml_in(variant, ctx.next(variant.tag))
|
||||||
|
else:
|
||||||
|
raise ErrorTypes.ParseInvalidVariant(
|
||||||
|
ctx, str(obj.tag), list(named.keys()), str(variant)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NoneObj(XObject):
|
||||||
|
"""
|
||||||
|
An object representing the python 'None' type
|
||||||
|
- Unions of form `int | None` are used for optionals
|
||||||
|
"""
|
||||||
|
|
||||||
|
def xsd_out(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
attribs: dict[str, str] = {},
|
||||||
|
add_ns: dict[str, str] = {},
|
||||||
|
) -> _Element:
|
||||||
|
return with_child(
|
||||||
|
Element(f"{XMLSchema}element", name=name, attrib=attribs),
|
||||||
|
Comment("This is a None type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def xml_temp(self, name: str) -> _Element:
|
||||||
|
return with_child(Element(name), Comment("This is None"))
|
||||||
|
|
||||||
|
def xml_out(self, name: str, val: Any, ctx: XErrorCtx) -> _Element:
|
||||||
|
if val != None:
|
||||||
|
raise ErrorTypes.NoneIsSome(ctx, name, val)
|
||||||
|
|
||||||
|
return with_child(Element(name), Comment("This is None"))
|
||||||
|
|
||||||
|
def xml_in(self, obj: ObjectifiedElement, ctx: XErrorCtx) -> Any:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_xmlified(cls):
|
||||||
|
return (
|
||||||
|
hasattr(cls, "xsd_forward")
|
||||||
|
and hasattr(cls, "xsd_dependencies")
|
||||||
|
and hasattr(cls, "get_xobject")
|
||||||
|
and hasattr(cls, "xsd")
|
||||||
|
and hasattr(cls, "xml")
|
||||||
|
and hasattr(cls, "xml_value")
|
||||||
|
and hasattr(cls, "parse")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gen_xobject(data_type: AnyType, forward_dec: set[AnyType]) -> XObject:
|
||||||
|
basic_types: dict[
|
||||||
|
AnyType, tuple[str, Callable[[Any], str], Callable[[Any], bool]]
|
||||||
|
] = {
|
||||||
|
int: ("integer", str, lambda d: type(d) == int),
|
||||||
|
str: ("string", str, lambda d: type(d) == str),
|
||||||
|
float: ("decimal", str, lambda d: type(d) == float),
|
||||||
|
bool: (
|
||||||
|
"boolean",
|
||||||
|
lambda b: "true" if b else "false",
|
||||||
|
lambda d: type(d) == bool,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basic_entry := basic_types.get(data_type)) is not None:
|
||||||
|
type_str, convert_fn, validate_fn = basic_entry
|
||||||
|
# NOTE: here was can pass the parse_fn as the data type, as the name is
|
||||||
|
# also a constructor. (e.g. `int` -> `int("23") == 32`)
|
||||||
|
parse_fn = cast(Callable[[ObjectifiedElement], Any], data_type)
|
||||||
|
return BasicObj(type_str, convert_fn, validate_fn, parse_fn)
|
||||||
|
elif isinstance(data_type, NoneType) or data_type == NoneType:
|
||||||
|
# NOTE: Python typing cringe: None can be both a type and a value
|
||||||
|
# (even when within a type hint!)
|
||||||
|
# a: list[None] -> None is an instance of NoneType
|
||||||
|
# a: int | None -> Union of int and NoneType
|
||||||
|
return NoneObj()
|
||||||
|
elif isinstance(data_type, UnionType):
|
||||||
|
return UnionObj(
|
||||||
|
{t: gen_xobject(t, forward_dec) for t in get_args(data_type)}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
t_name = typename(data_type)
|
||||||
|
if t_name == "list":
|
||||||
|
(item_type,) = get_args(data_type)
|
||||||
|
return ListObj(
|
||||||
|
gen_xobject(item_type, forward_dec),
|
||||||
|
pascalize(typename(item_type)),
|
||||||
|
)
|
||||||
|
elif t_name == "dict":
|
||||||
|
key_type, val_type = get_args(data_type)
|
||||||
|
return DictObj(
|
||||||
|
gen_xobject(key_type, forward_dec),
|
||||||
|
gen_xobject(val_type, forward_dec),
|
||||||
|
)
|
||||||
|
elif t_name == "tuple":
|
||||||
|
return TupleObj(
|
||||||
|
tuple(gen_xobject(t, forward_dec) for t in get_args(data_type))
|
||||||
|
)
|
||||||
|
elif t_name == "set":
|
||||||
|
(item_type,) = get_args(data_type)
|
||||||
|
return SetOBj(
|
||||||
|
gen_xobject(item_type, forward_dec),
|
||||||
|
pascalize(typename(item_type)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if is_xmlified(data_type):
|
||||||
|
forward_dec.add(data_type)
|
||||||
|
return data_type.get_xobject() # type: ignore[attr-defined, no-any-return]
|
||||||
|
else:
|
||||||
|
raise ErrorTypes.NonXMlifiedType(t_name)
|
||||||
0
third_party/xmlable/py.typed
vendored
Normal file
0
third_party/xmlable/py.typed
vendored
Normal file
Loading…
Reference in a new issue