Skip to content

properties/__init__

Full documentation pages are generated for docstring reference only and may contain symbols imported from other modules. Imported symbols are not distinguished from locally defined symbols and will appear in any module that they are imported into. For better information on where symbols should be imported from, review the sourcecode on the github.

FoSpy._docs.properties

CLI_TBL_FMT module-attribute

CLI_TBL_FMT = 'grid'

DOCS_DIR module-attribute

DOCS_DIR = module_dir / '../../../../mkdocs/docs'

DOCS_URL module-attribute

DOCS_URL = 'https://errthumt.github.io/FoSpy/latest/'

MD_DIR module-attribute

MD_DIR = DOCS_DIR / 'expected'

PREAMBLE module-attribute

PREAMBLE = module_dir / 'preamble.md'

PROP_DESCS module-attribute

PROP_DESCS = module_dir / 'descriptions.json'

PROP_URL module-attribute

PROP_URL = (
    "https://errthumt.github.io/FoSpy/latest/expected/"
)

TAB_WIDTH module-attribute

TAB_WIDTH = 80

TEMPLATE_DIR module-attribute

TEMPLATE_DIR = module_dir / 'summary_stubs'

module_dir module-attribute

module_dir = Path(os.path.abspath(__file__)).parent

val_rules module-attribute

val_rules = {
    str: "Any text entry",
    float: "Any decimal number (positive or negative)",
    int: "Any integer (positive or negative)",
    bool: "Python `True` or `False`",
    list: "A list of values",
    tuple: "A tuple of values",
    dict: "A dictionary of values",
    chemformula.ChemFormula: "A chemical formula recognized by the [`chemformula` package.](https://pypi.org/project/chemformula/)",
}

Summaries of validation rules mapped to validator functions.

Rule lists are formatted as markdown lists before being mapped to a validator.

Possible Validators

SingleBlock subclasses: A single rule points to the validating class. ListBlock subclasses: A single rule points to the SingleBlock class enforced by the ListBlock. Other Validators: The validator function is decorated with @_validator_rules(), which specifies a list of rules to be mapped in this dictionary.

_clean_diffs

_clean_diffs(diffs)
Source code in FoSpy/_docs/properties/__init__.py
def _clean_diffs(diffs):
    if isinstance(diffs, list):
        return [_clean_diffs(v) for v in diffs if _clean_diffs(v)]
    elif isinstance(diffs, dict):
        return {k: _clean_diffs(v) for k, v in diffs.items() if _clean_diffs(v)}
    else:
        return diffs

_get_block_module_path

_get_block_module_path(attr_name)

Gets the actual module path for a specific attribute in FoSpy.blocks.

Source code in FoSpy/_docs/properties/__init__.py
def _get_block_module_path(attr_name: str) -> str:
    """Gets the actual module path for a specific attribute in FoSpy.blocks."""
    import inspect
    import FoSpy.blocks

    # 1. Retrieve the attribute safely from the parent module
    try:
        obj = getattr(FoSpy.blocks, attr_name)
    except AttributeError:
        raise AttributeError(f"'{attr_name}' does not exist in FoSpy.blocks")

    # 2. Extract the true source module name
    # inspect.getmodule() works reliably for classes, functions, and methods
    module_obj = inspect.getmodule(obj)

    if module_obj is not None:
        return module_obj.__name__ + "." + attr_name

    # Fallback for basic data types (ints, strings) that lack module metadata
    if hasattr(obj, '__module__'):
        return obj.__module__

    return FoSpy.blocks.__name__

_get_header_lines

