Skip to content

odd_schema

Load an ODD and compile it by following schemaSpec/@source (schema specs and processing models). A TEI-targeting ODD (schemaSpec/@ns absent or the TEI namespace) is merged onto cached p5subset.xml (TEI/@version selects a Vault release). JATS / DocBook set another @ns and skip that base. use_guidelines (opm odd document without SOURCE) documents TEI alone; it is not shipped in the wheel.

opm.odd_schema

Load an ODD and compile it by following schemaSpec/@source.

An ODD may declare content models, processing models, or both. Compilation walks the inheritance chain (parent ODDs first), then overlays local specs with mode of add / change / delete / replace — including model / modelGrp / modelSequence.

TEI-targeting ODDs (schemaSpec/@ns absent or the TEI namespace) are merged onto cached p5subset.xml so moduleRef and content models resolve. TEI/@version selects a Vault release; otherwise the latest release is fetched. It is not shipped in the wheel. JATS / DocBook ODDs (@ns empty or another vocabulary) are not merged onto TEI.

Specs and chapter prose are compiled separately and joined at the end: the specs come from the moduleRef / @mode merge, the prose from the input itself or, when the input is the schema being documented, from the downloaded TEI Source/ Guidelines. The result is one tree carrying both.

SchemaError

Bases: ValueError

The input is not an ODD / compiled spec document we can document.

CompiledSchema dataclass

CompiledSchema(
    tree: _Element,
    source_path: Path | None = None,
    title: str = "",
    ident: str = "",
    warnings: list[str] = list(),
    fetched_source: Path | None = None,
)

A schema ready to index: merged specs, plus chapter prose when there is any.

p5all_cache_path

p5all_cache_path() -> Path

Where the decompressed TEI artifact lives once fetched.

Source code in src/opm/odd_schema.py
def p5all_cache_path() -> Path:
    """Where the decompressed TEI artifact lives once fetched."""
    return user_opm_cache_dir() / 'tei' / 'p5all.xml'

ensure_p5all

ensure_p5all(
    *,
    fetch: bool = True,
    url: str | None = None,
    checksums_url: str | None = None,
) -> Path

Return a local p5all.xml, downloading it into the user cache if needed.

One file, one request. It carries the same specs as p5subset.xml plus the Guidelines prose, so there is nothing else to fetch and no version to resolve — the artifact tracks TEI's latest release.

Source code in src/opm/odd_schema.py
def ensure_p5all(
    *,
    fetch: bool = True,
    url: str | None = None,
    checksums_url: str | None = None,
) -> Path:
    """Return a local ``p5all.xml``, downloading it into the user cache if needed.

    One file, one request. It carries the same specs as ``p5subset.xml`` plus
    the Guidelines prose, so there is nothing else to fetch and no version to
    resolve — the artifact tracks TEI's latest release.
    """
    dest = p5all_cache_path()
    if dest.is_file() and dest.stat().st_size > 0:
        return dest
    if not fetch:
        raise SchemaError(
            f'The TEI schema is not cached at {dest} and downloading is off. '
            f'Put a p5all.xml there to work offline.'
        )
    dest.parent.mkdir(parents=True, exist_ok=True)
    archive = dest.with_suffix('.xml.gz.part')
    try:
        _download_file(url or P5ALL_URL, archive)
        _verify_checksum(
            archive, P5ALL_ASSET, checksums_url or P5ALL_CHECKSUMS_URL,
        )
        _gunzip(archive, dest)
    finally:
        archive.unlink(missing_ok=True)
    return dest

ensure_guideline_images

ensure_guideline_images(
    *,
    fetch: bool = True,
    url: str | None = None,
    checksums_url: str | None = None,
) -> Path

Return the directory the Guidelines' figure URLs resolve against.

That is the directory of the cached p5all.xml, so Images/… sits beside it just as it does beside the chapters in TEI's source. The figures are downloaded on first use and unpacked whole or not at all.

