"""The schema-bound instance document: the interaction hub.
A :class:`Document` pairs a compiled :class:`~pyxsd.schema.Schema` with
one bound instance tree and the merged
:class:`~pyxsd.validation.ValidationReport` for the run (schema-phase
issues followed by this document's instance-phase issues). It is what
:meth:`pyxsd.schema.Schema.parse` returns, and the object callers
inspect (``report``/``is_valid``/``require_valid``) and write out
(``write``/``to_string``).
"""
from __future__ import annotations
import json
import logging
import os.path
from collections.abc import Callable, Iterator
from io import StringIO
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any
from xml.etree import ElementTree as ET
from pyxsd.exceptions import PyXSDError, ValidationError
from pyxsd.locations import IssueLocation, NodeLookup, ParseProvenance
from pyxsd.tree import iter_tree
from pyxsd.validation import ValidationReport
from pyxsd.writers.xml_tree_writer import XmlTreeWriter
if TYPE_CHECKING:
from pyxsd.dtd_declarations import DocumentDeclarations
from pyxsd.schema import Schema
logger = logging.getLogger(__name__)
def write_tree(root: Any, output: str | Path | os.PathLike[str] | IO[str]) -> None:
"""Sends a pythonic instance tree to the tree writer.
- ``root``: the root instance of a tree. Must be
formatted in the program's tree structure.
- ``output``: the file object (or path) to write the tree to.
Paths are opened and closed here; file objects passed by the
caller are flushed but left open.
"""
if isinstance(output, (str, os.PathLike)):
with open(output, "w", encoding="utf-8") as outputFile:
XmlTreeWriter(root, outputFile)
else:
XmlTreeWriter(root, output)
output.flush()
logger.debug("Data sent to the writer...")
[docs]
class Document:
"""A schema-bound instance document: the interaction hub."""
#: The schema the document was parsed against.
schema: Schema
#: The bound root instance, or ``None`` when binding failed (for
#: example an unknown document root element).
root: Any
#: The source the document was parsed from, as given to ``parse``.
source: str | Path | os.PathLike[str] | IO[str] | ET.Element | None
def __init__(
self,
schema: Schema,
root: Any,
source: str | Path | os.PathLike[str] | IO[str] | ET.Element | None,
report: ValidationReport,
*,
provenance: ParseProvenance | None = None,
declarations: DocumentDeclarations | None = None,
) -> None:
self.schema = schema
self.root = root
self.source = source
self._report = report
self._provenance = provenance
self._declarations = declarations
[docs]
def locate(self, location: IssueLocation) -> NodeLookup:
"""Resolve a source occurrence in this parse, not by value or local name.
Addresses are parse-revision-specific. Manually built/transformed
Documents have no provenance until freshly parsed with revalidate().
Attribute lookups return the original lexical attribute value.
"""
if self._provenance is None:
return NodeLookup(location, None, "missing")
return self._provenance.locate(location)
@property
def report(self) -> ValidationReport:
"""The merged report: schema-phase issues, then instance-phase."""
return self._report
@property
def is_valid(self) -> bool:
"""Whether the report holds no error-severity issues."""
return not self.report.has_errors
[docs]
def require_valid(self) -> None:
"""Raises :class:`pyxsd.ValidationError` if the report has errors."""
if self.report.has_errors:
raise ValidationError(
f"the document is not valid: {self.report.error_count} error(s); "
"inspect ValidationError.report",
self.report,
)
[docs]
def write(self, dest: str | Path | os.PathLike[str] | IO[str]) -> None:
"""Writes the bound tree to *dest* (a path or a file object)."""
if self.root is None:
raise PyXSDError("the document has no root to write")
write_tree(self.root, dest)
[docs]
def to_string(self) -> str:
"""Returns the bound tree serialized as XML text."""
if self.root is None:
raise PyXSDError("the document has no root to write")
output = StringIO()
write_tree(self.root, output)
return output.getvalue()
[docs]
def to_dict(self, *, typed: bool = True, always_list: bool = False) -> dict:
"""Returns the bound tree as a plain dict.
The export convention (child element keys, ``@`` attributes, the
``$`` text key, repeated children as lists) is documented in
``docs/data-model.md``. For an element whose content admits
character data (mixed content, ``xs:anyType``, wildcard
pass-through), ``$`` holds all of its recorded character data in
document order: the leading text followed by every child's
``_tail_``, concatenated. The dict shape cannot represent
text/element interleaving; :meth:`to_string` and :meth:`xpath`
are the faithful mixed-content views.
"""
if self.root is None:
raise PyXSDError("the document has no root to export")
# Deferred for the same cold-import reason as ``transform``: the
# export module pulls in the datatype stack.
from pyxsd.dict_export import bound_to_dict
return bound_to_dict(self.root, typed=typed, always_list=always_list)
[docs]
def to_json(self, *, indent: int | None = None, **to_dict_kwargs: Any) -> str:
"""Returns :meth:`to_dict` encoded as JSON text.
Keyword arguments are forwarded to :meth:`to_dict`. Values the
JSON encoder cannot represent natively (for example a
``decimal.Decimal``) are encoded as strings.
"""
return json.dumps(self.to_dict(**to_dict_kwargs), indent=indent, default=str)
[docs]
def revalidate(self) -> Document:
"""Re-parses the serialized tree, returning a fresh :class:`Document`.
The bound tree is written out and parsed again against the same
schema, so the returned document carries a fresh instance report
for the tree's current shape (transforms may have changed it).
The :attr:`source` is carried over, and so is the original
parse's DTD declaration evidence: serialization drops the
DOCTYPE, so rebinding with the recorded evidence keeps a declared
``ENTITY``/``ENTITIES`` value valid, while complete evidence
still rejects an undeclared replacement value. A document with no
evidence (a caller-built tree, a custom policy parse) stays
lexical-only instead of inventing an empty declaration set.
"""
again = self.schema._parse_instance(
StringIO(self.to_string()), declarations=self._declarations
)
again.source = self.source
return again
[docs]
def walk(self) -> Iterator[Any]:
"""Yields every node of the bound tree, pre-order."""
return iter_tree(self.root)
[docs]
def xpath(self, expr: str, *, namespaces: dict[str, str] | None = None) -> Any:
"""Evaluates an XPath expression over the bound tree.
Returns node results as the **original bound instances** and
passes scalar results (numbers, strings, booleans) through. The
expression is evaluated with the document's root element as the
context item, so an absolute path starts ``/root``. *namespaces*
maps query prefixes to namespace URIs.
The query runs over a projection of the bound tree, so it sees
exactly what the object model holds: descriptor write-through is
reflected, ``text`` mirrors ``_value_`` and a child's ``tail``
mirrors its recorded ``_tail_``, so mixed character data is
queryable in document order. Parsing does not preserve comment
nodes, and an element with ``_value_ is None`` projects
``text=None``. The projection is document-faithful, not
source-XPath-exact: element-only content's recorded layout
whitespace (the tails between block children) is part of the text
a value query returns. See ``docs/data-model.md``.
"""
if self.root is None:
raise PyXSDError("the document has no root to query")
# Deferred for the same cold-import reason as ``to_dict``.
from pyxsd.xpath_api import project, select
et_root, mapping = project(self.root)
results = select(et_root, expr, namespaces=namespaces)
if isinstance(results, list):
return [mapping.get(result, result) for result in results]
return results
[docs]
def find(self, path: str, *, namespaces: dict[str, str] | None = None) -> Any:
"""Finds the first node matching an ElementTree path.
Unlike :meth:`xpath`, this accepts ElementTree's path subset
(``{uri}name`` qualified names, ``*``, ``.``, ``..``, ``//``).
Returns the original bound node, or ``None``. The projection
contract documented on :meth:`xpath` applies here too.
"""
if self.root is None:
raise PyXSDError("the document has no root to query")
from pyxsd.xpath_api import project
et_root, mapping = project(self.root)
found = et_root.find(path, namespaces)
if found is None:
return None
return mapping.get(found, found)
[docs]
def findall(self, path: str, *, namespaces: dict[str, str] | None = None) -> list[Any]:
"""Finds every node matching an ElementTree path.
Like :meth:`find`, but returns a list of the original bound
nodes (possibly empty). See :meth:`xpath` for the projection
contract.
"""
if self.root is None:
raise PyXSDError("the document has no root to query")
from pyxsd.xpath_api import project
et_root, mapping = project(self.root)
return [mapping.get(element, element) for element in et_root.findall(path, namespaces)]