_get_header_lines(cls_nm, parent_nm, mode='cli')
Source code in FoSpy/_docs/properties/__init__.py
def _get_header_lines(cls_nm, parent_nm, mode="cli"):
    lines = []
    if mode in ("md-tb", "md"):
        lines.append(f"### `{cls_nm}`\n\n")
        bold = "**"
    else:
        if mode != "cli":
            warn(f"Unrecognized mode: {mode}. Defaulting to CLI formatting for header lines.")
        header = f"===== Property Summary for {cls_nm} =====\n\n"
        pre = "^" * len(header.strip()) + "\n"
        lines.extend([
            pre, header,
            "URLs are surrounded by ~ characters. FoSpy cross-references "
            "are surrounded by <> characters.\n",
            "URL and cross-reference destinations are listed at the end of this message.\n\n",
        ])
        bold = ""

    lines.append(f"[Class Documentation][blockdocs-{cls_nm}]\n\n")
    link = f"(#{parent_nm.lower()})" if not parent_nm == "Block" else "[blockdocs-Block]"
    lines.append(f"{bold}[Subclass of `{parent_nm}`]{link}{bold}\n\n")

    return lines

_load_all_validators

_load_all_validators()

Dynamically imports all modules in the validators package to trigger decorators.

Source code in FoSpy/_docs/properties/__init__.py
def _load_all_validators():
    """Dynamically imports all modules in the validators package to trigger decorators."""
    import pkgutil
    import importlib
    from ...parsing import validators
    for _, module_name, _ in pkgutil.walk_packages(validators.__path__, validators.__name__ + "."):
        importlib.import_module(module_name)

_md_to_mode

_md_to_mode(txt, mode='cli', urls={}, crossrefs={})
Source code in FoSpy/_docs/properties/__init__.py
def _md_to_mode(txt, mode="cli", urls={}, crossrefs={}):
    if mode in ("md", "md-tb"):
        return txt

    from tabulate import tabulate

    urls = urls.copy()
    crossrefs = crossrefs.copy()
    indent = 0
    out_txt = ""
    current_h = 1
    lines = txt.splitlines(keepends=True)

    for ln in lines:
        ln, urls, crossrefs = _strip_links(ln, urls=urls, crossrefs=crossrefs)
        ln = ln.replace("`", "")

        if ln.startswith("#"):
            h = ln.count("#")
            if h > current_h:
                indent += 1
            elif h < current_h:
                indent -= 1
            current_h = h

            header = ln.lstrip("#").strip()
            header = f"==== {header} ===="
            out_txt += "  "*indent + header + "\n"
        else:
            out_txt += "  "*indent + ln

    urls.pop("_repeats_", None)
    if urls != {}:
        out_txt += "\n\n==== URLS ====\n\n"

        key_width = max([len(k) for k in ["Text Reference", *urls.keys()]])
        url_width = TAB_WIDTH - key_width - 3

        urls = {
            k: _wrap_url_clickable(v, max_len=url_width)
            for k, v in urls.items()
        }
        out_txt += tabulate(urls.items(), headers=["Text Reference", "URL"],
                            tablefmt=CLI_TBL_FMT, maxcolwidths=(key_width, None))

    crossrefs.pop("_repeats_", None)
    if crossrefs != {}:
        out_txt += "\n\n==== CROSS-REFERENCES ====\n\n"

        key_width = max([len(k) for k in ["Text Reference", *crossrefs.keys()]])
        ref_width = TAB_WIDTH - key_width - 3

        out_txt += tabulate(crossrefs.items(), headers=["Text Reference", "Reference"],
                            tablefmt=CLI_TBL_FMT, maxcolwidths=(key_width, ref_width))


    return out_txt