Source code in src/opm/odd_schema.py
def ensure_guideline_images(
    *,
    fetch: bool = True,
    url: str | None = None,
    checksums_url: str | None = None,
) -> Path:
    """Return the directory the Guidelines' figure URLs resolve against.

    That is the directory of the cached ``p5all.xml``, so ``Images/…`` sits
    beside it just as it does beside the chapters in TEI's source. The figures
    are downloaded on first use and unpacked whole or not at all.
    """
    base = p5all_cache_path().parent
    images = base / 'Images'
    if images.is_dir() and any(images.iterdir()):
        return base
    if not fetch:
        raise SchemaError(
            f'The TEI figures are not cached at {images} and downloading is off.'
        )
    base.mkdir(parents=True, exist_ok=True)
    archive = base / f'{IMAGES_ASSET}.part'
    staging = Path(tempfile.mkdtemp(prefix='images-', dir=base))
    try:
        _download_file(url or IMAGES_URL, archive)
        _verify_checksum(
            archive, IMAGES_ASSET, checksums_url or P5ALL_CHECKSUMS_URL,
        )
        try:
            with tarfile.open(archive, 'r:gz') as tar:
                # 'data' refuses links, devices and paths leaving staging.
                tar.extractall(staging, filter='data')
        except (tarfile.TarError, OSError, EOFError) as exc:
            raise SchemaError(f'Could not unpack {IMAGES_ASSET}: {exc}') from exc
        unpacked = staging / 'Images'
        if not unpacked.is_dir():
            raise SchemaError(f'{IMAGES_ASSET} holds no Images/ directory.')
        shutil.rmtree(images, ignore_errors=True)
        unpacked.replace(images)
    finally:
        archive.unlink(missing_ok=True)
        shutil.rmtree(staging, ignore_errors=True)
    return base

normalize_tei_version

normalize_tei_version(raw: str | None) -> str

4.8.0 / 4.8 stay as Vault ids; anything else means latest.

Source code in src/opm/odd_schema.py
def normalize_tei_version(raw: str | None) -> str:
    """``4.8.0`` / ``4.8`` stay as Vault ids; anything else means latest."""
    if not raw:
        return 'current'
    value = raw.strip()
    if _TEI_VERSION_RE.match(value):
        return value
    return 'current'

load_xml

load_xml(path: Path) -> _Element

Parse path, or a directory of spec files, into one TEI tree.

Source code in src/opm/odd_schema.py
def load_xml(path: Path) -> etree._Element:
    """Parse *path*, or a directory of spec files, into one TEI tree."""
    path = path.resolve()
    if path.is_dir():
        return _load_spec_directory(path)
    if not path.is_file():
        raise SchemaError(f'No such file or directory: {path}')
    return parse_xml(path)

targets_tei

targets_tei(root: _Element) -> bool

True when schemaSpec/@ns is absent or the TEI namespace.

JATS (ns="") and DocBook (another namespace) are not TEI customizations.

Source code in src/opm/odd_schema.py
def targets_tei(root: etree._Element) -> bool:
    """True when ``schemaSpec/@ns`` is absent or the TEI namespace.

    JATS (``ns=""``) and DocBook (another namespace) are not TEI customizations.
    """
    schema = _main_schema_spec(root)
    if schema is None:
        return localname(root) == 'TEI' or root.nsmap.get(None) == TEI_NS
    ns = schema.get('ns')
    if ns is None:
        return True
    return ns == TEI_NS

has_guidelines_chapters

has_guidelines_chapters(root: _Element) -> bool

True when root is a Guidelines-like document with chapter divs.

Source code in src/opm/odd_schema.py
def has_guidelines_chapters(root: etree._Element) -> bool:
    """True when *root* is a Guidelines-like document with chapter ``div``s."""
    return bool(list(iter_guideline_chapters(root)))

iter_guideline_chapters

iter_guideline_chapters(root: _Element)

Yield top-level front/body/back divs used as Guidelines chapters.

Dedication / title-page verso and other typed front divs are included, and so are the A–Z catalog stubs opm odd document puts in back matter: they are its appendices.

Source code in src/opm/odd_schema.py
def iter_guideline_chapters(root: etree._Element):
    """Yield top-level front/body/back ``div``s used as Guidelines chapters.

    Dedication / title-page verso and other typed front ``div``s are included,
    and so are the A–Z catalog stubs ``opm odd document`` puts in back matter:
    they are its appendices.
    """
    text = root.find(f'.//{qn("text")}')
    if text is None:
        # Default namespace documents still match via local-name walks below.
        text = next((el for el in root.iter() if localname(el) == 'text'), None)
    if text is None:
        return
    for part_name in ('front', 'body', 'back'):
        for part in text:
            if localname(part) != part_name:
                continue
            for child in part:
                if localname(child) != 'div':
                    continue
                yield child

compile_schema

compile_schema(
    path: Path | str | None = None,
    *,
    use_guidelines: bool = False,
    fetch: bool = True,
    p5all_url: str | None = None,
) -> CompiledSchema

