Source code for ctao_datamodel._latex

"""Conversion from Model classes to LaTeX tables."""

import logging
import re
from collections import defaultdict
from pathlib import Path

from astropy import units as u
from astropy.table import Table, join
from pydantic import BaseModel
from pydantic_core import PydanticUndefined

from ._core import type_to_string
from ._visitor import extract_keyword_mapping, walk_model_to_depth

logger = logging.getLogger(__name__)

__all__ = [
    "model_to_table",
    "model_to_latex_table",
    "keyword_mapping_latex_table",
    "generate_latex_table_includes",
]

MISSING = ""  #: blank string for tables with missing values
OPTIONAL_SYMBOL = r"${^\oslash}$"
OPTIONAL_TEXT = f"Fields marked with {OPTIONAL_SYMBOL} are optional."
NONE_TYPE = "Optional"
MODEL_DUMP_OPTIONS = {"by_alias": True}
TABLE_START = r"""
\begin{tblr}{
  width=\linewidth,
  colspec={Q[wd=3.3cm] Q[wd=3cm] X[l]},
  row{odd}={bg=moongray},
  column{1-2} = {font=\scriptsize},
  column{1} = {font=\ttfamily\bfseries\tiny},
  column{3} = {font=\scriptsize},
  row{1} = {bg=galaxyblue, font=\normalfont\bfseries, fg=white}
}
"""
TABLE_END = r"\end{tblr}"

TABLE_START_MAP = r"""
\begin{longtblr}{
  colspec={Q Q Q},
  row{odd}={bg=moongray},
  column{1-3} = {font=\ttfamily},
  row{1} = {bg=galaxyblue, font=\normalfont\bfseries, fg=white},
}
"""
TABLE_END_MAP = r"\end{longtblr}"


def _string_to_latex(text: str) -> str:
    """Transform text string to LaTeX."""
    if not isinstance(text, str):
        return ""

    # markdown backtics to texttt:
    text = re.sub(r"`([^`]+)`", r"\\texttt{\1}", text)

    # markdown bold to emph:
    text = re.sub(r"\*([^*]+)\*", r"\\emph{\1}", text)

    # remove paragraph breaks that cause issues in includes:
    text = text.replace("\n\n", " ")

    # escape underscores that are not already escaped
    return re.sub(r"(?<!\\)_", r"\\_", text)


[docs] def model_to_table( model: type[BaseModel], optional_symbol=OPTIONAL_SYMBOL, type_sep: str = " | " ) -> Table: """Turn a model into an Astropy table.""" data = defaultdict(dict) types = defaultdict(list) has_optional = False # want to stay at depth=2, so we don't recurse for curmodel, path in walk_model_to_depth(model, max_depth=2): # skip parent: if curmodel is model: continue info = path[-1] extra = info.field.json_schema_extra or dict() # Name data[info.item_name]["Name"] = info.item_name if info.is_optional: data[info.item_name]["Name"] += optional_symbol has_optional = True # Types (which may be a list of several) types[info.item_name].append(info.item_type) # Unit in LaTex format if "unit" in extra: data[info.item_name]["Unit"] = u.Unit(extra["unit"]).to_string("latex") else: data[info.item_name]["Unit"] = "" # Description starting with the base description description = [info.field.description if info.field.description else ""] # - Add the default value if info.field.get_default() not in (PydanticUndefined, None): description.append(f" (default {info.field.get_default()})") # - add the examples if info.field.examples: description.append("Ex.: \\newline") examples = [rf" \tabitem{{`'{ex}'`}}" for ex in info.field.examples] description.append("\\newline".join(examples)) # - add the UCD if "ucd" in extra: description.append(rf" \newline(\textbf{{UCD}}: `{extra['ucd']}`)") data[info.item_name]["Description"] = _string_to_latex(" ".join(description)) # compute Type column for name in data: data[name]["Type"] = type_sep.join(type_to_string(t) for t in types[name]) return Table(rows=list(data.values()), meta=dict(has_optional=has_optional))
def _merge_type_and_unit_cols(table: Table) -> Table: """Turn unit and type columns into a single simplified column to save space.""" if len(table) == 0: return table table["Type"] = [ tp if un == "" else f"{tp} ({un})" for tp, un in zip(table["Type"], table["Unit"]) ] del table["Unit"] table = table[["Name", "Type", "Description"]] # col order table.columns["Type"].name = "Type (Unit)" return table def _table_to_latex_table_rows(table: Table, **kwargs) -> str: """Return a LaTeX string for just the rows of the input Table. Values in the table with underscores are escaped. """ import io table = table.copy() for col in table.colnames: if hasattr(col, "mask"): table[col] = col.filled("") table[col] = [_string_to_latex(x) for x in table[col]] out = io.StringIO() table.write(out, format="ascii.latex", **kwargs) return "\n".join([x for x in out.getvalue().split("\n") if not x.startswith("\\")])
[docs] def model_to_latex_table(model: type[BaseModel]) -> str: """Return LaTeX table in format used by Data Model documents.""" doc = _string_to_latex(model.__doc__) if model.__doc__ else "" table = model_to_table(model) opt = table.meta.get("has_optional", True) if table.meta else True return "\n".join( [ r"\begin{classdef}", ( rf"\caption{{\texttt{{{model.__name__}}}:" rf" {doc} {OPTIONAL_TEXT if opt else ''}}}" ), rf"\label{{tab:{model.__name__}}}", TABLE_START, _table_to_latex_table_rows(_merge_type_and_unit_cols(table)), TABLE_END, r"\end{classdef}", ], )
[docs] def keyword_mapping_latex_table( model: type[BaseModel], separator: str = ".", return_table: bool = False ): """Return LaTeX table with mappings to FITS and IVOA. Parameters ---------- model : type[BaseModel] Model class to use. sep : str Separator to use when generating the CTAO key return_table : bool if True, return the Astropy table instead of the LaTeX representation. """ fits_dict = extract_keyword_mapping(model, "fits_keyword", sep=separator) ivoa_dict = extract_keyword_mapping(model, "ivoa_keyword", sep=separator) fits = Table( dict( CTAO=list(fits_dict.keys()), FITS=list(" | ".join(f) for f in fits_dict.values()), ) ) ivoa = Table( dict( CTAO=list(ivoa_dict.keys()), IVOA=list(" | ".join(f) for f in ivoa_dict.values()), ) ) joined = join(ivoa, fits, join_type="outer") best = None if len(ivoa) > 0 and len(fits) > 0: best = joined elif len(ivoa) > 0: best = ivoa elif len(fits) > 0: best = fits else: return "no mappings found" if return_table: return best rows = _table_to_latex_table_rows(best) return f"{TABLE_START_MAP}\n{rows}\n{TABLE_END_MAP}"
[docs] def generate_latex_table_includes( models: list[type[BaseModel]], output_dir: Path | str ) -> None: """Write descriptive LaTeX tables for given models to the output_dir. These can then be included in latex documents. Parameters ---------- models: list[BaseModel] models to write output_dir: Path|str output directory """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) for model in models: filename = output_dir / f"table_{model.__name__}.inc.tex" logger.debug("Writing: %s", filename) filename.write_text(model_to_latex_table(model))