_strip_links(txt, urls=None, crossrefs=None)
Source code in FoSpy/_docs/properties/__init__.py
def _strip_links(txt, urls=None, crossrefs=None):
    txt = txt.replace("`","")

    urls = urls or {}
    crossrefs = crossrefs or {}

    urls.setdefault("_repeats_", {})
    url_rpts = urls["_repeats_"]

    crossrefs.setdefault("_repeats_", {})
    cr_rpts = crossrefs["_repeats_"]

    # Pattern matches either:
    # 1. (?P<cr_text>\[.*?\])(?P<cr_link>\[.*?\]) -> [crossref text][crossref]
    # 2. (?P<url_text>\[.*?\])(?P<url_link>\(.*?\)) -> [url text](url)
    # Note: We escape the outer brackets/parentheses to match literal syntax
    pattern = r'(?P<cr_text>\[[^\]]+\])(?P<cr_link>\[[^\]]+\])|(?P<url_text>\[[^\]]+\])\((?P<url_link>[^\)]+)\)'

    def replacer(match):
        # --- Crossref Branch ---
        if match.group('cr_text'):
            # Strip the outer brackets from the match groups
            text = match.group('cr_text')[1:-1]
            link = match.group('cr_link')[1:-1]

            if link.startswith("blockdocs-"):
                block_nm = link[10:]
                link = _get_block_module_path(block_nm)

            # Handle repeats
            rpts = cr_rpts
            if text in rpts:
                if link not in rpts[text]:
                    idx = len(rpts[text])
                    rpts[text].append(link)
                else:
                    idx = rpts[text].index(link)

                unique_text = f"{text} ({idx})" if idx > 0 else text
            else:
                rpts[text] = [link]
                unique_text = text

            crossrefs[unique_text] = link

            replace = f"<{unique_text}>"
            return replace

        # --- URL Branch ---
        elif match.group('url_text'):
            # Strip outer brackets and parentheses
            text = match.group('url_text')[1:-1]
            link = match.group('url_link')

            if not link.startswith("http"):
                page_path, anchor = link.split("#",1)
                page_path = MD_DIR / page_path
                rel_page_path = page_path.resolve().relative_to(DOCS_DIR.resolve()).as_posix()

                if rel_page_path.endswith(".md"):
                    rel_page_path = rel_page_path[:-3]

                    if rel_page_path.endswith("/index"):
                        rel_page_path = rel_page_path[:-5]
                    else:
                        rel_page_path += "/"

                elif rel_page_path == "expected":
                    rel_page_path += "/"

                link = f"{DOCS_URL}{rel_page_path}#{anchor}"

            # Handle repeats
            rpts = url_rpts
            if text in rpts:
                if link not in rpts[text]:
                    idx = len(rpts[text])
                    rpts[text].append(link)
                else:
                    idx = rpts[text].index(link)

                unique_text = f"{text} ({idx})" if idx > 0 else text
            else:
                rpts[text] = [link]
                unique_text = text


            urls[unique_text] = link

            replace = f"~{unique_text}~"
            return replace

        return match.group(0) # Fallback (shouldn't be reached)

    # Execute the single-scan replacement
    modified_txt = re.sub(pattern, replacer, txt)

    return modified_txt, urls, crossrefs

_val_rules_to_txt

_val_rules_to_txt(rules, mode='cli', indent=0)
Source code in FoSpy/_docs/properties/__init__.py
def _val_rules_to_txt(rules, mode="cli", indent=0):
    txt = ""
    if not isinstance(rules, (list, tuple)):
        rules = [rules]
    for i,rule in enumerate(rules):
        if isinstance(rule, (list, tuple)):
            i_txt = _val_rules_to_txt(rule, mode=mode, indent=indent+1)
        elif isinstance(rule, str):
            i_txt = rule
        else:
            raise ValueError(f"Unrecognized structure for validation rules: {rule}")

        if mode == "md-tb":
            if i == 0:
                txt += "<ul>"

            if i_txt.startswith("<ul>"):
                txt = txt[:-5] + i_txt + txt[-5:]
            else:
                txt += f"<li>{i_txt}</li>"

            if i == len(rules)-1:
                txt += "</ul>"
        else:
            if mode not in ("cli", "md"):
                warn(f"Unrecognized mode: {mode}. Defaulting to markdown/CLI formatting for list.")
            if isinstance(rule, (list, tuple)) and i_txt.strip().startswith("- "):
                txt += f"{'  '*indent}{i_txt}\n"
            else:
                txt += f"{'  '*indent}- {i_txt}\n"
    return txt

_validator_rules

_validator_rules(*args, inherit_from=None)
Source code in FoSpy/_docs/properties/__init__.py
def _validator_rules(*args, inherit_from=None):
    from ..._docs.properties import val_rules
    def decorator(func, a=args, ih=inherit_from):
        inherited = val_rules.get(ih, [])
        a = list(a)
        a.extend(inherited)
        a = tuple(a)

        if len(a) > 0:
            val_rules[func] = a

        return func
    return decorator