Load path and merge it along schemaSpec/@source.

  • Parent ODDs are applied first, then the local file — the same order parse_odd uses for processing models.
  • A TEI-targeting ODD (schemaSpec/@ns absent or the TEI namespace) is merged onto the cached TEI artifact so moduleRef and content models resolve. It tracks TEI's latest release; an ODD pinning an older TEI/@version is compiled against the latest with a warning. JATS / DocBook set a different @ns and are left alone.
  • use_guidelines documents TEI alone.
  • A compiled document (Guidelines p5.xml, p5subset.xml, a directory of Specs) with no further inheritance is returned as-is.
  • Chapter prose from the input is always kept. When the input is the schema being documented (use_guidelines, a Guidelines p5.xml, a Specs directory) its chapters are published too. A customization merged onto TEI documents itself, so TEI's chapters stay out of its site.
Source code in src/opm/odd_schema.py
def compile_schema(
    path: Path | str | None = None,
    *,
    use_guidelines: bool = False,
    fetch: bool = True,
    p5all_url: str | None = None,
) -> CompiledSchema:
    """Load *path* and merge it along ``schemaSpec/@source``.

    * Parent ODDs are applied first, then the local file — the same order
      [`parse_odd`][opm.odd_compiler.parse_odd.ParsedOdd] uses for processing models.
    * A TEI-targeting ODD (``schemaSpec/@ns`` absent or the TEI namespace) is
      merged onto the cached TEI artifact so ``moduleRef`` and content models
      resolve. It tracks TEI's latest release; an ODD pinning an older
      ``TEI/@version`` is compiled against the latest with a warning. JATS /
      DocBook set a different ``@ns`` and are left alone.
    * ``use_guidelines`` documents TEI alone.
    * A compiled document (Guidelines ``p5.xml``, ``p5subset.xml``, a directory
      of Specs) with no further inheritance is returned as-is.
    * Chapter prose from the input is always kept. When the input *is* the
      schema being documented (``use_guidelines``, a Guidelines ``p5.xml``, a Specs
      directory) its chapters are published too. A customization merged onto
      TEI documents itself, so TEI's chapters stay out of its site.
    """
    warnings: list[str] = []
    fetched: Path | None = None

    input_path: Path | None = Path(path).resolve() if path else None
    if input_path is None and not use_guidelines:
        raise SchemaError(
            'Pass an ODD / compiled spec document, or use_guidelines=True to document the TEI schema.'
        )

    root: etree._Element | None = None
    if input_path is not None:
        root = load_xml(input_path)

    if (
        root is not None
        and _is_standalone_spec_document(
            root, input_path, use_guidelines=use_guidelines,
        )
    ):
        tree = _apply_prose(
            root,
            _prose_document(
                root, None, documenting_base=True,
            ),
        )
        return CompiledSchema(
            tree=tree,
            source_path=input_path,
            title=_title_of(root, input_path),
            warnings=warnings,
        )

    chain: list[Path] = []
    if input_path is not None and input_path.is_file():
        chain = _inheritance_chain(input_path)

    source_root: etree._Element | None = None
    source_path: Path | None = None
    need_tei = use_guidelines or (
        root is not None
        and (_schema_source_is_tei(root) or targets_tei(root))
    )
    if need_tei:
        fetched = ensure_p5all(fetch=fetch, url=p5all_url)
        source_root = load_xml(fetched)
        source_path = fetched
        warnings.append(f'Using the TEI schema from {fetched}')
        warnings.extend(
            _version_pin_notes(_requested_tei_version(root, chain), source_root)
        )

    skip = source_path.resolve() if source_path is not None else None
    if source_root is not None:
        tree = source_root
        overlays = [p for p in chain if skip is None or p.resolve() != skip]
        if not overlays and input_path is not None and input_path.is_file():
            if skip is None or input_path.resolve() != skip:
                overlays = [input_path]
    elif chain:
        tree = load_xml(chain[0])
        overlays = chain[1:]
    elif root is not None:
        schema = _main_schema_spec(root)
        if schema is not None and schema.find(qn('moduleRef')) is not None:
            raise SchemaError(
                f'{input_path} uses moduleRef but no schema source was found. '
                'A TEI customization is merged onto the TEI schema automatically; '
                'this ODD neither targets TEI nor declares its own modules with '
                'moduleSpec.'
            )
        tree = _apply_prose(
            root,
            _prose_document(
                root, None, documenting_base=True,
            ),
        )
        return CompiledSchema(
            tree=tree,
            source_path=input_path,
            title=_title_of(root, input_path),
            warnings=warnings,
        )
    else:
        raise SchemaError(
            'Pass an ODD / compiled spec document, or use_guidelines=True to document the TEI schema.'
        )

    for overlay_path in overlays:
        overlay = root if overlay_path == input_path else load_xml(overlay_path)
        tree = _odd2odd(tree, overlay)

    title_root = root if root is not None else tree
    title = _title_of(title_root, input_path) or _title_of(tree, source_path)
    tree = _apply_prose(
        tree,
        _prose_document(
            root,
            source_root,
            # With no customization applied, the source document is itself what
            # we are documenting, so its chapters are the site's chapters.
            documenting_base=root is None,
        ),
    )
    _keep_header(tree, title_root)
    return CompiledSchema(
        tree=tree,
        source_path=input_path or source_path,
        title=title,
        warnings=warnings,
        fetched_source=fetched,
        ident=schema_ident(title_root, input_path) if title_root is not None else '',
    )

