Source code for pyxsd.stubs

"""Inventory of the generated classes a stub file must declare.

A compiled schema's classes are created at runtime from schema
bookkeeping, not authored as Python names: a named type keeps its XSD
local name, but an anonymous inline type is called ``shipTo|complexType``,
a strict-namespace compile registers one class under both a bare and a
Clark name, and two namespaces may declare the same local name. A stub
file needs one plain Python identifier per class.

The anti-goal is a stub that lies: this module never presents a stub
name as the schema's own when it is not one. Every name that had to be
synthesized, sanitized or disambiguated is recorded in the entry's
``note`` (the real bookkeeping name, the namespace a colliding class
belongs to), and ``er`` points at the type declaration the class was
built for whenever the component table still has it — ``None``
otherwise, never a guess. Nothing here calls ``schema.require_valid()``:
an error-bearing schema still compiles classes, and a stub may still be
requested for it.

On top of the inventory, :func:`_members` resolves one class's
renderable members: its own element and attribute descriptors in
declaration order, members attributed from a base class the inventory
does not list (an overlay's generated base), and public functions and
properties an overlay adds beyond the generated descriptors. Element
occurrence is folded through the compiled particle tree, because a
single descriptor's own ``minOccurs``/``maxOccurs`` lie whenever an
ancestor group is optional or repeated — a choice branch, for example,
is absent whenever another branch is taken. Every member carries the
annotation a stub renders and a comment recording what the annotation
cannot say (defaults, nillable, prohibited uses, unresolvable types).

Member annotations model *valid parsed documents* and degrade to ``Any``
with a comment rather than naming a type the stub cannot import: a union
member (a policy degradation — a parsed union value is an instance of
the union's own bookkeeping class, and the comment names the member
types its value space draws from), a recursive back edge (a type
re-entered while its own class resolves), and an unresolvable
declaration all become ``Any`` with the reason in the comment. A
reference to a substitution-group head keeps the head's type, takes the
``None`` arm (a member occurrence satisfies the particle without
filling the head accessor) and comments the member names the runtime
binds instead.
Constructs that reach the instance but have no member to hang on —
mixed content, wildcards, open content — become comment lines on the
class block. The member model is identical in both formatters;
``compact`` records the requested mode, and the compact renderer
rewrites annotation spellings through its own alias table afterwards.

:func:`emit_stub` renders the model as the full ``.pyi`` document: a
provenance and consumption header, imports, one class block per
inventory entry (mirroring the XSD derivation the runtime mirrors, with
simple types as their datatype subclasses and facets as comments), and
the global elements as module-level aliases. ``compact=True`` renders
the agent view instead: a two-line header (provenance and alias
legend), one line per class and per member, facet-only simple types
dropped with their facets inlined onto the members that use them, and a
single trailing comment line listing the root elements. Rendering is
total in both modes: a schema that compiled always emits, and every
construct the stub cannot type truthfully becomes ``Any`` with a
comment instead of an exception.
"""

from __future__ import annotations

import ast
import builtins
import datetime
import decimal
import inspect
import keyword
import os
import re
from collections.abc import Callable, Iterable
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Literal

from pyxsd import __version__, xsd_data_types
from pyxsd.integrations._shape import particle_elements
from pyxsd.namespaces import XSD_NS, clark, local_name

# ``Attribute``, ``Element`` and ``SchemaBase`` are imported inside the
# functions that use them, never at module scope: those modules sit in
# an import cycle that resolves only when
# ``element_representative`` is entered first (the order
# ``pyxsd.schema``'s own imports establish), and importing this module
# must not enter the cycle from a tag module. Every public entry point
# here takes a compiled schema, so the cycle is already resolved by
# call time.

if TYPE_CHECKING:
    from pyxsd.schema import Schema

#: The ER class names that build instance classes. Every other kind in
#: the component table (elements, groups, facets, ...) is not a class.
_TYPE_ER_NAMES = ("SimpleType", "ComplexType")

#: ``Element.getMaxOccurs`` spells "unbounded" as this sentinel; the
#: compiled particle tree normalizes it to ``None``.
_UNBOUNDED = 99999

#: The XSD builtin data types by identity, mapped to the name a stub
#: annotates them with. ``pyxsd.xsd_data_types`` gives every builtin
#: declaration a ``name`` attribute of its own; the private helper
#: classes in the module do not, which is how they are told apart.
#: Builtins have no ``schema.classes`` entry, so the inventory cannot
#: name them; their class name is the importable stub name.
_BUILTIN_STUB_NAMES: dict[type, str] = {
    cls: cls.__name__
    for cls in vars(xsd_data_types).values()
    if isinstance(cls, type)
    and issubclass(cls, xsd_data_types.XsdDataType)
    and "name" in cls.__dict__
}

#: An alphanumeric run; ``-`` and ``.`` (legal in XSD names) separate
#: words in a synthesized name.
_WORD = re.compile(r"[^\W_]+")

#: The typing generics an overlay signature may name, bare or as a
#: ``typing.`` spelling. They are the ones the full stub's import block
#: can bind: a bare name is imported when the rendered body uses it, and
#: a ``typing.X`` spelling is rewritten to the bare ``X``
#: (:func:`_validated_signature`). Anything else -- ``typing.TypedDict``
#: included -- is not in the contract and degrades the signature.
_TYPING_NAMES = frozenset(
    {
        "Any",
        "List",
        "Dict",
        "Optional",
        "Sequence",
        "Iterable",
        "Iterator",
        "Mapping",
        "Set",
        "FrozenSet",
        "Tuple",
        "Union",
        "Callable",
        "Literal",
        "Type",
    }
)

#: The standard library modules an overlay signature may qualify names
#: with, mapped to the module the validator resolves the attribute
#: against. The renderer emits ``import <module>`` exactly when the
#: rendered body uses the qualifier, matching the ``datetime.``
#: precedent.
_QUALIFIER_MODULES: dict[str, Any] = {"datetime": datetime, "decimal": decimal}

#: The names the full stub's import block can bind without the body
#: referencing them: ``SchemaBase`` is imported unconditionally, and a
#: datatype or typing name's own alias text counts as a body reference,
#: so a root element named after one of these would duplicate a binding.
#: The qualifier modules are absent: their import depends on the body,
#: which their root alias is re-checked against after rendering.
_IMPORTED_STUB_NAMES = (
    frozenset({"SchemaBase"}) | frozenset(_BUILTIN_STUB_NAMES.values()) | _TYPING_NAMES
)


@dataclass(frozen=True)
class _StubClass:
    """One generated class and the stub name it renders under.

    - ``cls`` - the generated class object.
    - ``name`` - the stub-safe class name: a valid Python identifier,
      unique within one inventory result. Renderers must read a
      class's own name as ``cls.__name__``: a schema element literally
      named ``name`` replaces the ``name`` attribute in the generated
      class namespace with a descriptor.
    - ``note`` - what had to change for ``name`` to be usable (the real
      bookkeeping name, a sanitization, a collision and the namespace
      involved), or ``None`` when the class's own name already is the
      stub name.
    - ``er`` - the matching type element representative, when the
      component table still has one; ``None`` for classes with no table
      entry (overlays, builtins).
    """

    cls: type
    name: str
    note: str | None = None
    er: Any | None = None


def _inventory(schema: Schema) -> list[_StubClass]:
    """Returns one entry per generated class, in ``schema.classes`` order.

    The ``"schema"`` root-dispatch class is skipped, and a class
    registered under several keys (bare and Clark) is listed once, at
    its first key's position. Names are unique: a later class wanting a
    name already taken gets a deterministic suffix and a note saying
    why. The result is deterministic for a given schema.
    """
    grouped: list[tuple[type, list[str]]] = []
    keys_by_class: dict[int, list[str]] = {}
    for key, cls in schema.classes.items():
        # Only class objects name a stub; the root dispatcher is keyed
        # "schema", and anything else is not renderable.
        if key == "schema" or not isinstance(cls, type):
            continue
        keys = keys_by_class.get(id(cls))
        if keys is None:
            keys = []
            keys_by_class[id(cls)] = keys
            grouped.append((cls, keys))
        keys.append(key)

    type_ers = _type_ers_by_name(schema)
    used: dict[str, _StubClass] = {}
    entries: list[_StubClass] = []
    for cls, keys in grouped:
        er = _match_type_er(type_ers.get(cls.__name__, []), keys)
        base, facts = _base_stub_name(cls.__name__)
        name = base
        earlier = used.get(name)
        if earlier is not None:
            if earlier.cls.__name__ == cls.__name__:
                # The strict-mode case: two namespaces declare the same
                # local name, so the namespace is the only distinguisher.
                name = _suffixed(name, used, "_ns")
                facts.append(_namespace_note(er))
            else:
                # Two different bookkeeping names folded to one
                # identifier by synthesis or sanitization.
                name = _suffixed(name, used, "_")
                facts.append(f"name collision; an earlier class already uses {base!r}")
        entry = _StubClass(cls=cls, name=name, note="; ".join(facts) if facts else None, er=er)
        used[name] = entry
        entries.append(entry)
    return entries


def _base_stub_name(bookkeeping: str) -> tuple[str, list[str]]:
    """Returns the class's base stub name and the note facts behind it.

    An anonymous inline type's ``element|tag`` bookkeeping name is
    synthesized from the element part (PascalCase); anything else is
    taken as-is. Either way the result is sanitized to a Python
    identifier, and every deviation from the class's own name is
    returned as a fact for the entry's note.
    """
    candidate = bookkeeping
    facts: list[str] = []
    if "|" in bookkeeping:
        local = bookkeeping.split("|", 1)[0]
        candidate = _pascal_case(local)
        facts.append(f"bookkeeping name {bookkeeping!r}; stub name synthesized")
    sanitized = _sanitize(candidate)
    if sanitized != candidate:
        if keyword.iskeyword(candidate):
            facts.append(f"Python keyword {candidate!r}; stub name adjusted")
        else:
            facts.append(f"sanitized from {candidate!r}")
    if not sanitized:
        sanitized = "_"
        facts.append("no usable characters in the name")
    return sanitized, facts