_wrap_preserving_indent

_wrap_preserving_indent(text, width=40)
Source code in FoSpy/_docs/properties/__init__.py
def _wrap_preserving_indent(text, width=40):
    wrapped_lines = []

    for line in text.splitlines():
        # 1. Capture the existing indentation of this specific line
        indentation = line[:len(line) - len(line.lstrip())]

        # 2. If the line is empty, just keep it empty
        if not line.strip():
            wrapped_lines.append("")
            continue

        # 3. Wrap the line, applying the captured indent to subsequent lines
        # subsequent_indent ensures lines 2, 3, etc. line up with line 1
        chunks = textwrap.wrap(
            line, 
            width=width, 
            initial_indent="", 
            subsequent_indent=indentation
        )
        wrapped_lines.extend(chunks)

    return "\n".join(wrapped_lines)

_wrap_url_clickable

_wrap_url_clickable(url, max_len=25)

Splits a URL into multiple lines matching max_len. Wraps each chunk in an OSC 8 sequence pointing to the FULL URL.

Source code in FoSpy/_docs/properties/__init__.py
def _wrap_url_clickable(url, max_len=25):
    """
    Splits a URL into multiple lines matching max_len.
    Wraps each chunk in an OSC 8 sequence pointing to the FULL URL.
    """
    if not url:
        return ""

    chunks = []
    # Split the URL into text segments of max_len width
    for i in range(0, len(url), max_len):
        chunk_text = url[i:i + max_len]

        # Wrap the visual chunk in an OSC 8 sequence pointing to the full URL
        clickable_chunk = f"\033]8;;{url}\033\\{chunk_text}\033]8;;\033\\"
        chunks.append(clickable_chunk)

    # Join with newlines so tabulate stacks them vertically within the cell
    return " \n ".join(chunks)

build_tables

build_tables(
    cls,
    descs,
    enforce=False,
    mode="cli",
    urls={},
    crossrefs={},
)
Source code in FoSpy/_docs/properties/__init__.py
def build_tables(cls, descs, enforce=False, mode="cli", urls={}, crossrefs={}):
    from ...blocks.metadata import Rename
    _load_all_validators()
    def empty_gen():
        while True:
            mt = {"Property": [], "Description": [], "Validation Rules": []}
            yield mt

    empty = empty_gen()

    out = {"req": next(empty), "opt": next(empty)}

    required = cls.build_req_validators()
    optional = cls.build_validators()

    exceptions = []

    def add_to_table(key, prop, val, desc=None, fallback_rules:list=None):
        try:
            if prop == "ext":
                return
            if key == "req":
                optional.pop(prop, None)

            if desc is None:
                desc = find_desc(cls, prop, descs)

            if mode == "cli":
                desc, urls, crossrefs = _strip_links(desc, urls=urls, crossrefs=crossrefs)

            val_rule = val_rules.get(val, None)
            if val_rule is None:
                if enforce and fallback_rules is None:
                    val_nm = val.__name__ if hasattr(val, "__name__") else str(val)
                    raise ValueError(f"No validation rules found for {val_nm} ({prop} validator).")
                elif fallback_rules is not None:
                    val_rule = _val_rules_to_txt(fallback_rules, mode=mode)
                else:
                    val_rule = "No Rules Found"
            else:
                val_rule = _val_rules_to_txt(val_rule, mode=mode)

            if mode == "cli":
                val_rule, urls, crossrefs = _strip_links(val_rule, urls=urls, crossrefs=crossrefs)
                # val_rule = val_rule.replace("\n- ", "</li><li>").replace("- ", "<li>") + "</li>"
                # val_rule = val_rule.replace("\n", "<br>")
                # val_rule = f"<ul>{val_rule}</ul>"
            out[key]["Property"].append(prop)
            out[key]["Description"].append(desc)
            out[key]["Validation Rules"].append(val_rule)
        except Exception as e:
            exceptions.append(e)


    universal_val = cls.universal_val
    add_to_table("req", "**Universal**", universal_val,
                 desc="Rules that apply to all properties of this block.",
                 fallback_rules=["No Universal Rules"])

    for (key, val_set) in (("req",required), ("opt",optional)):
        for prop, val in val_set.items():
            add_to_table(key, prop, val)

    if exceptions:
        raise ExceptionGroup(f"Error(s) occured while building table for {cls.__name__}.", exceptions)

    return out, urls, crossrefs