compiled_odd

compiled_odd(compiled: CompiledSchema) -> _Element

compiled as a standalone ODD, for tools such as the TEI Stylesheets' odd2relax.

The merge leaves one schemaSpec with every spec in it and no moduleRef to resolve, but only its @ident. This gives it back the other attributes of the customization's schemaSpec: @start, @ns, @prefix and the like, from the input ODD first and its parents after. @source is dropped, since nothing is left to inherit from.

Source code in src/opm/odd_schema.py
def compiled_odd(compiled: CompiledSchema) -> etree._Element:
    """*compiled* as a standalone ODD, for tools such as the TEI Stylesheets' odd2relax.

    The merge leaves one ``schemaSpec`` with every spec in it and no ``moduleRef``
    to resolve, but only its ``@ident``. This gives it back the other attributes of
    the customization's ``schemaSpec``: ``@start``, ``@ns``, ``@prefix`` and the like,
    from the input ODD first and its parents after. ``@source`` is dropped, since
    nothing is left to inherit from.
    """
    tree = deepcopy(compiled.tree)
    target = _main_schema_spec(tree)
    path = compiled.source_path
    if target is None or path is None or not path.is_file():
        return tree
    for odd in reversed(_inheritance_chain(path)):
        schema = _main_schema_spec(load_xml(odd))
        if schema is None:
            continue
        for name, value in schema.attrib.items():
            if name not in ('source', 'ident') and name not in target.attrib:
                target.set(name, value)
    return tree

chapters_have_prose

chapters_have_prose(root: _Element) -> bool

True when guideline chapters contain prose outside specification elements.

p5subset.xml keeps the division/heading skeleton (and inlined specs) but strips ordinary chapter paragraphs — those chapters must not be treated as a full Guidelines document.

Source code in src/opm/odd_schema.py
def chapters_have_prose(root: etree._Element) -> bool:
    """True when guideline chapters contain prose outside specification elements.

    ``p5subset.xml`` keeps the division/heading skeleton (and inlined specs)
    but strips ordinary chapter paragraphs — those chapters must not be treated
    as a full Guidelines document.
    """
    return any(chapter_has_prose(div) for div in iter_guideline_chapters(root))

chapter_has_prose

chapter_has_prose(div: _Element) -> bool

True when div has ordinary prose, not only inlined specs.

Front matter such as the title-page verso is often a list with no p; those still belong on the site.

Source code in src/opm/odd_schema.py
def chapter_has_prose(div: etree._Element) -> bool:
    """True when *div* has ordinary prose, not only inlined specs.

    Front matter such as the title-page verso is often a ``list`` with no
    ``p``; those still belong on the site.
    """
    for el in div.iter():
        if not isinstance(el.tag, str) or localname(el) not in _PROSE_TAGS:
            continue
        if _inside_spec_documentation(el):
            continue
        return True
    return False

schema_ident

schema_ident(
    root: _Element, path: Path | None = None
) -> str

schemaSpec/@ident, else the source file stem, else schema.

Source code in src/opm/odd_schema.py
def schema_ident(root: etree._Element, path: Path | None = None) -> str:
    """``schemaSpec/@ident``, else the source file stem, else ``schema``."""
    schema = _main_schema_spec(root)
    if schema is not None:
        ident = (schema.get('ident') or '').strip()
        if ident:
            return ident
    if path is not None:
        stem = path.stem.strip()
        if stem and stem not in {'.', '..'}:
            return stem
    return 'schema'