def _pascal_case(local: str) -> str:
    """Returns the words of *local*, each capitalized and joined.

    ``shipTo`` becomes ``ShipTo``; separators that are legal in an XSD
    name but not in a Python identifier (``-``, ``.``) are word
    boundaries, so ``ship-to`` also becomes ``ShipTo``.
    """
    return "".join(word[:1].upper() + word[1:] for word in _WORD.findall(local))


def _sanitize(name: str) -> str:
    """Returns *name* as a valid Python identifier, ``_`` standing in.

    Every character that cannot appear in an identifier is replaced
    with ``_``; a leading digit is kept and the name prefixed with
    ``_``, so the result stays recognizable (``9lives`` → ``_9lives``).
    A Python keyword gets a trailing ``_`` (PEP 8's convention for
    keyword clashes, e.g. ``class`` → ``class_``): keywords are legal
    XSD names but not usable class names.
    """
    sanitized = "".join(ch if ("_" + ch).isidentifier() else "_" for ch in name)
    if sanitized[:1].isdigit():
        sanitized = "_" + sanitized
    if keyword.iskeyword(sanitized):
        sanitized += "_"
    return sanitized


def _suffixed(base: str, used: dict[str, _StubClass], marker: str) -> str:
    """Returns the first ``base + marker + n`` (n from 2) not yet used."""
    counter = 2
    while f"{base}{marker}{counter}" in used:
        counter += 1
    return f"{base}{marker}{counter}"


def _type_ers_by_name(schema: Schema) -> dict[str, list[Any]]:
    """Indexes the table's type representatives by their bookkeeping name.

    The name used here is the representative's own ``name`` attribute —
    the value the generated class was created from — not ``getName()``:
    for an anonymous inline type ``getName()`` re-derives the name
    against the now-populated component table and returns a suffixed
    bookkeeping variant that no class is named after.
    """
    by_name: dict[str, list[Any]] = {}
    seen: set[int] = set()
    for representatives in schema.components.values():
        for er in representatives:
            if type(er).__name__ not in _TYPE_ER_NAMES or id(er) in seen:
                continue
            seen.add(id(er))
            by_name.setdefault(er.name, []).append(er)
    return by_name


def _match_type_er(candidates: list[Any], keys: list[str]) -> Any | None:
    """Returns the type ER the class was built for, or ``None``.

    A class's ``schema.classes`` keys identify it: a strict-namespace
    compile registers the class under its bare name and its Clark name,
    and two same-local-name classes from different namespaces are told
    apart by their Clark keys. A candidate whose expanded name is among
    the keys is the class's own declaration; when several candidates
    remain and none is identified, ``None`` is returned rather than a
    guess.
    """
    if not candidates:
        return None
    if len(candidates) == 1:
        return candidates[0]
    key_set = set(keys)
    for er in candidates:
        if clark(er.getNamespace(), er.name) in key_set:
            return er
    return None


def _namespace_note(er: Any | None) -> str:
    """Returns a collision note naming the class's XSD namespace."""
    namespace = er.getNamespace() if er is not None else None
    if isinstance(namespace, str) and namespace:
        return f"name collision; XSD namespace {namespace!r}"
    return "name collision; XSD namespace absent"


# ---------------------------------------------------------------------------
# Member annotation resolution
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class _Member:
    """One class-body member a stub renders.

    - ``name`` - the runtime accessor name (the descriptor's bound
      class-attribute key, or the overlay member's name). Empty for a
      member that has no usable accessor; the renderer emits those as a
      comment line built from ``comment``.
    - ``annotation`` - the annotation text a stub renders. For element
      and attribute members it is a union of stub type names
      (``Int | str | None``, ``list[Item]``). For extension members it
      is the text following the member name in its ``def`` line
      (``(self, count: int) -> str``), so a renderer emits
      ``def {name}{annotation}`` without re-introspecting.
    - ``kind`` - what the member is: a schema element, a schema
      attribute, or a member an overlay added beyond the descriptors.
    - ``comment`` - what the annotation cannot say (a default reads as
      ``str`` when absent, the element is nillable, an attribute is
      prohibited, the type could not be resolved, the XML name an
      aliased member belongs to), or ``None``.
    - ``is_property`` - True when an overlay bound the member with
      ``@property``; a renderer emits a decorator line and a
      parameterless ``def`` instead of a plain method. The
      ``annotation`` still carries the getter's signature text, so the
      discriminator only selects the spelling.
    """

    name: str
    annotation: str
    kind: Literal["element", "attribute", "extension"]
    comment: str | None = None
    is_property: bool = False


def _members(cls: type, names: dict[int, str], *, compact: bool) -> list[_Member]:
    """Returns ``cls``'s stub-renderable members, in declaration order.

    ``names`` is the class-name resolution map a renderer builds from
    :func:`_inventory` (``{id(entry.cls): entry.name}``): a member's
    type class is annotated with its stub name when the inventory has
    one, a builtin data type's own name when it is one, and ``Any``
    with a comment otherwise.

    The walk is over ``cls.__mro__`` up to ``SchemaBase``. The first
    (most derived) binding of a name wins, and a member whose defining
    class the inventory lists is *not* re-rendered here: its own class
    block renders it, and re-listing it would invent a second accessor.
    A member whose defining class is not in the inventory (an overlay's
    generated base) is attributed to ``cls``, so it still reaches the
    stub. Elements come first, then attributes, then the overlay's own
    public functions and properties, each in declaration order
    (base-class declarations before derived ones, as XSD extension
    order).

    Members that cannot be annotated truthfully are degraded, never
    skipped: an unresolvable type, a union member's value and a
    recursive back edge annotate as ``Any`` with a comment, a
    prohibited attribute use and a name with no usable Python
    accessor become comment-only members, and a member accessor that
    raises is treated as absent rather than propagating. ``compact``
    records the requested formatter; the member model is identical in
    both modes, and the compact renderer maps the annotations' spellings
    to its alias table itself.

    Nothing here calls ``schema.require_valid()``: an error-bearing
    schema still compiles classes, and a stub may still be requested
    for it.
    """
    # Deferred so importing this module does not enter the ER tag
    # cycle from a tag module (see the module-level comment).
    from pyxsd.element_representatives.attribute import Attribute
    from pyxsd.element_representatives.element import Element
    from pyxsd.schema_base import SchemaBase

    model = getattr(cls, "_contentModel_", None)
    inventory = set(names)
    class_names = frozenset(names.values())
    candidates: dict[str, tuple[int, int, int, _Member]] = {}
    #: An inventory class at or below a defining class renders that
    #: class's members itself; once one is met, later (less derived)
    #: classes' descriptors are skipped instead of duplicated.
    blocked = False
    for depth, klass in enumerate(cls.__mro__):
        if klass is SchemaBase or klass is object:
            break
        in_inventory = id(klass) in inventory
        renders = depth == 0 or (not blocked and not in_inventory)
        if depth > 0 and in_inventory:
            blocked = True
        if not renders:
            continue
        #: The class declarations open while this class's members
        #: resolve; a member type re-entering one is a recursive back
        #: edge and degrades to ``Any`` (see :func:`_annotation_base`).
        active = frozenset({id(cls), id(klass)})
        for order, key in enumerate(klass.__dict__.get("_elementNames_", ())):
            descriptor = klass.__dict__.get(key)
            if not isinstance(descriptor, Element):
                continue  # shadowed by a later binding in the same body
            member = _element_member(key, descriptor, names, model, active)
            candidates.setdefault(key, (0, -depth, order, member))
        for order, key in enumerate(klass.__dict__.get("_attributeNames_", ())):
            descriptor = klass.__dict__.get(key)
            if not isinstance(descriptor, Attribute):
                continue
            member = _attribute_member(key, descriptor, names, active)
            candidates.setdefault(key, (1, -depth, order, member))
    # Only the class's own body contributes overlay members: a builtin
    # data type further up the MRO declares public methods too
    # (``lexical``, ``to_date``, ...), and those are base-library API,
    # not members an overlay added.
    for order, (key, value) in enumerate(cls.__dict__.items()):
        if key.startswith("_") or key in candidates:
            continue
        if not isinstance(value, property) and not inspect.isfunction(value):
            continue
        candidates[key] = (2, 0, order, _extension_member(key, value, class_names))
    return [member for _, _, _, member in sorted(candidates.values(), key=lambda item: item[:3])]