diff_descs

diff_descs()
Source code in FoSpy/_docs/properties/__init__.py
def diff_descs():
    from ...parsing.validation import(
        required_keys as req_keys,
        optional_keys as opt_keys
    )
    full_descs = get_descs()

    w_descs = get_descs()

    diffs = {"block diffs": {}, "missing blocks": []}

    block_lst = list(set(req_keys.keys()) | set(opt_keys.keys()))

    for blk in block_lst:
        blk_diffs = {"missing": [], "overrided": {}, "extra": {}, "inherited": {}}

        blk_nm = blk.__name__
        validators = blk.build_validators()

        blk_descs = w_descs.pop(blk_nm, {})


        blk_props = req_keys.get(blk, {}) | opt_keys.get(blk, {})
        blk_props = {k:v for k, v in blk_props.items() if not isinstance(v, bool)}
        blk_props.pop("ext", None)

        if blk_descs == {} and blk_props != {}:
            diffs["missing blocks"].append(blk_nm)

            full_descs.setdefault(blk_nm, {})
            for prop in blk_props:
                full_descs[blk_nm].setdefault(prop, {})
                full_descs[blk_nm][prop]["desc"] = None

            continue

        for prop in blk_props.keys():
            desc = blk_descs.pop(prop, {}).get("desc", None)
            if desc is None:
                try:
                    desc = find_desc(blk, prop, get_descs())
                    if desc is None:
                        raise Exception
                    blk_diffs["inherited"][prop] = desc
                except Exception:
                    full_descs[blk_nm].setdefault(prop, {})
                    full_descs[blk_nm][prop]["desc"] = None

                    blk_diffs["missing"].append(prop)

        for prop, desc in blk_descs.items():
            if prop in validators:
                blk_diffs["overrided"][prop] = desc["desc"]
            else:
                blk_diffs["extra"][prop] = desc["desc"]

        diffs["block diffs"][blk_nm] = blk_diffs

    diffs["extra blocks"] = w_descs
    diffs = _clean_diffs(diffs)

    with open(PROP_DESCS, "w", encoding="utf-8") as f:
        json.dump(full_descs, f, indent=4, sort_keys=True)

    return diffs

find_desc

find_desc(cls, prop, descs)
Source code in FoSpy/_docs/properties/__init__.py
def find_desc(cls, prop, descs):
    for parent in cls.__mro__:
        cls_nm = parent.__name__
        prop_set = descs.get(cls_nm, {})
        if prop in prop_set:
            desc = prop_set[prop]["desc"]
            return desc
    raise KeyError(f"Could not find description for {cls.__name__}.{prop}")

get_descs

get_descs()
Source code in FoSpy/_docs/properties/__init__.py
def get_descs():
    with open(PROP_DESCS, "r", encoding="utf-8") as f:
        descs = json.load(f)
    return descs

get_prop_md

get_prop_md(enforce=False)
Source code in FoSpy/_docs/properties/__init__.py
def get_prop_md(enforce=False):
    from ... import blocks as blk_module

    block_lst = sorted(blk_module.__all__)

    txt = "\n## Expected Property Tables\n"

    exceptions = []
    for cls_nm in block_lst:
        cls = getattr(blk_module, cls_nm)

        if isinstance(cls, type) and issubclass(cls, blk_module.SingleBlock):
            try:
                txt += get_summary(cls, mode="md-tb", enforce=enforce)
                txt += "\n\n---\n"
            except Exception as e:
                exceptions.append(e)

    if exceptions:
        raise ExceptionGroup("Property markdown generation failed", exceptions)

    return txt

