Source code for pyxsd.document

"""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 transform(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Document | Any: """Applies *fn* to the bound tree. - ``fn(root, *args, **kwargs)`` may return a new tree root (a bound ``SchemaBase`` instance), which becomes the root of a new :class:`Document` against the same schema — or anything else, including ``None``, which is returned unchanged. When a transform mutates the tree in place and returns ``None``, the original document remains available to the caller; no new document is created. A returned document shares this document's report, which reflects the tree as it was parsed, not the transformed shape; :meth:`revalidate` is the refresh path. """ result = fn(self.root, *args, **kwargs) # Imported here (not at module level): a module-level import of # schema_base would drag the element_representatives stack in # behind this module, and that stack's internal import order is # load-bearing — entering it through schema_base from cold # breaks it. Deferred, the import stays cold-safe. from pyxsd.schema_base import SchemaBase if isinstance(result, SchemaBase): return Document( schema=self.schema, root=result, source=self.source, report=self._report, declarations=self._declarations, ) return result
[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)]