def _element_member(
    key: str, descriptor: Any, names: dict[int, str], model: Any, active: frozenset[int]
) -> _Member:
    """Returns the ``_Member`` for one element descriptor bound as *key*.

    Occurs are folded through *model* (the owning class's
    ``_contentModel_``), so an optional or repeated ancestor group
    reaches the annotation. A ``default`` or ``fixed`` value appends
    ``str`` and ``None`` to the union. The comment distinguishes where
    the value constraint lives: a default on the declaration itself is
    what makes its absent read the raw lexical string, while a
    reference site delegates the constraint to the referred declaration
    and still reads ``None`` when absent (the site carries no fallback
    of its own). A reference to a substitution-group head keeps the
    head's type annotation, gains the ``None`` arm (a member occurrence
    satisfies the head's particle without filling the head accessor),
    and comments the member names the runtime binds instead.
    ``nillable`` is a comment, not a union arm: the value class itself
    is unchanged.
    """
    xml_name = _xml_name(descriptor, key)
    if not _usable_identifier(key):
        return _Member(
            name="",
            annotation="",
            kind="element",
            comment=f"element {xml_name!r} is not dot-accessible (bound as {key!r})",
        )
    parts: list[str] = []
    base, problem = _annotation_base(descriptor, xml_name, names, active)
    if problem is not None:
        parts.append(problem)
    substitution = _substitution_comment(descriptor)
    if substitution is not None:
        parts.append(substitution)
    minimum, maximum = _effective_occurs(model, descriptor)
    union = [f"list[{base}]" if maximum is None or maximum > 1 else base]
    own_default = _lexical(descriptor, "default")
    default = _call(descriptor, "getDefault")
    fixed = _call(descriptor, "getFixed")
    if default is not None:
        union += ["str", "None"]
        if own_default is not None:
            parts.append(f"default {own_default!r} reads as str when absent")
        else:
            parts.append(f"default {str(default)!r} from the referenced declaration")
    elif fixed is not None:
        union += ["str", "None"]
        parts.append(f"fixed {_lexical(descriptor, 'fixed', fixed)!r}")
    elif minimum == 0:
        union.append("None")
    if substitution is not None and "None" not in union:
        # A substituting member occurrence satisfies the head's particle
        # without filling the head accessor (members bind under their
        # own names), so None is reachable even when the head itself is
        # required.
        union.append("None")
    if _call(descriptor, "isNillable") is True:
        parts.append("nillable")
    if xml_name != key:
        parts.append(f"xml: {xml_name!r}")
    return _Member(
        name=key,
        annotation=" | ".join(union),
        kind="element",
        comment="; ".join(parts) if parts else None,
    )


def _attribute_member(
    key: str, descriptor: Any, names: dict[int, str], active: frozenset[int]
) -> _Member:
    """Returns the ``_Member`` for one attribute descriptor bound as *key*.

    A prohibited use has no annotation at all: the attribute is not
    part of a valid document, so its only stub presence is a comment.
    ``use="required"`` annotates as the type alone; anything else adds
    ``None``. ``default``/``fixed`` append ``str`` and ``None``, the
    same as elements: an absent attribute reads back the lexical string
    (before the binder converts it), and an unbound instance reads
    ``None``.
    """
    xml_name = _xml_name(descriptor, key)
    if _call(descriptor, "getUse") == "prohibited":
        return _Member(
            name="",
            annotation="",
            kind="attribute",
            comment=f"attribute {xml_name!r} is prohibited",
        )
    if not _usable_identifier(key):
        return _Member(
            name="",
            annotation="",
            kind="attribute",
            comment=f"attribute {xml_name!r} is not dot-accessible (bound as {key!r})",
        )
    parts: list[str] = []
    base, problem = _annotation_base(descriptor, xml_name, names, active)
    if problem is not None:
        parts.append(problem)
    union = [base]
    default = _call(descriptor, "getDefault")
    fixed = _call(descriptor, "getFixed")
    if default is not None:
        union += ["str", "None"]
        parts.append(
            f"default {_lexical(descriptor, 'default', default)!r} reads as str when absent"
        )
    elif fixed is not None:
        union += ["str", "None"]
        parts.append(f"fixed {_lexical(descriptor, 'fixed', fixed)!r}")
    if _call(descriptor, "getUse") != "required" and "None" not in union:
        union.append("None")
    if xml_name != key:
        parts.append(f"xml: {xml_name!r}")
    return _Member(
        name=key,
        annotation=" | ".join(union),
        kind="attribute",
        comment="; ".join(parts) if parts else None,
    )


def _extension_member(key: str, value: Any, class_names: frozenset[str]) -> _Member:
    """Returns the ``_Member`` for an overlay's function or property.

    The annotation is the ``def`` text after the member name, read from
    ``inspect.signature`` (a property is introspected through its
    getter; its setter's asymmetry is out of a stub's reach).
    ``is_property`` records that the overlay bound the name with
    ``@property``, so the renderer emits the decorator form. An
    unannotated return becomes ``Any``; a signature that cannot be read
    degrades to a variadic ``Any`` signature with a comment.

    A signature whose annotations name identifiers the emitted document
    cannot resolve degrades to ``(self) -> Any`` with the identifier in
    the comment (:func:`_validated_signature`): the document binds the
    pyxsd imports, the rendered classes (*class_names*), the typing
    generics, the qualifier modules, and the Python builtins, and
    nothing else.
    """
    is_property = isinstance(value, property)
    target = value.fget if is_property else value
    signature = None
    if target is not None:
        try:
            signature = str(inspect.signature(target))
        except (TypeError, ValueError):
            signature = None
    if signature is None:
        return _Member(
            name=key,
            annotation="(self, *args, **kwargs) -> Any",
            kind="extension",
            comment="signature unavailable; annotated Any",
            is_property=is_property,
        )
    comment = None
    if " -> " not in signature:
        signature += " -> Any"
        if is_property:
            comment = "getter return unannotated; annotated Any"
    signature, problem = _validated_signature(signature, class_names)
    if problem is not None:
        return _Member(
            name=key,
            annotation="(self) -> Any",
            kind="extension",
            comment=problem,
            is_property=is_property,
        )
    return _Member(
        name=key,
        annotation=signature,
        kind="extension",
        comment=comment,
        is_property=is_property,
    )


def _validated_signature(signature: str, class_names: frozenset[str]) -> tuple[str, str | None]:
    """Returns the signature text the emitted document can resolve, or a problem.

    ``inspect.signature`` formats annotations through their defining
    modules (``decimal.Decimal``, ``List[str]``, a quoted forward
    reference), so text copied into the document verbatim can name
    identifiers nothing there binds (mypy's ``name is not defined``).
    Only resolvable identifiers survive:

    - a Python builtin (``str``, ``int``, ``list``, ...), ``Any``, and
      the always-imported ``SchemaBase``;
    - a bare typing generic and a rendered class name, both module-level
      bindings of the emitted document;
    - a ``typing.X`` spelling, rewritten to the bare ``X`` the emitted
      ``from typing import`` line binds; and
    - a ``datetime.``/``decimal.`` qualifier whose first attribute
      exists, resolvable through the ``import`` line the renderer emits
      for the qualifier.

    Everything else -- an unknown bare name, a foreign module qualifier
    such as ``pandas.Series``, a quoted string annotation -- is
    unresolvable: ``(signature, problem)`` returns the problem text for
    the caller to degrade the member to ``(self) -> Any`` with. A
    signature that cannot even parse (a default value's repr is not
    always an expression) degrades the same way. Never raises.
    """
    try:
        module = ast.parse(f"def _f{signature}: ...")
    except (SyntaxError, ValueError):
        return signature, "signature could not be parsed; annotated Any"
    function = module.body[0] if module.body else None
    if not isinstance(function, ast.FunctionDef):
        return signature, "signature could not be parsed; annotated Any"
    problem: str | None = None
    rewrites: list[tuple[int, int, str]] = []
    for annotation in _signature_annotations(function):
        found = _unresolvable_annotation(annotation, class_names, rewrites)
        if found is not None and problem is None:
            problem = found
    if problem is not None:
        return signature, f"unresolvable identifier {problem!r} in signature; annotated Any"
    if not rewrites:
        return signature, None
    rewritten = _rewrite_typing_spellings(signature, rewrites)
    if rewritten is None:  # pragma: no cover - inspect.signature renders one ASCII line
        name = f"typing.{rewrites[0][2]}"
        return signature, f"unresolvable identifier {name!r} in signature; annotated Any"
    return rewritten, None


def _signature_annotations(function: ast.FunctionDef) -> list[ast.expr]:
    """Returns the annotation expressions of one parsed signature."""
    arguments = function.args
    annotations = [
        argument.annotation
        for argument in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs)
        if argument.annotation is not None
    ]
    annotations.extend(
        argument.annotation
        for argument in (arguments.vararg, arguments.kwarg)
        if argument is not None and argument.annotation is not None
    )
    if function.returns is not None:
        annotations.append(function.returns)
    return annotations


def _resolvable_bare_name(name: str, class_names: frozenset[str]) -> bool:
    """Returns True when the emitted document binds *name* as an annotation."""
    return (
        name == "SchemaBase"
        or name in class_names
        or name in _TYPING_NAMES
        or hasattr(builtins, name)
    )


def _unresolvable_annotation(
    node: ast.expr,
    class_names: frozenset[str],
    rewrites: list[tuple[int, int, str]],
) -> str | None:
    """Returns the first identifier *node* cannot resolve, or ``None``.

    A resolvable ``typing.X`` spelling is recorded in *rewrites* (the
    node's source span and the bare name) for
    :func:`_rewrite_typing_spellings` to normalize once the whole
    signature is known to be sound. ``Literal``'s arguments are values,
    not annotations, so their string literals do not degrade the union
    they appear in; any other quoted string is a forward reference the
    emitted document cannot see. Shapes the walk does not model are
    named by their unparsed source rather than passed through.
    """
    if isinstance(node, ast.Constant):
        if isinstance(node.value, str):
            return node.value
        return None
    if isinstance(node, ast.Name):
        if _resolvable_bare_name(node.id, class_names):
            return None
        return node.id
    if isinstance(node, ast.Attribute):
        return _attribute_problem(node, rewrites)
    if isinstance(node, ast.Subscript):
        problem = _unresolvable_annotation(node.value, class_names, rewrites)
        if problem is not None:
            return problem
        if _is_literal_spelling(node.value):
            return None
        return _unresolvable_annotation(node.slice, class_names, rewrites)
    if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
        parts: tuple[ast.expr, ...] = (node.left, node.right)
    elif isinstance(node, (ast.Tuple, ast.List)):
        parts = tuple(node.elts)
    else:
        return ast.unparse(node)
    for part in parts:
        problem = _unresolvable_annotation(part, class_names, rewrites)
        if problem is not None:
            return problem
    return None