get_summary

get_summary(cls, enforce=False, mode='cli')
Source code in FoSpy/_docs/properties/__init__.py
def get_summary(cls, enforce=False, mode="cli"):
    from ...blocks import Block

    cls_nm = cls.__name__
    bases = cls.__bases__

    parent = next(c for c in bases if issubclass(c, Block))

    parent_nm = parent.__name__

    descs = get_descs()
    temp_dir = TEMPLATE_DIR

    urls = {
        "Full Fospy Property Documentation": PROP_URL,
        f"Subclass of {parent_nm}": f"{PROP_URL}#{parent_nm.lower()}"
    }
    crossrefs = {}

    exceptions = []

    # @contextmanager
    # def try_summary(step, fallback_func=lambda: None):
    #     try:
    #         yield
    #     except Exception as e:
    #         exceptions.append(Exception(f"Error on summary step: {step}", e))
    #         yield fallback_func()

    def try_summary_call(step, fallback, func, *args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            exc = Exception(f"Error on summary step: {step}")
            exc.__cause__ = e
            exceptions.append(exc)
            return fallback

    tables, urls, crossrefs = try_summary_call(
        "Building table dicts", 
        ({"req": {}, "opt": {}}, {}, {}),
        build_tables,
        cls, descs, enforce=enforce, mode=mode,
        urls=urls, crossrefs=crossrefs
    )

    req_tb_lines = try_summary_call(
        "Building required table lines", [], 
        table_dict_to_lines,
        tables["req"], mode=mode
    )

    opt_tb_lines = try_summary_call(
        "Building optional table lines", [], 
        table_dict_to_lines,
        tables["opt"], mode=mode
    )


    stub_path = temp_dir / f"{cls_nm}.md"

    full_summary = try_summary_call(
        "Getting header lines", None,
        _get_header_lines,
        cls_nm, parent_nm, mode=mode)
    full_summary = [] if full_summary is None else full_summary

    temp_lines = []

    if stub_path.exists():
        with open(stub_path, "r", encoding="utf-8") as f:
            template_lines = f.readlines()
    else:
        template_lines = []

    found = {k: False for k in ["req_hd", "req_tb", "opt_hd", "opt_tb"]}

    def _hd_balanced(found):
        return (
            found["req_hd"] == found["req_tb"] and
            found["opt_hd"] == found["opt_tb"]
        )

    def _process_line(line):
        stripped = line.strip()

        if (line.startswith("#") and
            not _hd_balanced(found)):
            raise Exception("Property table headers must be accompanied by a table placeholder")

        if stripped.endswith("# Required properties"):
            if found["req_hd"]:
                raise Exception("Duplicate required properties header")
            found["req_hd"] = True

        if stripped.endswith("# Optional properties"):
            if found["opt_hd"]:
                raise Exception("Duplicate optional properties header")
            elif not found["req_hd"] and tables["req"] != {}:
                raise Exception("Optional properties header found without preceding required properties header")
            found["opt_hd"] = True

        if stripped == "<prop_table>":
            if _hd_balanced(found) or (
                not found["req_hd"] and not found["opt_hd"]
            ):
                raise Exception("Unexpected property table placeholder")
            if found["opt_hd"]:
                found["opt_tb"] = True
                temp_lines.extend(opt_tb_lines)
            elif found["req_hd"]:
                found["req_tb"] = True
                temp_lines.extend(req_tb_lines)
            else:
                # Shouldn't get here
                raise Exception("Property table placeholder found without preceding header")

            return

        temp_lines.append(line)

    for i, line in enumerate(template_lines):
        try_summary_call(
            f"Processing line {i+1}: {line}", None,
            _process_line, line
        )

    if not _hd_balanced(found):
        if found["req_hd"] and not found["req_tb"]:
            found["req_tb"] = True
            temp_lines.extend(req_tb_lines)
        elif found["opt_hd"] and not found["opt_tb"]:
            found["opt_tb"] = True
            temp_lines.extend(opt_tb_lines)
        else:
            # Shouldn't get here
            exceptions.append(Exception("Property table headers and placeholders not balanced"))

    if not (found["req_hd"] or
            any(col==[] for col in tables["req"].values())):
        full_summary.append("#### Required properties\n\n")
        full_summary.extend(req_tb_lines)
        full_summary.append("\n\n")

    if not (found["opt_hd"] or
            any(col==[] for col in tables["opt"].values())):
        full_summary.append("#### Optional properties\n\n")
        full_summary.extend(opt_tb_lines)
        full_summary.append("\n\n")

    full_summary.extend(temp_lines)

    txt = "".join(full_summary)

    txt = _md_to_mode(txt, mode=mode, urls=urls, crossrefs=crossrefs)

    if exceptions:
        raise ExceptionGroup(f"Summary generation for {cls_nm} failed", exceptions)

    return txt

table_dict_to_lines

table_dict_to_lines(table_dict, mode='cli')
Source code in FoSpy/_docs/properties/__init__.py
def table_dict_to_lines(table_dict, mode="cli"):
    from tabulate import tabulate
    tab_kwargs = {
        "headers": "keys",
        "tablefmt": "pipe",
        "maxcolwidths": None,
        "stralign": None,
        "numalign": None
    }
    if mode == "cli":
        first_col = next(iter(table_dict.keys()))
        prop_width = max([len(k) for k in [first_col, *table_dict[first_col]]])

        cols = len(table_dict.keys())
        col_widths = [(TAB_WIDTH - cols - 1 - prop_width) // (cols - 1) for _ in range(cols-1)]

        tab_kwargs["stralign"] = "left"
        tab_kwargs["numalign"] = "left"
        tab_kwargs["maxcolwidths"] = (prop_width, *col_widths)
        tab_kwargs["tablefmt"] = CLI_TBL_FMT

    txt = tabulate(table_dict, **tab_kwargs)
    lines = txt.splitlines(keepends=True)
    lines.append("\n")

    return lines

write_prop_md

write_prop_md(md_path, delay=False, enforce=False)
Source code in FoSpy/_docs/properties/__init__.py
def write_prop_md(md_path, delay=False, enforce=False):
    diff_exc = None

    diffs = diff_descs()

    overrides = {}

    if "block diffs" in diffs:
        for cls, diff in diffs["block diffs"].items():
            ovrd = diff.pop("overrided", None)
            if ovrd:
                overrides[cls] = ovrd

        diffs["block diffs"] = {k:v for k,v in diffs["block diffs"].items() if v}

    if overrides:
        warning = "The following property descriptions have overridden their parents:\n"

        for cls, ovrd in overrides.items():
            warning += "  "+cls+"\n    "
            warning += "\n    ".join([
                f"{k}: {v}" for k,v in ovrd.items()
            ])
            warning += "\n\n"

        warning = _wrap_preserving_indent(warning, width=80)
        warn(warning)

    diffs = {k:v for k,v in diffs.items() if v}

    exc = Exception(
        f"Property descriptions are out of sync. Diff:\n{diffs}"
        ) if diffs else None

    with open(PREAMBLE, "r", encoding="utf-8") as f:
        preamble = f.read()
    try:
        txt = preamble + get_prop_md(enforce=enforce)
        exc = diff_exc
    except Exception as e:
        txt = ""
        exc = ExceptionGroup(
            "Problem(s) with property documentation.",
            [exc, e]) if exc is not None else e


    if exc is not None:
        import traceback
        txt = ""

        if overrides:
            txt += warning + "\n\n"

        txt += "".join(
            traceback.format_exception(
                type(exc), exc, exc.__traceback__
            )
        )

    def _write(md=md_path, t=txt):
        with open(md, "w", encoding="utf-8") as f:

            f.write(t)


    if delay:
        return exc, _write
    elif exc is None:
        _write()
        return None, lambda: None
    else:
        raise exc