Source code for toycoronagraph.params_docs

"""
Text generated from the parameter declarations (toycoronagraph.params and the source
modules), so it never goes out of date:

    new_config()          a parameter file holding only the chosen modules (+ the JSON schema)
    json_schema()         the JSON schema: editor hover, completion and checks (Even Better TOML)
    write_schema(path)    the JSON schema written to a file
    reference_markdown()  the parameter reference tables
    describe(query)       where a key is set, its unit, default, allowed values and users
    migrate(path)         an old-layout file rewritten in the new layout

Command line: python -m toycoronagraph new | describe | migrate | schema | reference
"""
import os
import json
import difflib
import datetime
try:
    import tomllib # Python >= 3.11
except ModuleNotFoundError: # pragma: no cover
    import tomli as tomllib
from toycoronagraph.params import (AUTO, SCHEMA_FILE, SECTIONS, SOURCE_COMMON, CORONAGRAPHS, CORONAGRAPH_COMMON,
                                   CONFLICT_RULES, LambdaD, ParamError, _QUANTITY, _toml, _line, _sources,
                                   _source_class, from_data, is_legacy, legacy_to_new)

# The [observation] wavelength of templates with a lazuli coronagraph: Lazuli's design wavelength (config_um
# telescope.general.design_wavelength), in the WCC's r' band, where its "auto" wavelengths and throughput have light
LAZULI_WAVELENGTH = 633e-9


def _family(cls, settings):
    """
    The unit family of a module with these settings.

    Args:
        cls (type): a Source subclass.
        settings (dict): {key: value}; keys that are not cls's parameters and None values are ignored.

    Returns:
        str or None: "physical", "relative", or None (decided by the input).
    """
    names = {p.name for p in cls.params}
    return cls(**{k: v for k, v in settings.items() if k in names and v is not None}).family_of()


def _comment(p, used_by=None):
    """
    The comment of a key in generated files: "unit; doc. Used by: ...".

    Args:
        p (Param): the declaration.
        used_by (list of str): who uses it (e.g. source kinds); None or [] = not shown.

    Returns:
        str
    """
    unit = p.unit or ""
    text = (unit + "; " if unit else "") + p.doc
    if used_by:
        text += ". Used by: " + ", ".join(used_by)
    return text


def _example_value(p):
    """
    The value written for a key in generated files: its default, else its example.

    Args:
        p (Param): the declaration.

    Returns:
        object
    """
    return p.default if p.default is not None else p.example


def _odd_image_size(srcs):
    """
    The [grid] size of a template whose grid comes from an image source, for a lazuli coronagraph (which
    needs an odd size): the first image's own size, made odd.

    Args:
        srcs (list): (Source class, {key: value} given) of the template's sources, one at least providing
            grid.size (an image source). The first of those is read, its keys not given taking the values
            the template writes for them.

    Returns:
        int or str: the image's size minus 1 when it is even (the image is resampled onto it); "auto" when
            it is odd, or when the image cannot be read yet (e.g. a file still to be made: the run then names
            the odd sizes to use).
    """
    cls, given = next((cls, s) for cls, s in srcs if "grid.size" in cls.provides)
    try:
        provided = cls(**{p.name: given.get(p.name, _example_value(p)) for p in cls.params}).inspect()
    except (OSError, ValueError):
        return AUTO
    size = next(value for key, value, _ in provided if key == "grid.size")
    return size-1 if size % 2 == 0 else AUTO