def _attribute_problem(node: ast.Attribute, rewrites: list[tuple[int, int, str]]) -> str | None:
    """Returns the problem for one dotted annotation, or ``None``.

    A one-attribute chain qualified by ``typing`` is a spelling
    (:data:`_TYPING_NAMES`) the import block can bind, and is recorded
    in *rewrites*; one qualified by a qualifier module
    (:data:`_QUALIFIER_MODULES`) resolves when the attribute exists on
    the real module. Anything deeper or else qualifies -- ``pandas.
    Series``, ``typing.TypedDict``, ``datetime.no_such_thing`` -- is
    named by its unparsed source.
    """
    attributes: list[str] = []
    root: ast.expr = node
    while isinstance(root, ast.Attribute):
        attributes.append(root.attr)
        root = root.value
    if not isinstance(root, ast.Name) or len(attributes) != 1:
        return ast.unparse(node)
    end = node.end_col_offset
    if root.id == "typing" and attributes[0] in _TYPING_NAMES and end is not None:
        rewrites.append((node.col_offset, end, attributes[0]))
        return None
    module = _QUALIFIER_MODULES.get(root.id)
    if module is not None and hasattr(module, attributes[0]):
        return None
    return ast.unparse(node)


def _is_literal_spelling(node: ast.expr) -> bool:
    """Returns True when *node* spells ``Literal`` or ``typing.Literal``."""
    if isinstance(node, ast.Name):
        return node.id == "Literal"
    return isinstance(node, ast.Attribute) and node.attr == "Literal"


def _rewrite_typing_spellings(signature: str, rewrites: list[tuple[int, int, str]]) -> str | None:
    """Returns *signature* with the recorded ``typing.X`` spans replaced.

    The recorded offsets are relative to ``"def _f" + signature``; they
    are trusted only for the single-line ASCII text ``inspect.signature``
    renders, and every span is checked to be the spelling it claims
    before the splice, so an offset that would cut the text is ``None``
    for the caller to degrade instead of a mangled signature.
    """
    if not signature.isascii() or "\n" in signature:  # pragma: no cover - one ASCII line
        return None
    prefix = len("def _f")
    text = signature
    for start, end, name in sorted(rewrites, reverse=True):
        low, high = start - prefix, end - prefix
        if signature[low:high] != f"typing.{name}":
            return None
        text = text[:low] + name + text[high:]
    return text


def _effective_occurs(model: Any, descriptor: Any) -> tuple[int, int | None]:
    """Returns the descriptor's effective ``(min, max)`` occurrence.

    ``model`` is the owning class's compiled ``_contentModel_``
    particle tree; the walk mirrors
    :func:`pyxsd.integrations._projection._edge_nullable`, folding every
    ancestor between the root and the descriptor's leaf particle: an
    ancestor with ``min_occurs == 0`` or ``kind == "choice"`` forces
    the effective minimum to 0, and an ancestor with ``max_occurs is
    None`` or ``> 1`` forces the effective maximum to ``None``
    (unbounded). A descriptor with no leaf particle — a class whose
    content model did not compile, or a member inherited from a class
    outside the model — falls back to the descriptor's own occurrence
    values. Never raises.
    """
    minimum, maximum = _declared_occurs(descriptor)
    if model is None:
        return minimum, maximum
    edge = _leaf_particle(model, descriptor)
    if edge is None:
        return minimum, maximum
    return _fold_occurs(model, edge, edge.min_occurs, edge.max_occurs)


def _fold_occurs(
    particle: Any, edge: Any, minimum: int, maximum: int | None
) -> tuple[int, int | None]:
    """Folds one ancestor particle's occurrence into the edge's."""
    if particle.min_occurs == 0 or particle.kind == "choice":
        minimum = 0
    if particle.max_occurs is None or particle.max_occurs > 1:
        maximum = None
    if particle is edge:
        return minimum, maximum
    for child in particle.children:
        if any(leaf is edge for leaf in particle_elements(child)):
            return _fold_occurs(child, edge, minimum, maximum)
    return minimum, maximum


def _leaf_particle(model: Any, descriptor: Any) -> Any | None:
    """Returns the element particle compiled for *descriptor*, or ``None``.

    A class-body descriptor is the declaration the class declares, but
    two compilation steps produce particles that carry *different*
    objects:

    - an element reference site compiles to a particle carrying the
      *referred* declaration, so the site is matched through
      ``referredElement``; and
    - a named group's expansion binds a per-use shallow copy into the
      class body while the particle tree carries the group's shared
      declarations, so the copy is matched through the shared
      ``xsdElement`` source node it was made from.

    When several leaves can carry the declaration (a group referenced
    twice, a reference site beside a group expansion), the descriptor's
    own occurrence identifies its leaf: a copy's occurrence is the
    product of the group references that contributed it, which is
    exactly :func:`_copy_signature`. A site's own occurrence is the
    particle's own (``_compile_element`` takes ``minOccurs``/``maxOccurs``
    from the site), the fallback tie-break.
    """
    leaves = [particle for particle in particle_elements(model) if particle.descriptor is not None]
    for leaf in leaves:
        if leaf.descriptor is descriptor:
            return leaf
    if getattr(descriptor, "isElementRef", False):
        referred = getattr(descriptor, "referredElement", None)
        if referred is None:
            return None
        candidates = [leaf for leaf in leaves if leaf.descriptor is referred]
        return _preferred_leaf(model, candidates, descriptor)
    return _preferred_leaf(model, _twin_leaves(leaves, descriptor), descriptor)


def _twin_leaves(leaves: list[Any], descriptor: Any) -> list[Any]:
    """Returns the leaves carrying the declaration *descriptor* was copied from.

    ``ComplexType._flattenGroupRef`` binds each group reference's own
    shallow copies of the group's declarations, so the class body and
    the particle tree hold distinct objects for one declaration. The
    copy shares the source ``xsdElement`` node with the shared
    declaration the particle carries, which is the exact link
    descriptor identity cannot see. A descriptor without a source node
    (a hand-written declaration) has no copy twin.
    """
    source = getattr(descriptor, "xsdElement", None)
    if source is None:
        return []
    return [leaf for leaf in leaves if getattr(leaf.descriptor, "xsdElement", None) is source]


def _preferred_leaf(model: Any, candidates: list[Any], descriptor: Any) -> Any | None:
    """Returns the leaf that carries *descriptor*'s occurrence, or ``None``.

    One candidate is the declaration's only particle. With several, the
    descriptor's own occurrence picks the leaf its declaration was
    compiled for: ``_copy_signature`` reproduces a group-expanded
    copy's occurrence exactly, and a direct (unwrapped) site's own
    occurrence is its particle's. The first candidate is the last
    resort, as before.
    """
    if not candidates:
        return None
    if len(candidates) == 1:
        return candidates[0]
    minimum, maximum = _declared_occurs(descriptor)
    for leaf in candidates:
        if _copy_signature(model, leaf) == (minimum, maximum):
            return leaf
    for leaf in candidates:
        if leaf.min_occurs == minimum and leaf.max_occurs == maximum:
            return leaf
    return candidates[0]


def _copy_signature(model: Any, edge: Any) -> tuple[int, int | None]:
    """Returns the occurrence a group-expanded copy of *edge* carries.

    ``ComplexType._foldRefOccurrences`` multiplies a group reference
    site's occurrence into every element the reference contributes, and
    the compiled tree keeps that occurrence on the synthetic wrapper
    particle the reference compiles to (``_compile_group_ref``).
    Multiplying the synthetic wrappers and the element's own occurrence
    therefore reproduces the copy's own ``minOccurs``/``maxOccurs``
    exactly, normalized the way :func:`_declared_occurs` normalizes
    them.
    """
    return _signature(model, edge, 1, 1)


def _signature(
    particle: Any, edge: Any, minimum: int, maximum: int | None
) -> tuple[int, int | None]:
    """Multiplies the synthetic ancestors' occurrences into *edge*'s own."""
    if particle.synthetic:
        minimum, maximum = _multiplied(minimum, maximum, particle.min_occurs, particle.max_occurs)
    if particle is edge:
        minimum, maximum = _multiplied(minimum, maximum, edge.min_occurs, edge.max_occurs)
        return minimum, None if maximum is None or maximum >= _UNBOUNDED else maximum
    for child in particle.children:
        if any(leaf is edge for leaf in particle_elements(child)):
            return _signature(child, edge, minimum, maximum)
    return minimum, maximum


def _multiplied(
    minimum: int, maximum: int | None, factor_min: int, factor_max: int | None
) -> tuple[int, int | None]:
    """Multiplies one occurrence factor in, capping like the ref fold does."""
    minimum *= factor_min
    if maximum is None or factor_max is None:
        return minimum, None
    maximum *= factor_max
    if maximum >= _UNBOUNDED:
        return minimum, None
    return minimum, maximum


def _declared_occurs(descriptor: Any) -> tuple[int, int | None]:
    """Returns the descriptor's own occurrence, unbounded normalized."""
    try:
        minimum = int(descriptor.getMinOccurs())
    except Exception:
        minimum = 1
    try:
        maximum = descriptor.getMaxOccurs()
    except Exception:
        maximum = 1
    if maximum is None or maximum >= _UNBOUNDED:
        return minimum, None
    return minimum, int(maximum)


def _annotation_base(
    descriptor: Any,
    xml_name: str,
    names: dict[int, str],
    active: frozenset[int] = frozenset(),
) -> tuple[str, str | None]:
    """Returns the annotation's base type name and any degradation comment.

    The resolution order is the inventory's stub name, then the builtin
    data type's own class name. A construct the stub cannot present
    directly annotates as ``Any`` with a comment saying why, never as a
    guessed class name: an unresolved declaration, the untyped
    ``anyType`` fallback, a union simple type (a policy degradation:
    the parsed value is an instance of the union's own class, and the
    comment names the member types its value space draws from), and a
    class already open on *active* — the ids of the class declarations
    currently being resolved — which is a recursive back edge.
    """
    try:
        resolved = descriptor.getType()
    except Exception:
        return "Any", f"type of {xml_name!r} could not be resolved; annotated Any"
    if resolved is None:
        declared = getattr(descriptor, "type", None)
        return "Any", f"type {declared!r} is unresolved; annotated Any"
    if isinstance(resolved, type):
        return _resolved_base(resolved, names, active)
    return "Any", f"type {getattr(resolved, '__name__', resolved)!r} is not stubbed; annotated Any"


def _resolved_base(
    cls: type, names: dict[int, str], active: frozenset[int]
) -> tuple[str, str | None]:
    """Returns the annotation base and degradation comment for one class.

    A re-entered class (``cls``'s id is on *active*) is the back edge of
    a recursive type graph. A union reads ``Any`` by policy: a parsed
    union value is an instance of the union's own bookkeeping class, and
    the stub names the member types its value space draws from in the
    comment rather than presenting that class as the member's type;
    ``_unionMembers`` is read from the class's own namespace, mirroring
    derivation: a restriction of a union inherits the attribute through
    its MRO but is not itself a union.
    """
    from pyxsd.schema_base import SchemaBase

    if id(cls) in active:
        return "Any", "recursive type; annotated Any"
    union_members = vars(cls).get("_unionMembers")
    if union_members is not None:
        return "Any", f"union of: {_union_member_names(union_members, names)}; annotated Any"
    stub_name = names.get(id(cls))
    if stub_name is not None:
        return stub_name, None
    builtin_name = _BUILTIN_STUB_NAMES.get(cls)
    if builtin_name is not None:
        return builtin_name, None
    if cls is SchemaBase:
        return "Any", "no declared type; annotated Any"
    return "Any", f"type {getattr(cls, '__name__', cls)!r} is not stubbed; annotated Any"


def _union_member_names(members: Any, names: dict[int, str]) -> str:
    """Returns a union's member classes as annotation-style names.

    Each member resolves the way a member annotation does — the
    inventory's stub name, then the builtin data type's own class name —
    and falls back to the class's own ``__name__``. The walk never
    recurses into a member that is itself a union: naming it is all the
    comment needs, and a (malformed) circular union must not loop.
    """
    parts: list[str] = []
    for member in members or ():
        if not isinstance(member, type):
            parts.append(str(member))
            continue
        name = names.get(id(member))
        if name is None:
            name = _BUILTIN_STUB_NAMES.get(member)
        if name is None:
            name = str(getattr(member, "__name__", None) or repr(member))
        parts.append(name)
    return " | ".join(parts) if parts else "no resolvable members"


def _substitution_comment(descriptor: Any) -> str | None:
    """Returns the substitution-group comment for an element reference site.

    A local ``ref`` to a global element heading a substitution group
    admits every declaration that names it as their group: the runtime
    binds such a child's value through a member-named accessor, so the
    head's annotation models the head's own declaration only. A member
    occurrence satisfies the head's particle without filling the head
    accessor, so the caller adds the ``None`` arm; the comment names the
    members the annotation cannot say. ``None`` for anything that is not
    a resolution-proven head: a plain element, a declaration whose group
    cannot be established (the schema's element table is unreachable), or
    a head that blocks ``substitution``, where no member is admissible
    at all. Never raises.
    """
    if not getattr(descriptor, "isElementRef", False):
        return None
    try:
        schema_er = descriptor.getSchema()
    except Exception:
        return None
    table = getattr(schema_er, "substitutionGroups", None) if schema_er is not None else None
    if not table:
        return None
    referred = getattr(descriptor, "referredElement", None)
    if referred is None:
        referred = descriptor
    head = getattr(referred, "name", None)
    if not isinstance(head, str) or not head:
        return None
    members = None
    for key in (head, _attribute(referred, "expandedName")):
        if isinstance(key, str) and key in table:
            members = table[key]
            break
    if members is None or _blocks_substitution(referred):
        return None
    names = [
        member.name
        for member in members
        if isinstance(getattr(member, "name", None), str) and member.name
    ]
    if names:
        return f"substitution group: {head}; members: {', '.join(names)}"
    return f"substitution group: {head}"


def _blocks_substitution(referred: Any) -> bool:
    """Returns True when a head's effective ``block`` forbids substitutes."""
    block = _call(referred, "getBlock")
    if not isinstance(block, str) or not block:
        attributes = getattr(referred, "tagAttributes", None) or {}
        block = str(attributes.get("block") or "")
    return "substitution" in block.split() or "#all" in block.split()


def _usable_identifier(name: str) -> bool:
    """Returns True when *name* can bind a class-body member in source."""
    return name.isidentifier() and not keyword.iskeyword(name)


def _xml_name(descriptor: Any, key: str) -> str:
    """Returns the descriptor's schema name, falling back to its bound key."""
    name = getattr(descriptor, "name", None)
    return name if isinstance(name, str) and name else key


def _lexical(descriptor: Any, attribute: str, fallback: Any = None) -> Any:
    """Returns the descriptor's own raw lexical value for a facet.

    The ER keeps the schema XML attribute verbatim, while
    ``getDefault``/``getFixed`` return the value already converted to
    the declared type (``3`` for ``default="3"`` on ``xs:int``) and a
    reference site delegates to the referred declaration. ``None`` when
    the declaration carries no value of its own; *fallback* otherwise.
    """
    raw = getattr(descriptor, attribute, None)
    return raw if isinstance(raw, str) else fallback


def _call(descriptor: Any, method: str) -> Any:
    """Calls a descriptor accessor, returning ``None`` when it raises."""
    accessor = getattr(descriptor, method, None)
    if accessor is None:
        return None
    try:
        return accessor()
    except Exception:
        return None


def _attribute(instance: Any, name: str) -> Any:
    """Returns ``instance.name``, or ``None`` when reading it raises.

    A ``getattr`` default only covers a missing attribute; a property
    can raise anything at all, so the read is guarded like :func:`_call`
    guards a method call, keeping the module's never-raises discipline.
    """
    try:
        return getattr(instance, name)
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Class-level degradations
# ---------------------------------------------------------------------------


def _class_degradations(entry: _StubClass) -> list[str]:
    """Returns the class-level comment lines for content a stub cannot show.

    These are constructs whose effect reaches the parsed instance but
    that have no member to hang on: mixed content's character data, a
    wildcard's undeclared children, an attribute wildcard's undeclared
    attributes, and XSD 1.1 open content. Both renderers print the
    returned texts as comment lines in the class body, so the omission
    is never silent and the text is identical in both modes.
    """
    cls = entry.cls
    warnings: list[str] = []
    if (
        not getattr(cls, "_elementOnly_", True)
        and getattr(cls, "_simpleContentType_", None) is None
    ):
        warnings.append("mixed content: text not modeled")
    child_flags = _child_wildcard_flags(cls)
    if "wildcard" in child_flags:
        warnings.append("admits undeclared children (wildcard)")
    if "open content" in child_flags:
        warnings.append("admits undeclared children (open content)")
    if getattr(cls, "hasWildcardAttributes_", False):
        warnings.append("admits undeclared attributes (wildcard)")
    return warnings


def _child_wildcard_flags(cls: type) -> set[str]:
    """Returns which child wildcard kinds *cls*'s content model admits.

    The schema-phase particle tree (``_contentModel_``) carries the
    declared wildcards as ``any`` particles; the instance matcher's tree
    (``_instanceContentModel_``) additionally carries a type's effective
    XSD 1.1 open content as an ``any`` particle flagged
    ``open_content``. Both are walked, each model once. Never raises.
    """
    flags: set[str] = set()
    seen: set[int] = set()
    for attribute in ("_contentModel_", "_instanceContentModel_"):
        model = getattr(cls, attribute, None)
        if model is not None and id(model) not in seen:
            seen.add(id(model))
            _collect_particle_flags(model, flags)
    return flags


def _collect_particle_flags(particle: Any, flags: set[str]) -> None:
    """Adds the wildcard kinds reachable in *particle* to *flags*."""
    if particle is None:
        return
    if getattr(particle, "kind", None) == "any":
        flags.add("open content" if getattr(particle, "open_content", False) else "wildcard")
    for child in getattr(particle, "children", ()) or ():
        _collect_particle_flags(child, flags)


# ---------------------------------------------------------------------------
# Full-mode rendering
# ---------------------------------------------------------------------------