[docs] def new_config(sources=("fits_image",), coronagraphs=("vortex:2",), path=None, full=False, schema=True, header=True): """ A parameter file holding only what the chosen modules use. [observation] and [grid] are always written ("auto" from an image source if one provides the pixel scale, else sampling and fov); [star] holds the keys the sources require (and temperature, radius, distance if the sources are in different unit families). Then one [[source]] table per source (its keys without a default are marked "(required)"), one [[coronagraph]] per coronagraph, and [method] if there are several sources. With a lazuli coronagraph, the wavelength is LAZULI_WAVELENGTH (in the r' band of the Lazuli camera) and the image's size is made odd (_odd_image_size), so the file runs as written. Args: sources (list): Source kinds (str), or dicts {kind: ..., key: value} whose values are written instead of the defaults. coronagraphs (list): "type:charge" strings (e.g. "vortex:2"; "" type = vortex), or dicts {type: ..., charge: ...}. path (str): Write it there (and the JSON schema next to it, if schema); None = only return it. full (bool): Also list the optional (advanced) keys and the other sections, commented out. schema (bool): Refer to the JSON schema in the header ("#:schema" line) and write it next to path. header (bool): Start with the header comment lines. Returns: str: the TOML text. Raises: ParamError: an unknown source kind. """ srcs = [] # (Source class, {key: value} given) for s in sources: s = {"kind": s} if isinstance(s, str) else dict(s) cls = _source_class(s["kind"], s.get("module", "")) if cls is None: raise ParamError("unknown source kind %r; known: %s" % (s["kind"], ", ".join(_sources().source_kinds()))) srcs.append((cls, s)) coros = [] # {type: ..., key: value} for c in coronagraphs: if isinstance(c, str): t, _, charge = c.partition(":") c = {"type": t or "vortex"} if charge: c["charge"] = int(charge) if c.get("type", "vortex") not in CORONAGRAPHS: raise ParamError("unknown coronagraph type %r; known: %s" % (c.get("type"), ", ".join(CORONAGRAPHS))) coros.append(c) requires = set() for cls, _ in srcs: requires |= set(cls.requires) families = set() for cls, s in srcs: settings = {p.name: p.default for p in cls.params} settings.update(s) families.add(_family(cls, settings)) families.discard(None) image_source = any("grid.pixel_scale" in cls.provides for cls, _ in srcs) flux_needed = len(families) > 1 # the star's flux puts the components in one unit lines = [] if header: if schema: lines.append("#:schema ./%s" % SCHEMA_FILE) lines += ["# Toy Coronagraph parameters. Hover a key in VS Code (Even Better TOML), or run", "# python -m toycoronagraph describe KEY for details. Every key is optional unless noted.", "# Each quantity is set in ONE place; the values used (and where they came from) are saved with the results.", ""] def used(key): """The kinds of the chosen sources requiring a shared key ("section.name").""" return [cls.kind for cls, _ in srcs if key in cls.requires] def section(name, keys, note=""): """ Append a core section to lines. Args: name (str): the section. keys (dict): {key: value} written; with full, the section's other keys are written commented out. note (str): the comment of the section header; "" = the section's doc. """ doc = SECTIONS[name][0] lines.append("%-26s # %s" % ("[%s]" % name, note or doc)) for p in SECTIONS[name][1]: if p.name in keys: lines.append(_line(p.name, keys[p.name], _comment(p, used("%s.%s" % (name, p.name))), 26)) elif full: lines.append("# " + _line(p.name, _example_value(p), _comment(p), 24)) lines.append("") lazuli = any(c.get("type") == "lazuli" for c in coros) obs = {"wavelength": AUTO if image_source else 1e-6, "diameter": 2.4} if lazuli: obs["wavelength"] = LAZULI_WAVELENGTH section("observation", obs, "the observation; the wavelength: Lazuli's design wavelength (r' band)" if lazuli else "") if image_source: section("grid", {"size": _odd_image_size(srcs) if lazuli else AUTO, "pixel_scale": AUTO}, "the simulated image; \"auto\" = the image source's") else: section("grid", {"sampling": 4.0, "fov": LambdaD(32)}, "the simulated image; star at pixel (N-1)/2") star = {} for p in SECTIONS["star"][1]: key = "star." + p.name if key in requires or (flux_needed and p.name in ("temperature", "radius", "distance")): star[p.name] = p.example if star or full: who = sorted({cls.kind for cls, _ in srcs if set(cls.requires) & {"star." + n for n in star}}) note = ("used by: " + ", ".join(who + (["unit conversion"] if flux_needed else []))) if star else "" section("star", star, note) for cls, s in srcs: lines.append("%-26s # %s" % ("[[source]]", "one table per component; enabled = false switches it off")) lines.append(_line("kind", cls.kind, cls.doc, 26)) for p in SOURCE_COMMON[1:]: # name, enabled, scale, method, module: when given (module: your own modules) if p.name in s: lines.append(_line(p.name, s[p.name], _comment(p), 26)) for p in cls.params: if p.name in s: lines.append(_line(p.name, s[p.name], _comment(p), 26)) elif p.default is None: lines.append(_line(p.name, p.example, _comment(p) + " (required)", 26)) elif not p.advanced: lines.append(_line(p.name, p.default, _comment(p), 26)) elif full: lines.append("# " + _line(p.name, p.default, _comment(p), 24)) lines.append("") for c in coros: doc, params = CORONAGRAPHS[c.get("type", "vortex")] lines.append("%-26s # %s" % ("[[coronagraph]]", "one table per coronagraph; each gets its own results")) lines.append(_line("type", c.get("type", "vortex"), doc, 26)) for p in params: if p.name in c: lines.append(_line(p.name, c[p.name], _comment(p), 26)) elif not p.advanced: lines.append(_line(p.name, p.default, _comment(p), 26)) elif full: lines.append("# " + _line(p.name, p.default, _comment(p), 24)) lines.append("") if len(srcs) > 1 or full: section("method", {"combine": "separate"} if len(srcs) > 1 else {}) if full: for name in ("compute", "checks", "run", "display"): section(name, {}) text = "\n".join(lines).rstrip()+"\n" if path: with open(path, "w") as f: f.write(text) if schema: write_schema(os.path.join(os.path.dirname(os.path.abspath(path)), SCHEMA_FILE)) return text
def _param_schema(p, prefix=""): """ The JSON schema of one key. Args: p (Param): the declaration. prefix (str): text starting the description, e.g. "(wind) ". Returns: dict: the schema: the type (or enum) with its bounds, alternatives in "anyOf" ("auto", the auto_word, a string with a unit; for a list with a unit, its items), "description" / "markdownDescription" (doc, type, default, help) and "default" (unless there is none or it is a LambdaD). """ desc = "%s%s\n\n**Type:** %s" % (prefix, p.doc[0].upper()+p.doc[1:] if p.doc else "", p.describe_type()) if p.default is not None: desc += " \n**Default:** `%s`" % _toml(p.default) if p.help: desc += "\n\n" + p.help if p.choices: base = {"enum": list(p.choices)} elif p.type == "bool": base = {"type": "boolean"} elif p.type == "str": base = {"type": "string"} elif p.type == "table": base = {"type": "object"} elif p.type.endswith("_list"): base = {"type": "array", "items": {"type": {"int_list": "integer", "float_list": "number", "str_list": "string"}[p.type]}} if p.unit: # items may also be text with a unit base["items"] = {"anyOf": [base["items"], {"type": "string", "pattern": _QUANTITY.pattern}]} else: base = {"type": "integer" if p.type == "int" else "number"} if p.min is not None: base["exclusiveMinimum" if p.exclusive_min else "minimum"] = p.min if p.max is not None: base["maximum"] = p.max options = [base] if p.auto and not p.choices: options.append({"const": AUTO}) if p.auto_word: options.append({"const": p.auto_word}) if p.unit and p.type == "float": options.append({"type": "string", "pattern": _QUANTITY.pattern, "description": "a value with a unit, e.g. \"3.22 pc\""}) schema = options[0] if len(options) == 1 else {"anyOf": options} schema["description"] = desc schema["markdownDescription"] = desc if p.default is not None and not isinstance(p.default, LambdaD): schema["default"] = p.default return schema def _either(first, second): """ The schema of a key declared by several modules (or coronagraph types): any of their schemas. Args: first (dict): the schema so far (from _param_schema, or an earlier _either). second (dict): the next module's schema of the key. Returns: dict: {"anyOf": [first, second]} with both descriptions. """ return {"anyOf": [first, second], "description": first["description"] + "\n\n" + second["description"], "markdownDescription": first.get("markdownDescription", "") + "\n\n" + second["markdownDescription"]}
[docs] def json_schema(): """ JSON schema of the parameter file (editor hover, completion and checks; e.g. Even Better TOML). [[source]] tables accept the keys of every kind (a key shared by several kinds: "anyOf" their schemas), then each kind is limited to its own keys; an unknown kind needs `module`. [[coronagraph]] tables likewise, each type also checking the values of its own keys (e.g. a vortex charge >= 2, a lazuli charge >= 0). Returns: dict: the schema (draft-07), ready for json.dump. """ props = {"include": {"type": "array", "items": {"type": "string"}, "description": "Other parameter files to read first (e.g. a shared star description). " "A key set in two files is an error."}} for name, (doc, params) in SECTIONS.items(): props[name] = {"type": "object", "description": doc[0].upper()+doc[1:], "additionalProperties": False, "properties": {p.name: _param_schema(p) for p in params}} kinds = _sources().source_kinds() common = [p.name for p in SOURCE_COMMON] union, per_kind = {}, {} # {key: schema} of all kinds; {kind: its allowed keys} for p in SOURCE_COMMON: union[p.name] = _param_schema(p) kind_doc = ("Which module. Built-in: " + "; ".join("`%s`: %s" % (k, _sources().get_source(k).doc) for k in kinds) + ". Your own module: its kind, with `module` set.") union["kind"] = {"type": "string", "description": kind_doc, "markdownDescription": kind_doc, "examples": kinds} for k in kinds: cls = _sources().get_source(k) per_kind[k] = common + [p.name for p in cls.params] for p in cls.params: entry = _param_schema(p, "(%s) " % k) union[p.name] = _either(union[p.name], entry) if p.name in union and p.name not in common else entry source_item = {"type": "object", "required": ["kind"], "properties": union, "allOf": [{"if": {"properties": {"kind": {"const": k}}, "required": ["kind"]}, "then": {"propertyNames": {"enum": per_kind[k]}}} for k in kinds] + [{"if": {"properties": {"kind": {"not": {"enum": kinds}}}, "required": ["kind"]}, "then": {"required": ["module"]}}]} props["source"] = {"type": "array", "items": source_item, "description": "One table per component of the scene ([[source]]); kind picks the module."} cunion, cper = {p.name: _param_schema(p) for p in CORONAGRAPH_COMMON}, {} # as union, per_kind for the types cunion["type"]["enum"] = list(CORONAGRAPHS) cown = {} # {type: {key: schema}}: each type's own value schemas (a key of several types gets the type's own) for t, (doc, params) in CORONAGRAPHS.items(): cper[t] = [p.name for p in CORONAGRAPH_COMMON] + [p.name for p in params] cown[t] = {p.name: _param_schema(p, "(%s) " % t) for p in params} for name, entry in cown[t].items(): cunion[name] = _either(cunion[name], entry) if name in cunion else entry # a table without type matches every "if": it gets the limits of every type, the vortex's (its default) included props["coronagraph"] = {"type": "array", "description": "One table per coronagraph ([[coronagraph]]).", "items": {"type": "object", "properties": cunion, "allOf": [{"if": {"properties": {"type": {"const": t}}}, "then": {"propertyNames": {"enum": cper[t]}, "properties": cown[t]}} for t in CORONAGRAPHS]}} return {"$schema": "http://json-schema.org/draft-07/schema#", "title": "Toy Coronagraph parameters", "type": "object", "additionalProperties": False, "properties": props}
[docs] def write_schema(path): """ Write json_schema() to a file (indented JSON). Args: path (str): the file (overwritten), normally SCHEMA_FILE next to the parameter file. """ with open(path, "w") as f: json.dump(json_schema(), f, indent=1) f.write("\n")
def _md_rows(params, prefix=""): """ Markdown table rows "| key | default | unit | meaning |". Args: params (iterable of Param): the declarations. prefix (str): text before each key name. Returns: list of str: one row per parameter. """ rows = [] for p in params: default = "—" if p.default is None else "`%s`" % _toml(p.default) rows.append("| `%s%s` | %s | %s | %s |" % (prefix, p.name, default, p.unit or "", p.doc.replace("|", "\\|"))) return rows
[docs] def reference_markdown(): """ Parameter reference tables (README / docs), generated from the declarations. Returns: str: Markdown: the core sections, [[source]] (common keys, then each kind), [[coronagraph]] types, and the rules when values disagree. """ out = ["# Parameter reference", "", "Generated from the declarations in `toycoronagraph/params.py` and the source modules. " "Every key is optional unless a module needs it; each quantity is set in one place.", ""] head = ["| key | default | unit | meaning |", "|---|---|---|---|"] for name, (doc, params) in SECTIONS.items(): out += ["## [%s]: %s" % (name, doc), ""] + head + _md_rows(params) + [""] out += ["## [[source]]: the components", "", "Keys every source table may have:", ""] + head + _md_rows(SOURCE_COMMON) + [""] for k in _sources().source_kinds(): cls = _sources().get_source(k) extra = [] if cls.requires: extra.append("needs " + ", ".join("`%s`" % r for r in cls.requires)) if cls.provides: extra.append("can supply " + ", ".join("`%s`" % r for r in cls.provides)) fam = {"physical": "physical units", "relative": "contrast (relative to the star)", None: "from the input"}[cls.family] out += ["### kind = \"%s\": %s" % (k, cls.doc), "", "Output: %s. %s" % (fam, "; ".join(extra).capitalize() + "." if extra else ""), ""] out += head + _md_rows(cls.params) + [""] out += ["## [[coronagraph]]", ""] for t, (doc, params) in CORONAGRAPHS.items(): out += ["### type = \"%s\": %s" % (t, doc), ""] + head + _md_rows(params) + [""] out += ["## When values disagree", "", "| quantity | tolerance | consequence | why |", "|---|---|---|---|"] for key, (tol, consequence, why) in CONFLICT_RULES.items(): out.append("| `%s` | %g%% | %s | %s |" % (key, 100*tol, consequence, why)) out += ["| an image's pixel scale or size vs `[grid]` | — | resampled onto the grid (a note) | flux conserved |", "| `grid`: pixel_scale + sampling | 0.1% | error | over-specified |", "| `grid`: size x pixel_scale vs fov | 1.5 px (or 0.1%) | error | over-specified |", "| `star`: temperature + flux_jy | 0.1% | error | over-specified |", ""] return "\n".join(out)
[docs] def describe(query): """ Where a key is set, its unit, default and allowed values, and who uses it. Args: query (str): "section" or "section.key" (e.g. "grid", "star.distance"; brackets are ignored), "kind" or "kind.key" of a source module (e.g. "wind", "wind.mdot"), "type" or "type.key" of a coronagraph (e.g. "vortex.charge"), or a bare key name searched everywhere (e.g. "charge"). Returns: str: one block per match, separated by blank lines; if nothing matches, "Nothing called ..." with the closest names. """ query = query.strip().lstrip("[").rstrip("]") head, _, tail = query.partition(".") out = [] kinds = _sources().source_kinds() def param_text(p, where, users=None): """ The description block of one key. Args: p (Param): the declaration. where (str): where it is set, e.g. "[star] distance" (also used to find its CONFLICT_RULES). users (list of str): source kinds needing it; None or [] = not shown. Returns: str: lines: where and type, doc, default, help, needed by, conflict rule. """ lines = ["%s (%s)" % (where, p.describe_type()), " " + p.doc] if p.default is not None: lines.append(" default: %s" % _toml(p.default)) if p.help: lines.append(" " + p.help) if users: lines.append(" needed by: " + ", ".join(users)) key = where.strip("[]").replace("] ", ".") # "[star] distance" -> "star.distance" if key in CONFLICT_RULES: tol, consequence, why = CONFLICT_RULES[key] lines.append(" if values disagree (> %g%%): %s; %s" % (100*tol, consequence, why)) return "\n".join(lines) if head in SECTIONS: doc, params = SECTIONS[head] for p in params: if not tail or p.name == tail: users = [k for k in kinds if "%s.%s" % (head, p.name) in _sources().get_source(k).requires] out.append(param_text(p, "[%s] %s" % (head, p.name), users)) if not tail: out.insert(0, "[%s]: %s" % (head, doc)) elif head in kinds: cls = _sources().get_source(head) if not tail: block = ['[[source]] kind = "%s": %s' % (head, cls.doc), " output: %s" % {"physical": "physical units", "relative": "contrast", None: "from the input"}[cls.family]] if cls.requires: block.append(" needs: " + ", ".join(cls.requires)) if cls.provides: block.append(" can supply: " + ", ".join(cls.provides)) out.append("\n".join(block)) # a key: also the common keys (but kind and name); the whole kind: its own keys only for p in ((SOURCE_COMMON[2:] + tuple(cls.params)) if tail else cls.params): if not tail or p.name == tail: out.append(param_text(p, '[[source]] (kind = "%s") %s' % (head, p.name))) elif head in CORONAGRAPHS: doc, params = CORONAGRAPHS[head] if not tail: out.append('[[coronagraph]] type = "%s": %s' % (head, doc)) for p in params: if not tail or p.name == tail: out.append(param_text(p, '[[coronagraph]] (type = "%s") %s' % (head, p.name))) else: # a bare key: search everywhere for section, (_, params) in SECTIONS.items(): for p in params: if p.name == query: users = [k for k in kinds if "%s.%s" % (section, p.name) in _sources().get_source(k).requires] out.append(param_text(p, "[%s] %s" % (section, p.name), users)) for k in kinds: for p in _sources().get_source(k).params: if p.name == query: out.append(param_text(p, '[[source]] (kind = "%s") %s' % (k, p.name))) for t, (_, params) in CORONAGRAPHS.items(): for p in params: if p.name == query: out.append(param_text(p, '[[coronagraph]] (type = "%s") %s' % (t, p.name))) if not out: names = list(SECTIONS) + kinds + list(CORONAGRAPHS) + sorted( {p.name for _, ps in SECTIONS.values() for p in ps} | {p.name for k in kinds for p in _sources().get_source(k).params} | {p.name for _, ps in CORONAGRAPHS.values() for p in ps}) close = difflib.get_close_matches(tail or head, names, 3) return "Nothing called %r.%s" % (query, (" Did you mean: " + ", ".join(close) + "?") if close else "") return "\n\n".join(out)
[docs] def migrate(path, out_path=None): """ Rewrite an old-layout file in the new layout (only the values set there are kept). Args: path (str): the old file: a .toml file, or a .py parameter file (read by config._from_legacy). out_path (str): where to write the new file (and the JSON schema next to it); None = only return it. Returns: str: the TOML text in the new layout. Raises: ParamError: the .toml file is already in the new layout, or the mapped parameters are not valid (from_data()). """ if path.endswith(".py"): from toycoronagraph import config cfg = config._from_legacy(path) data = {"target": {"target_pixel": cfg.target_pixel}, "telescope": {"wavelength": cfg.wavelength, "diameter": cfg.diameter, "visual_range": float(cfg.visual_range)}, "coronagraph": {"type": cfg.coronagraph_type, "lyot_mask_size": cfg.lyot_mask_size}, "method": {"rot_number": cfg.rot_number}, "compute": {"num_cores": cfg.num_cores}} if cfg.fits_filename: data["target"]["fits_filename"] = cfg.fits_filename else: with open(path, "rb") as f: data = tomllib.load(f) data.pop("resolved", None) # values written back by old runs: not parameters if not is_legacy(data): raise ParamError("%s is already in the new layout" % path) new = legacy_to_new(data, os.path.dirname(os.path.abspath(path))) from_data(new) # check it lines = ["#:schema ./%s" % SCHEMA_FILE, "# Migrated from %s (%s) to the new layout. Only the values set there are kept;" % (os.path.basename(path), datetime.date.today()), "# everything else uses the defaults (python -m toycoronagraph describe KEY).", ""] if "fov" in new.get("grid", {}): # inserted before the blank line ending the header lines[-1:-1] = ["# Note: [grid] fov sets the simulated field. An image whose header gives its pixel scale is", "# resampled onto it; the old layout stretched such an image to fit visual_range (with a warning)."] for name in ("observation", "grid", "star"): if new.get(name): lines.append("[%s]" % name) for p in SECTIONS[name][1]: if p.name in new[name]: lines.append(_line(p.name, new[name][p.name], _comment(p), 26)) lines.append("") for s in new["source"]: cls = _source_class(s["kind"]) lines.append("[[source]]") lines.append(_line("kind", s["kind"], cls.doc, 26)) for p in SOURCE_COMMON[1:] + tuple(cls.params): if p.name in s: lines.append(_line(p.name, s[p.name], _comment(p), 26)) lines.append("") for c in new["coronagraph"]: ctype = c.get("type", "vortex") lines.append("[[coronagraph]]") lines.append(_line("type", ctype, CORONAGRAPHS[ctype][0], 26)) for p in CORONAGRAPHS[ctype][1]: if p.name in c: lines.append(_line(p.name, c[p.name], _comment(p), 26)) lines.append("") for name in ("method", "compute", "display"): if new.get(name): lines.append("[%s]" % name) for p in SECTIONS[name][1]: if p.name in new[name]: lines.append(_line(p.name, new[name][p.name], _comment(p), 26)) lines.append("") text = "\n".join(lines).rstrip()+"\n" if out_path: with open(out_path, "w") as f: f.write(text) write_schema(os.path.join(os.path.dirname(os.path.abspath(out_path)), SCHEMA_FILE)) return text