[docs] def emit_stub( schema: Schema, *, compact: bool = False, component_ids: tuple[str, ...] | None = None, depth: int | None = None, ) -> str: """Returns the stub text for the classes *schema* compiled. The text is generated from the live schema at call time (the class table, the declarations behind it, and the compiled particle model), so re-emitting after an overlay is loaded shows the overlay's members — nothing is cached at compile time. Full mode is an importable-quality ``.pyi`` document: a provenance and consumption header, imports, one class block per generated class, and a root-element alias section. Annotations name the real runtime classes, so a member typed ``Int`` reads back a ``pyxsd.xsd_data_types.Int``. ``compact`` selects the agent-oriented compact formatter, which shares this module's member model: a two-line header (provenance and an alias legend mapping the short spellings back to the pyxsd classes), one line per class and per member, facet-only simple types folded onto the members that use them, and a trailing comment line listing the root elements. The compact text is a description, not importable Python; every annotation spelling it uses is a rendered class name, a pyxsd builtin's own name, or a legend alias. Degradation comments are shared through the model and render verbatim in both modes (see :func:`_render_compact`). ``component_ids`` selects native declaration IDs; ``depth`` bounds outgoing declaration dependencies (None follows all reachable ones). An empty selection emits no classes or root aliases. Explicitly selected simple types are not folded away in compact mode. Unknown IDs raise KeyError; negative depths raise ValueError. Selection does not modify the schema's class table. Unfiltered rendering never raises on a schema that compiled: a construct a stub cannot type truthfully (an unresolvable type, a member with no Python accessor) degrades to ``Any`` with a comment, and an error-bearing schema still emits. This never calls ``schema.require_valid()``. """ inventory = _inventory(schema) root_elements = None if depth is not None and depth < 0: raise ValueError("depth must be non-negative") if component_ids is not None: from pyxsd.introspection import _stub_selection class_ids, root_elements = _stub_selection(schema, component_ids, depth) inventory = [entry for entry in inventory if id(entry.cls) in class_ids] if compact: return _render_compact(schema, inventory, root_elements=root_elements) return _render_full(schema, inventory, root_elements=root_elements)
def _render_full( schema: Schema, inventory: list[_StubClass], *, root_elements: frozenset[int] | None = None ) -> str: """Assembles the full stub document for *schema*. Class blocks come in inventory order; the imports are derived from the rendered body, so only classes the document actually names are imported. Root aliases are checked against the class names and the import bindings, and one colliding with a qualifier module's import is re-rendered as a comment once the body decides that import. The result always ends in a newline. """ names = {id(entry.cls): entry.name for entry in inventory} class_blocks = [_render_class(entry, names) for entry in inventory] class_names = {entry.name for entry in inventory} aliases = _root_aliases( schema, names, class_names=frozenset(class_names), imported_names=_IMPORTED_STUB_NAMES, element_ids=root_elements, ) body_parts = list(class_blocks) if aliases: body_parts.append(_render_root_section(aliases)) body = "\n\n".join(body_parts) module_imports = _module_imports(body) retracted = _retract_shadowed_module_aliases(aliases, module_imports) if retracted is not None: aliases = retracted body_parts = list(class_blocks) if aliases: body_parts.append(_render_root_section(aliases)) body = "\n\n".join(body_parts) module_imports = _module_imports(body) sections = [ "\n".join(_header_lines(schema, _example_class(inventory, aliases))), "\n".join( _import_lines( _used_builtin_names(body, class_names), _used_typing_names(body, class_names), module_imports, ) ), ] if body: sections.append(body) return "\n\n".join(sections) + "\n" def _header_lines(schema: Schema, example: str) -> list[str]: """Returns the provenance and consumption header comment block. ``example`` is the class the consumption idiom names; it is a real class of this stub when one exists, so the idiom is copy-pasteable for the schema at hand. """ return [ f"# Generated by pyxsd {__version__} from {_source_label(schema.source)}." " Do not edit by hand.", "#", "# This file is a generated typing surface for the classes a compiled", "# schema creates at runtime; there is no importable module (the classes", "# live in ``schema.classes``). Save this stub next to your code and", "# import it for type checking:", "# if TYPE_CHECKING:", f"# from schema_stubs import {example}", f"# root = cast({example}, document.root)", "#", "# Semantics: annotations model valid parsed documents. Absent optional", "# members read as None (including absent repeated members); required", "# members of invalid documents read as None too. Repeated assignment", "# appends one item (obj.x = item); it does not take a list. The parser", "# binds a complex-typed child in the parsed tree only (obj.child reads", "# None), while a primitive-typed child is accessor-bound; the", "# annotations describe the descriptor and construction API. Constructs", "# a stub cannot type truthfully are Any with a comment.", ] def _source_label(source: Any) -> str: """Returns a deterministic, human-readable name for a schema source. A path (or a file object's ``name``) is used verbatim; an anonymous stream has no name to show, so it reads as ``<stream>`` rather than an object repr that changes from run to run. """ try: if isinstance(source, (str, os.PathLike)): return str(source) name = getattr(source, "name", None) if isinstance(name, str) and name: return name except Exception: pass return "<stream>" def _import_lines( builtins: list[str], typing_names: list[str], module_imports: list[str] ) -> list[str]: """Returns the import block a full stub document starts with. ``typing_names`` are the typing generics beyond ``Any`` the body uses; ``module_imports`` are ready-made ``import`` lines for the qualifier modules the body uses (``import datetime``, ...). """ lines: list[str] = list(module_imports) lines.append(f"from typing import {', '.join(('Any', *typing_names))}") lines.append("") lines.append("from pyxsd.schema_base import SchemaBase") if builtins: lines.append(f"from pyxsd.xsd_data_types import {', '.join(builtins)}") return lines def _module_imports(body: str) -> list[str]: """Returns the ``import`` lines for the qualifier modules *body* uses. A validated signature keeps a ``datetime.``/``decimal.`` qualifier verbatim, so the module it names is imported exactly when the rendered text mentions it. """ return [f"import {name}" for name in _QUALIFIER_MODULES if f"{name}." in body] def _used_builtin_names(body: str, class_names: set[str]) -> list[str]: """Returns the builtin data type names *body* references, sorted. The scan is over the rendered text so extension signatures and degradation comments are covered too. A builtin whose name a rendered class also uses is skipped: the class shadows the import, so importing it would only create a duplicate name. A comment that happens to spell a builtin adds a harmless unused import. """ return sorted( { name for name in set(_BUILTIN_STUB_NAMES.values()) if name not in class_names and re.search(rf"\b{re.escape(name)}\b", body) } ) def _used_typing_names(body: str, class_names: set[str]) -> list[str]: """Returns the typing generics *body* references beyond ``Any``, sorted. Resolvable overlay signatures keep their bare spellings (:func:`_validated_signature`), so the scan over the rendered text is what decides the ``from typing import`` line. A generic whose name a rendered class also uses is skipped like a shadowed builtin; a comment that spells one adds a harmless unused import. """ return sorted( name for name in _TYPING_NAMES - {"Any"} if name not in class_names and re.search(rf"\b{re.escape(name)}\b", body) ) def _example_class(inventory: list[_StubClass], aliases: list[_RootAlias]) -> str: """Returns the class name the consumption idiom should show. The first root element's class is the natural example for ``document.root``; a schema whose roots are all builtin-typed falls back to its first generated class, then to the base class. """ class_names = {entry.name for entry in inventory} for alias in aliases: if alias.type_name in class_names: return alias.type_name if inventory: return inventory[0].name return "SchemaBase" def _render_class(entry: _StubClass, names: dict[int, str]) -> str: """Returns one class block, without a trailing newline. A note renders as a comment above the class; a documentation string and the class-level degradations (:func:`_class_degradations`: mixed content, wildcards, open content) render inside it. Members come in :func:`_members` order (elements, attributes, overlay extensions); a class with no members renders ``...``. Simple types additionally carry their facets as comments, which is the only place a restriction's constraints are visible in the stub. """ lines: list[str] = [] if entry.note: lines.append(f"# {entry.note}") lines.append(f"class {entry.name}({_stub_base(entry, names)}):") lines.extend(f" # {warning}" for warning in _class_degradations(entry)) lines.extend(_docstring_lines(entry)) members = _members(entry.cls, names, compact=False) member_lines = _member_lines(members) lines.extend(member_lines) # Comment-only members (unusable accessor names) are not # statements, so a block that has no statement line still needs # ``...`` to stay syntactically valid. if not any(not line.lstrip().startswith("#") for line in member_lines): lines.append(" ...") if _is_simple_type(entry.cls): lines.extend(f" {comment}" for comment in _facet_comments(entry)) return "\n".join(lines) def _stub_base(entry: _StubClass, names: dict[int, str]) -> str: """Returns the base class the stub block for *entry* declares. Mirrors the runtime MRO: the nearest ancestor the inventory lists is the XSD base type's stub name (collisions and all), the nearest builtin data type is its own class name (a simple-type wrapper mirrors its runtime base), and anything else renders ``SchemaBase``, the class every generated class is rooted in. """ from pyxsd.schema_base import SchemaBase for base in entry.cls.__mro__[1:]: if base is object: break stub_name = names.get(id(base)) if stub_name is not None: return stub_name builtin_name = _BUILTIN_STUB_NAMES.get(base) if builtin_name is not None: return builtin_name if base is SchemaBase: break return "SchemaBase" def _is_simple_type(cls: type) -> bool: """Returns True when *cls* is a schema simple type. A simple type's runtime class subclasses a builtin data type (``xsd_data_types.XsdDataType``); its value space is the facets the stub renders as comments. """ try: return issubclass(cls, xsd_data_types.XsdDataType) except TypeError: return False def _docstring_lines(entry: _StubClass) -> list[str]: """Returns the class docstring lines for a documented type, or ``[]``. The ``Documentation`` ER stamps ``xs:documentation`` text onto the containing type's ER as ``__doc__``; the annotation data alone would lose it. Quotes and backslashes are escaped so arbitrary documentation text stays syntactically valid. """ er = entry.er if er is None: return [] text = er.__dict__.get("__doc__") if not isinstance(text, str) or not text.strip(): return [] escaped = text.strip().replace("\\", "\\\\").replace('"', '\\"') parts = escaped.splitlines() or [""] if len(parts) == 1: return [f' """{parts[0]}"""'] return [f' """{parts[0]}', *(f" {part}" for part in parts[1:]), ' """'] def _facet_items(entry: _StubClass) -> list[str]: """Returns the simple type's facets as ``name: value`` text items. Facets live on the ``xs:restriction`` children of a simple type's declaration; every child carrying a ``value`` attribute is one, so the items show what the value space constrains (``pattern``, ``minLength``, ``enumeration``, ...) without a facet list to keep up to date. A declaration shape this does not recognize yields no items rather than an error. """ xsd_element = getattr(entry.er, "xsdElement", None) items: list[str] = [] if xsd_element is None: return items try: restrictions = xsd_element.findall(f".//{{{XSD_NS}}}restriction") for restriction in restrictions: for facet in restriction: value = facet.get("value") if value is not None: items.append(f"{local_name(facet.tag)}: {value}") except Exception: return items return items def _facet_comments(entry: _StubClass) -> list[str]: """Returns the simple type's facets as ``# name: value`` comment lines.""" return [f"# {item}" for item in _facet_items(entry)] def _member_lines(members: list[_Member]) -> list[str]: """Returns the class-body lines for *members*. A comment-only member (no usable accessor) renders as a bare comment, an overlay extension as a one-line ``def`` (a property as a ``@property`` line plus a parameterless ``def``), and a descriptor member as a class-level annotation with its comment trailing. """ lines: list[str] = [] for member in members: if not member.name: if member.comment: lines.append(f" # {member.comment}") continue if member.kind == "extension": if member.is_property: lines.append(" @property") lines.append(_extension_line(member)) continue line = f" {member.name}: {member.annotation}" if member.comment: line += f" # {member.comment}" lines.append(line) return lines def _extension_line(member: _Member) -> str: """Returns the ``def`` line for one overlay function or property. ``member.annotation`` carries the signature text after the member name (``(self) -> str``); the leading parameter list and the rest are split apart so the line reads as ordinary stub source. A property drops its parameter list — ``obj.name`` is a value, not a call — and keeps only the getter's return type. A signature that cannot be split degrades to ``(self) -> Any`` with the member's comment, never a syntax-breaking line. """ params, returns = _split_signature(member.annotation) if params is None or returns is None: reason = member.comment or "signature could not be split; annotated Any" return f" def {member.name}(self) -> Any: ... # {reason}" if member.is_property: line = f" def {member.name}(self) -> {returns}: ..." else: line = f" def {member.name}{params} -> {returns}: ..." if member.comment: line += f" # {member.comment}" return line def _split_signature(signature: str) -> tuple[str | None, str | None]: """Splits ``(params) -> ret`` into its parameter list and return type. The parameter list ends at the ``)`` matching the leading ``(``, so annotations carrying nested parentheses (``Callable[[int], str]``) survive intact. Anything that is not a ``(params) -> ret`` shape returns ``(None, None)`` for the caller to degrade. """ if not signature.startswith("("): return None, None depth = 0 for index, char in enumerate(signature): if char == "(": depth += 1 elif char == ")": depth -= 1 if depth == 0: params = signature[: index + 1] rest = signature[index + 1 :].strip() if not rest.startswith("->"): return None, None returns = rest[2:].strip() return (params, returns) if returns else (None, None) return None, None @dataclass(frozen=True) class _RootAlias: """One global element's module-level alias. - ``name`` - the element's XML name (the alias target). - ``type_name`` - the stub name of the element's type class, or ``None`` when the alias cannot be typed (see ``comment``). - ``comment`` - why a ``name`` has no ``type_name`` (unresolvable type, a name that is not a Python identifier, a repeat, a name the emitted module already binds). """ name: str type_name: str | None = None comment: str | None = None def _root_aliases( schema: Schema, names: dict[int, str], *, class_names: frozenset[str] = frozenset(), imported_names: frozenset[str] = frozenset(), element_ids: frozenset[int] | None = None, ) -> list[_RootAlias]: """Returns the aliases for *schema*'s global element declarations. Global elements are the valid document roots, so the stub lists them after the classes. A root whose type resolves annotates as ``type[TheClass]``; one that cannot be typed truthfully keeps its name in a comment instead. The first declaration of a name wins; a repeat is commented rather than emitted twice. *class_names* and *imported_names* are the identifiers the full document already binds; an alias duplicating one would be a duplicate definition, so it is commented too (the compact renderer passes neither: its root line is a comment, not a binding). """ aliases: list[_RootAlias] = [] seen_elements: set[int] = set() used_names: set[str] = set() declarations: Iterable[Any] if element_ids is None: declarations = (er for group in schema.components.values() for er in group) else: from pyxsd.introspection import _index declarations = _index(schema).elements.values() for er in declarations: if id(er) in seen_elements or not _is_global_element(er): continue if element_ids is not None and id(er) not in element_ids: continue seen_elements.add(id(er)) aliases.append( _root_alias( er, names, used_names, class_names=class_names, imported_names=imported_names, ) ) return aliases def _is_global_element(er: Any) -> bool: """Returns True for a global ``Element`` representative.""" if type(er).__name__ != "Element": return False try: return bool(er.isGlobalDeclaration()) except Exception: return False def _bound_name_comment(name: str, binding: str) -> str: """Returns the comment for a root name the emitted module already binds.""" return f"root element {name!r}: name already used by {binding}" def _root_alias( er: Any, names: dict[int, str], used_names: set[str], *, class_names: frozenset[str] = frozenset(), imported_names: frozenset[str] = frozenset(), ) -> _RootAlias: """Returns the alias, or the comment explaining its absence, for *er*. A name the emitted module already binds cannot become an alias: a rendered class or an import binding would be a duplicate definition (mypy: ``name already defined``), so the element keeps its name in a comment instead. Qualifier-module names are not on *imported_names*: their import depends on the rendered body, and :func:`_retract_shadowed_module_aliases` re-checks them once the body exists. """ name = getattr(er, "name", None) if not isinstance(name, str) or not name: name = _called_name(er) if not name or not name.isidentifier() or keyword.iskeyword(name): return _RootAlias(name or "?", comment=f"root element {name!r} is not a Python identifier") if name in class_names: return _RootAlias(name, comment=_bound_name_comment(name, f"class {name!r}")) if name in imported_names: return _RootAlias(name, comment=_bound_name_comment(name, "an import")) if name in used_names: return _RootAlias(name, comment=f"root element {name!r} repeats an earlier alias") type_name, problem = _annotation_base(er, name, names) if problem is not None: return _RootAlias(name, comment=f"root element {name!r}: {problem}") used_names.add(name) return _RootAlias(name, type_name=type_name) def _called_name(er: Any) -> str | None: """Returns ``er.getName()``, or ``None`` when it raises or is unusable.""" try: name = er.getName() except Exception: return None return name if isinstance(name, str) and name else None def _render_root_section(aliases: list[_RootAlias]) -> str: """Returns the trailing root-element section as one text block.""" lines = ["# Root elements:"] for alias in aliases: if alias.type_name is not None: lines.append(f"{alias.name}: type[{alias.type_name}]") else: lines.append(f"# {alias.comment}") return "\n".join(lines) def _retract_shadowed_module_aliases( aliases: list[_RootAlias], module_imports: list[str] ) -> list[_RootAlias] | None: """Returns *aliases* with module-shadowed names re-commented, or ``None``. A root alias named after a qualifier module the body uses (``datetime``, ``decimal``) would duplicate the ``import`` line the imports derive from the body, so it becomes the comment form. The check runs after the body exists because the alias line itself never carries the qualifier dot, so retracting it cannot change the body's import decision. ``None`` when nothing is shadowed. """ imported = {line.removeprefix("import ") for line in module_imports} shadowed = { alias.name for alias in aliases if alias.type_name is not None and alias.name in imported } if not shadowed: return None return [ replace(alias, type_name=None, comment=_bound_name_comment(alias.name, "an import")) if alias.name in shadowed else alias for alias in aliases ] # --------------------------------------------------------------------------- # Compact-mode rendering # --------------------------------------------------------------------------- #: The builtin data types that render under a short Python spelling. A #: type named here aliases to the family's spelling; anything else keeps #: its stub name (``Duration``, ``AnyType``, ...). The alias renames the #: spelling of the same runtime type — ``str`` is a ``String`` subclass #: instance, ``int`` an ``Integer`` one — so the legend stays truthful. _STRING_FAMILY_NAMES = ( "String", "NormalizedString", "Token", "Language", "Name", "NCName", "NMTOKEN", "ID", "IDREF", "ENTITY", "IDREFS", "ENTITIES", "NMTOKENS", "AnyURI", "QName", "NOTATION", ) _INTEGER_FAMILY_NAMES = ( "Integer", "NonPositiveInteger", "NegativeInteger", "NonNegativeInteger", "PositiveInteger", "Long", "Int", "Short", "Byte", "UnsignedLong", "UnsignedInt", "UnsignedShort", "UnsignedByte", ) _TEMPORAL_ALIASES = {"Date": "date", "DateTime": "datetime", "Time": "time"} def _compact_alias_name(stub_name: str) -> str: """Returns the compact spelling for one builtin data type's name.""" if stub_name in _STRING_FAMILY_NAMES: return "str" if stub_name in _INTEGER_FAMILY_NAMES: return "int" if stub_name == "Boolean": return "bool" if stub_name in ("Double", "Float"): return "float" if stub_name == "Decimal": return "Decimal" return _TEMPORAL_ALIASES.get(stub_name, stub_name) #: Every builtin data type's compact spelling, keyed by class identity: #: ``String`` family → ``str``, ``Integer`` family → ``int``, #: ``Boolean`` → ``bool``, ``Decimal`` → ``Decimal``, ``Double``/ #: ``Float`` → ``float``, ``Date``/``DateTime``/``Time`` → ``date``/ #: ``datetime``/``time``; every other class keeps its stub name. _COMPACT_ALIASES: dict[type, str] = { cls: _compact_alias_name(name) for cls, name in _BUILTIN_STUB_NAMES.items() } @dataclass(frozen=True) class _Spelling: """One compact annotation spelling and what it stands for. - ``text`` - the short spelling a member annotation uses. - ``source`` - the pyxsd class name the spelling denotes. For a dropped simple type this is the builtin data type the class derives from; for a builtin it is the class's own name. Only a spelling that differs from its source joins the legend (an unchanged name is already the pyxsd class's own spelling). """ text: str source: str def _render_compact( schema: Schema, inventory: list[_StubClass], *, root_elements: frozenset[int] | None = None ) -> str: """Assembles the compact agent view for *schema*. The document is a description, not importable Python: a two-line header (provenance and alias legend), one line per class and per member, plus the class-level degradation comment lines (:func:`_class_degradations`), and a trailing comment line listing the root elements. Facet-only simple types are dropped and their facets render on each member that uses them; every annotation spelling is a rendered class name, a pyxsd builtin's own name, or a legend alias, so the view cannot drift from the model. Degradation comments are shared text and render verbatim — the same words as the full mode — so a comment may name a class the compact view folded, which the aliased annotations around it cannot. """ names = {id(entry.cls): entry.name for entry in inventory} entries_by_id = {id(entry.cls): entry for entry in inventory} aliases = _root_aliases(schema, names, element_ids=root_elements) root_names = {alias.type_name for alias in aliases if alias.type_name is not None} rendered: list[tuple[_StubClass, list[_Member]]] = [] skipped: list[_StubClass] = [] for entry in inventory: members = _members(entry.cls, names, compact=True) if ( root_elements is None and entry.name not in root_names and not members and _foldable_simple_type(entry.cls) ): skipped.append(entry) else: rendered.append((entry, members)) skipped_ids = {id(entry.cls) for entry in skipped} spellings = _compact_spellings(inventory, skipped) pattern = _spelling_pattern(spellings) facets_by_name = { entry.name: _compact_facets(entry, entries_by_id, skipped_ids) for entry in skipped } used: dict[str, list[str]] = {} def spell(text: str) -> str: return _spell_text(text, pattern, spellings, facets_by_name, used)[0] blocks: list[str] = [] for entry, members in rendered: member_lines = _compact_member_lines(members, pattern, spellings, facets_by_name, used) facets = _compact_facets(entry, entries_by_id, skipped_ids) warnings = _class_degradations(entry) blocks.append( _compact_class_block( entry, spell(_stub_base(entry, names)), member_lines, facets, warnings ) ) roots_line = _compact_roots(aliases, spell) legend = ", ".join(f"{text}={'+'.join(sources)}" for text, sources in used.items()) sections = [ "\n".join( [ f"# Generated by pyxsd {__version__} from {_source_label(schema.source)};" " compact view, not Python source.", f"# aliases: {legend or 'none'}", ] ) ] if blocks: sections.append("\n\n".join(blocks)) if roots_line is not None: sections.append(roots_line) return "\n\n".join(sections) + "\n" def _foldable_simple_type(cls: type) -> bool: """Returns True when the compact view folds *cls* onto its users. A simple type whose content is only facet comments renders no line of its own: its facets inline on each member that uses it, and its spelling resolves to the builtin data type it derives from. A list type is excluded — its content is the item type, not facets — and an unknown base (an ``xs:union``, whose runtime class derives from ``XsdDataType`` directly) keeps its own line so the name it renders under stays resolvable. """ if not _is_simple_type(cls) or issubclass(cls, xsd_data_types.TypeList): return False return _nearest_builtin(cls) is not None def _nearest_builtin(cls: type) -> type | None: """Returns the nearest builtin data type *cls* derives from, or ``None``. The compact alias a dropped simple type spells as is its nearest builtin ancestor's: a type restricted from ``xs:string`` reads as ``String``'s alias, and a restriction chain resolves the same way the runtime classes do. """ for klass in cls.__mro__: if klass is not cls and klass in _BUILTIN_STUB_NAMES: return klass return None def _compact_spellings( inventory: list[_StubClass], skipped: list[_StubClass] ) -> dict[str, _Spelling]: """Returns the annotation-name replacements the compact view applies. A dropped simple type spells as the compact alias of the builtin data type it derives (``SKU`` reads ``str``); the facet comment on the use site carries what the class added. Every remaining builtin data type's stub name maps to its alias. A schema class that shadows a builtin's *name* (a type literally named ``Int``) keeps its own name — the class wins, as in the full renderer's import scan — and a class that shadows an *alias text* (a type literally named ``str``) suppresses that alias family: the builtin keeps its long spelling, and a dropped simple type in the family spells as that long name, so no annotation reads ``str`` for both. """ spellings: dict[str, _Spelling] = {} class_names = {entry.name for entry in inventory} for entry in skipped: base = _nearest_builtin(entry.cls) if base is None: # unreachable for a foldable class; never guess continue spellings[entry.name] = _compact_spelling(entry.name, base, class_names) for cls, alias in _COMPACT_ALIASES.items(): name = _BUILTIN_STUB_NAMES[cls] if name in class_names or alias in class_names: continue spellings[name] = _Spelling(alias, name) return spellings def _compact_spelling(folded: str, base: type, class_names: set[str]) -> _Spelling: """Returns the spelling for a dropped simple type, alias collisions kept out. The alias text is the builtin's short spelling, which a class literally named after it takes precedence over; the spelling then falls back to the builtin's own stub name (and, when even that is taken, to the folded class's name, which at least stays unique). """ alias = _COMPACT_ALIASES.get(base, base.__name__) if alias in class_names: long = _BUILTIN_STUB_NAMES.get(base, base.__name__) alias = long if long not in class_names else folded return _Spelling(alias, base.__name__) def _spelling_pattern(spellings: dict[str, _Spelling]) -> re.Pattern[str] | None: """Returns the word-boundary pattern matching every spelled name.""" if not spellings: return None names = "|".join(re.escape(name) for name in sorted(spellings, key=len, reverse=True)) return re.compile(rf"\b(?:{names})\b") def _spell_text( text: str, pattern: re.Pattern[str] | None, spellings: dict[str, _Spelling], facets_by_name: dict[str, list[str]], used: dict[str, list[str]], ) -> tuple[str, list[str]]: """Rewrites *text* into compact spellings; returns it with the facets it inlines. Only whole identifiers are rewritten, so ``list[SKU]`` becomes ``list[str]``. A spelling that differs from the pyxsd class it stands for is recorded in *used*, in first-use order, for the header's legend; one alias may fold several pyxsd classes, and the legend names each of them. The facets of a dropped simple type found in the text are returned for the member's comment. """ if pattern is None: return text, [] inlined: list[str] = [] def replace(match: re.Match[str]) -> str: name = match.group(0) spelling = spellings[name] if spelling.text != spelling.source: sources = used.setdefault(spelling.text, []) if spelling.source not in sources: sources.append(spelling.source) for item in facets_by_name.get(name, ()): if item not in inlined: inlined.append(item) return spelling.text return pattern.sub(replace, text), inlined def _compact_facets( entry: _StubClass, entries_by_id: dict[int, _StubClass], skipped_ids: set[int] ) -> list[str]: """Returns the facet items *entry*'s compact rendering inlines. The class's own facets come first; a skipped simple-type ancestor (a restriction chain whose parent class the compact view dropped) contributes its facets too, because no line of its own renders them. """ items = _facet_items(entry) if _is_simple_type(entry.cls) else [] seen = set(items) for klass in entry.cls.__mro__: ancestor = entries_by_id.get(id(klass)) if ancestor is None or ancestor is entry or id(ancestor.cls) not in skipped_ids: continue for item in _facet_items(ancestor): if item not in seen: seen.add(item) items.append(item) return items def _compact_class_block( entry: _StubClass, base: str, member_lines: list[str], facets: list[str], warnings: list[str], ) -> str: """Returns one compact class block: a header line, then one line per member. A class deriving only from ``SchemaBase`` shows no base; its class-level degradations (:func:`_class_degradations`) follow the header as comment lines, so the compact view carries the same truthfulness notes as the full one. A simple type's facets ride on the header line, and a class with no members, warnings and facets renders ``...`` so the header is not a dangling colon. """ lines: list[str] = [] if entry.note: lines.append(f"# {entry.note}") header = f"class {entry.name}" if base != "SchemaBase": header += f"({base})" facet_comment = f" # {'; '.join(facets)}" if facets else "" if member_lines or warnings: lines.append(f"{header}:{facet_comment}") lines.extend(f" # {warning}" for warning in warnings) lines.extend(member_lines) elif facets: lines.append(f"{header}:{facet_comment}") else: lines.append(f"{header}: ...") return "\n".join(lines) def _compact_member_lines( members: list[_Member], pattern: re.Pattern[str] | None, spellings: dict[str, _Spelling], facets_by_name: dict[str, list[str]], used: dict[str, list[str]], ) -> list[str]: """Returns one compact line per member. A comment-only member stays a comment, an element or attribute a ``name: annotation`` line, and an overlay member a one-line signature (a property reads as a value). A folded simple type's facets append to the member's comment; the annotation's spellings are rewritten through the compact alias table. """ lines: list[str] = [] for member in members: if not member.name: if member.comment: lines.append(f" # {member.comment}") continue annotation, inlined = _spell_text( member.annotation, pattern, spellings, facets_by_name, used ) parts = [member.comment] if member.comment else [] parts.extend(inlined) comment = f" # {'; '.join(parts)}" if parts else "" if member.kind == "extension": lines.append(_compact_extension_line(member, annotation, comment)) continue lines.append(f" {member.name}: {annotation}{comment}") return lines def _compact_extension_line(member: _Member, annotation: str, comment: str) -> str: """Returns one compact line for an overlay function or property. A property reads as a value (``label: str``), a function as a one-line signature (``def double(self, count: int) -> str: ...``); a signature that cannot be split degrades to ``Any`` with the member's comment, never a broken line. """ params, returns = _split_signature(annotation) if member.is_property: line = f" {member.name}: {returns if returns is not None else 'Any'}" elif params is None or returns is None: line = f" def {member.name}(self) -> Any: ..." else: line = f" def {member.name}{params} -> {returns}: ..." return line + comment def _compact_roots(aliases: list[_RootAlias], spell: Callable[[str], str]) -> str | None: """Returns the trailing root-element comment line, or ``None``. Every global element is listed once as ``name: Type``; a root whose type could not be resolved keeps its name with ``?`` in place of a type rather than naming a class the document does not contain. """ if not aliases: return None parts: list[str] = [] for alias in aliases: type_text = spell(alias.type_name) if alias.type_name is not None else "?" parts.append(f"{alias.name}: {type_text}") return "# roots: " + ", ".join(parts)