Source code for batools.builtinassetids

# Released under the MIT License. See LICENSE for details.
#
"""Generate C++ id-enum + load-block code for the construct asset-package.

Fetches the asset *listing* for the projectconfig ``"assets"`` pin via
``tools/bacloud assetpackage _listing`` -- an assembly-free server query
that returns every logical asset + kind from the package's workspace
snapshot (no recipes run, no blobs assembled). The same server-side
enumeration backs Python wrapper generation, so the C++ id enums and the
wrapper namespace can't drift. Generated content is spliced into two
pre-marked autogen sections in checked-in source files:

* ``src/ballistica/base/base.h`` — between the
  ``// __AUTOGENERATED_BUILTIN_ASSET_IDS_BEGIN__`` and ``…_END__``
  markers: the four ``BuiltinTextureID`` / ``BuiltinCubeMapTextureID``
  / ``BuiltinSoundID`` / ``BuiltinMeshID`` enums + the
  ``kBuiltinAssetsApverid`` string constant.
* ``src/ballistica/base/assets/assets.cc`` — between the
  ``// __AUTOGENERATED_BUILTIN_ASSET_LOAD_BEGIN__`` and ``…_END__``
  markers inside ``Assets::StartLoading()``: one
  ``LoadBuiltinTexture(BuiltinTextureID::kFooBar, "<apverid>:foo/bar")``
  call per entry.
* ``src/ballistica/base/assets/builtin_strings.h`` / ``.cc`` — FULLY
  generated (no splice markers): the ``BuiltinStrings`` typed accessor
  tree (strings-asset-migration decision D22). Each ``string``-kind
  listing entry (which carries the brief's ordered ``params`` + author
  ``docs``) becomes one static method building a pure resource-form
  ``LangStr`` — ``count`` params type as ``int64_t``, ``text`` params
  as ``LangStr::Sub``. String sources must live under a top-level
  ``strings/`` dir; the remaining path segments map to nested classes
  (namespaces are barred by the one-namespace-per-featureset rule) and
  the leaf method name.

This is invoked by ``assetpins`` when the pin changes (it talks to the
cloud for the listing, so it lives there, not in ``make update`` --
which only *checks* the splice matches the pin and stays offline; see
``check_builtin_asset_ids``). All targets live in checked-in files.
Idempotent — only writes a target file if the generated result differs
from what's on disk, so a no-op regen leaves the files (and their
mtimes) untouched.
"""

from __future__ import annotations  # Docs-generation hack.

import re
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING

from efro.error import CleanError

if TYPE_CHECKING:
    from pathlib import Path


[docs] class AssetKind(Enum): """Which of the four C++ enums an entry belongs to.""" TEXTURE = 'texture' CUBE_MAP_TEXTURE = 'cube_map_texture' SOUND = 'sound' MESH = 'mesh' @property def cpp_enum_name(self) -> str: """C++ enum class name for this kind.""" return { AssetKind.TEXTURE: 'BuiltinTextureID', AssetKind.CUBE_MAP_TEXTURE: 'BuiltinCubeMapTextureID', AssetKind.SOUND: 'BuiltinSoundID', AssetKind.MESH: 'BuiltinMeshID', }[self] @property def cpp_loader_name(self) -> str: """C++ ``LoadBuiltin*`` function name for this kind.""" return { AssetKind.TEXTURE: 'LoadBuiltinTexture', AssetKind.CUBE_MAP_TEXTURE: 'LoadBuiltinCubeMapTexture', AssetKind.SOUND: 'LoadBuiltinSound', AssetKind.MESH: 'LoadBuiltinMesh', }[self]
[docs] @dataclass class AssetEntry: """One asset, post-grouping & validation.""" kind: AssetKind # Logical asset path within the package (workspace-relative, no # extension). E.g. ``mydir/helloworld``. logical_name: str @property def cpp_enum_entry(self) -> str: """``kMydirHelloworld`` form.""" return 'k' + ''.join( _pascal_case(seg) for seg in self.logical_name.split('/') )
[docs] @dataclass class StringEntry: """One ``string``-kind asset, post-validation. ``params`` is the brief's ordered call signature as ``(name, kind)`` pairs, kind ``'count'`` (the plural pivot; typed ``int64_t``) or ``'text'`` (typed ``LangStr::Sub``). ``docs`` is the combined author doc text from the ``.bstr``. """ # Logical asset path within the package (e.g. # ``strings/input/keyboard``); always begins with ``strings/``. logical_name: str params: list[tuple[str, str]] docs: str @property def cpp_class_path(self) -> list[str]: """Nested-class names (path segments between root and leaf).""" return [_pascal_case(s) for s in self.logical_name.split('/')[1:-1]] @property def cpp_method_name(self) -> str: """``Keyboard`` form (from the leaf segment).""" return _pascal_case(self.logical_name.split('/')[-1])
[docs] @dataclass class BuildResult: """Output collected before writing to disk.""" apverid: str entries: list[AssetEntry] = field(default_factory=list) string_entries: list[StringEntry] = field(default_factory=list)
[docs] def entries_for(self, kind: AssetKind) -> list[AssetEntry]: """Entries of a given asset kind, sorted by enum-entry name.""" return sorted( (e for e in self.entries if e.kind == kind), key=lambda e: e.cpp_enum_entry, )
def _pascal_case(segment: str) -> str: """``some_thing`` → ``SomeThing``; ``foo-bar`` → invalid.""" if not re.fullmatch(r'[a-z0-9_]+', segment): raise CleanError( f'Asset-path segment {segment!r} is not lowercase ' 'ascii letters/digits/underscores; rename in the workspace.' ) return ''.join(part.capitalize() for part in segment.split('_')) def _fetch_listing( projroot: Path, apverid: str ) -> list[dict[str, str | list[dict[str, str]]]]: """Fetch the assembly-free asset listing for ``apverid`` via bacloud. Returns the ``assets`` list of ``{'path', 'kind'}`` dicts (see the bamaster ``assetpackage _listing`` command; ``string`` entries also carry ``params`` + ``docs``). This is the one cloud-talking step in builtin-id generation; it is invoked only from ``assetpins`` / ``gen_builtin_asset_ids`` (never ``update_project``, which stays offline). """ import os import json import tempfile import subprocess tmpdir = projroot / 'build/tmp' tmpdir.mkdir(parents=True, exist_ok=True) fd, tmppath = tempfile.mkstemp(suffix='.json', dir=tmpdir) os.close(fd) try: # Capture output so bacloud's own "Wrote asset listing..." # end-message stays out of the (deliberately terse) assetpins # output; surface it only if the call fails. result = subprocess.run( [ str(projroot / 'tools/bacloud'), 'assetpackage', '_listing', apverid, tmppath, ], check=True, capture_output=True, text=True, ) del result # Output intentionally discarded on success. with open(tmppath, encoding='utf-8') as infile: data = json.load(infile) assets = data['assets'] assert isinstance(assets, list) return assets except Exception as exc: detail = '' if isinstance(exc, subprocess.CalledProcessError): msg = (exc.stderr or exc.stdout or '').strip() if msg: detail = f'\n{msg}' raise CleanError( f'Failed to fetch asset listing for {apverid!r}. If the' f' apverid no longer exists on master (dev snapshots get' f' pruned quickly), run `make assetpins-latest`.{detail}' ) from exc finally: if os.path.exists(tmppath): os.unlink(tmppath) def _collect_string( asset: dict[str, str | list[dict[str, str]]], result: BuildResult ) -> str | None: """Validate one ``string``-kind listing entry into ``result``. Returns an error string (for the caller's aggregated report) or ``None`` on success. """ logical_name = asset['path'] assert isinstance(logical_name, str) segments = logical_name.split('/') if len(segments) < 2 or segments[0] != 'strings': return ( f'String {logical_name!r} is not under a top-level' " 'strings/' dir; the C++ accessor tree requires" " 'strings/<group>/<name>' paths." ) try: for seg in segments: _pascal_case(seg) # validation only except CleanError as exc: return str(exc) if 'params' not in asset or 'docs' not in asset: return ( f'String {logical_name!r} listing entry lacks params/docs;' ' the master serving the listing predates typed-string' ' support (deploy bamaster first).' ) rawparams = asset['params'] assert isinstance(rawparams, list) params: list[tuple[str, str]] = [] for rawparam in rawparams: name, pkind = rawparam['name'], rawparam['kind'] if not re.fullmatch(r'[a-z][a-z0-9_]*', name): return ( f'String {logical_name!r} param {name!r} is not a' ' valid lowercase identifier; rename in the brief.' ) if pkind not in ('count', 'text'): return ( f'String {logical_name!r} param {name!r} has unknown' f' kind {pkind!r} (this generator understands' " 'count'/'text'; it may need updating)." ) params.append((name, pkind)) docs = asset['docs'] assert isinstance(docs, str) result.string_entries.append( StringEntry(logical_name=logical_name, params=params, docs=docs) ) return None
[docs] def collect(projroot: Path, apverid: str) -> BuildResult: """Fetch the asset listing for ``apverid`` and build a validated result. The apverid is passed explicitly (not read from projectconfig) so a caller staging a not-yet-written pin update can generate against the *target* version. Fetches that version's asset listing via ``bacloud`` (assembly-free; see :func:`!_fetch_listing`). ``collect`` therefore talks to the cloud and must only run from ``assetpins`` / ``gen_builtin_asset_ids`` -- never ``update_project`` (which stays offline and merely *checks* the spliced apverid string). """ assets = _fetch_listing(projroot, apverid) result = BuildResult(apverid=apverid) errors: list[str] = [] for asset in assets: logical_name = asset['path'] assert isinstance(logical_name, str) if asset['kind'] == 'string': err = _collect_string(asset, result) if err is not None: errors.append(err) continue # Kinds with no C++ id enum (collision_mesh today, plus any # future kind the listing gains) have no AssetKind member; # skip them. Keeps the generator forward-compatible without a # listing-format gate. try: kind = AssetKind(str(asset['kind'])) except ValueError: continue segments = logical_name.split('/') if len(segments) < 2: errors.append( f'Asset {logical_name!r} is at workspace root; ' 'move into a category subdir (e.g. ui/, test/).' ) continue try: for seg in segments: _pascal_case(seg) # validation only except CleanError as exc: errors.append(str(exc)) continue result.entries.append(AssetEntry(kind=kind, logical_name=logical_name)) # Cross-kind collision check: same logical_name appearing under two # AssetKinds is ambiguous since the wrapper namespace is flat. by_name: dict[str, list[AssetEntry]] = {} for entry in result.entries: by_name.setdefault(entry.logical_name, []).append(entry) for name, entries in by_name.items(): kinds = {e.kind for e in entries} if len(kinds) > 1: errors.append( f'Logical name {name!r} appears across multiple asset ' f'types ({sorted(k.value for k in kinds)}); rename to ' 'disambiguate.' ) if errors: raise CleanError( 'Asset-package validation failed:\n - ' + '\n - '.join(errors) ) return result
[docs] def render_enum_block(result: BuildResult) -> str: """Build the autogen-section content for ``base.h``. Returns the lines that go between ``// __AUTOGENERATED_BUILTIN_ASSET_IDS_BEGIN__`` and ``// __AUTOGENERATED_BUILTIN_ASSET_IDS_END__`` in base.h (the markers themselves are NOT included). """ lines: list[str] = [ '//', '// Generated by ``tools/pcommand gen_builtin_asset_ids`` (run by', '// ``assetpins`` when the construct asset-package pin in', '// ``pconfig/projectconfig.json`` changes) from that pin. Do not edit', '// by hand; rerun ``make assetpins-latest`` to regenerate. New', '// per-asset entries land here as the workspace gains them; old', '// hand-coded ``Builtin*OldID`` entries above retire one at a time as', '// their callsites migrate.', '', ( 'inline constexpr const char* kBuiltinAssetsApverid = ' f'"{result.apverid}";' ), '', ] for kind in AssetKind: entries = result.entries_for(kind) if not entries: lines.append(f'enum class {kind.cpp_enum_name} : uint16_t {{}};') else: lines.append(f'enum class {kind.cpp_enum_name} : uint16_t {{') # Comment with the logical name — it matches # wrapper/compat-table keys. # clang-format aligns each run of trailing comments to # its longest member, so place the comment on its own # line above the entry whenever the aligned trailing # form could exceed cpplint's 80-col limit (long mesh # names hit this). entry_parts = [f' {e.cpp_enum_entry},' for e in entries] align_col = max(len(p) for p in entry_parts) + 2 for entry, part in zip(entries, entry_parts): comment = f'// {entry.logical_name}' if align_col + len(comment) > 80: lines.append(f' {comment}') lines.append(part) else: lines.append(f'{part} {comment}') lines.append('};') lines.append('') # Drop the trailing blank line so the closing marker sits flush. if lines and lines[-1] == '': lines.pop() return '\n'.join(lines)
[docs] def render_load_block(result: BuildResult) -> str: """Build the autogen-section content for ``assets.cc``. Returns the lines that go between ``// __AUTOGENERATED_BUILTIN_ASSET_LOAD_BEGIN__`` and ``// __AUTOGENERATED_BUILTIN_ASSET_LOAD_END__`` inside ``Assets::StartLoading()`` (the markers themselves are NOT included). Lines that would exceed the 80-char cpplint limit wrap after the comma. """ lines: list[str] = [] for kind in AssetKind: entries = result.entries_for(kind) if not entries: continue lines.append(f' // {kind.value}s') for entry in entries: full = f'{result.apverid}:{entry.logical_name}' single = ( f' {kind.cpp_loader_name}(' f'{kind.cpp_enum_name}::{entry.cpp_enum_entry}, ' f'"{full}");' ) if len(single) <= 80: lines.append(single) else: # Wrap at the comma. indent = ' ' * (len(kind.cpp_loader_name) + 3) lines.append( f' {kind.cpp_loader_name}(' f'{kind.cpp_enum_name}::{entry.cpp_enum_entry},' ) lines.append(f'{indent}"{full}");') return '\n'.join(lines)
def _wrap_doc_comment(docs: str, indent: str) -> list[str]: """Render doc text as wrapped ``///`` comment lines.""" import textwrap lines: list[str] = [] for i, para in enumerate(p for p in docs.split('\n\n') if p.strip()): if i > 0: lines.append(f'{indent}///') flat = ' '.join(para.split()) lines.extend( f'{indent}/// {wrapped}' for wrapped in textwrap.wrap(flat, width=76 - len(indent)) ) return lines def _string_param_sig(entry: StringEntry) -> str: """C++ parameter list for a string entry's accessor.""" return ', '.join( f'int64_t {name}' if kind == 'count' else f'LangStr::Sub {name}' for name, kind in entry.params ) @dataclass class _StringTreeNode: """One level of the nested-class accessor tree.""" methods: list[StringEntry] = field(default_factory=list) children: dict[str, '_StringTreeNode'] = field(default_factory=dict) def _string_tree(entries: list[StringEntry]) -> _StringTreeNode: """Group entries into the nested-class tree by their class paths.""" root = _StringTreeNode() for entry in sorted(entries, key=lambda e: e.logical_name): node = root for cls in entry.cpp_class_path: node = node.children.setdefault(cls, _StringTreeNode()) node.methods.append(entry) return root # NOTE: the license line is emitted separately (for the header it must # be immediately followed by the include guard per the project # header-guard check). _STRINGS_FILE_BANNER = [ '// AUTOGENERATED FILE - DO NOT EDIT BY HAND.', '//', '// Generated by ``tools/pcommand gen_builtin_asset_ids`` (run by', '// ``assetpins`` when the construct asset-package pin in', '// ``pconfig/projectconfig.json`` changes) from that pin\'s asset', '// listing. Rerun ``make assetpins-latest`` to regenerate.', ] _LICENSE_LINE = '// Released under the MIT License. See LICENSE for details.'
[docs] def render_strings_h(result: BuildResult) -> str: """Render the full generated ``builtin_strings.h`` content.""" guard = 'BALLISTICA_BASE_ASSETS_BUILTIN_STRINGS_H_' lines: list[str] = [ _LICENSE_LINE, '', f'#ifndef {guard}', f'#define {guard}', '', ] lines += _STRINGS_FILE_BANNER lines += [ '//', f'// Generated from: "{result.apverid}"', '', '#include <memory>', '', '#include "ballistica/base/base.h"', # The full LangStr definition (not just a forward decl) is # needed here: text params type as the nested LangStr::Sub. '#include "ballistica/base/support/lang_str.h"', '', 'namespace ballistica::base {', '', '/// Typed accessors for the language-strings shipped in the', '/// builtin asset package (strings-asset-migration decision D22).', '/// Each accessor mirrors one `.bstr` source in the package', '/// (nested classes mirror its dir tree; classes stand in for', '/// namespaces per the one-namespace-per-featureset rule) and', '/// builds a resource-form LangStr addressing that string in the', '/// pinned builtin package (kBuiltinAssetsApverid), with any', '/// parameters attached as keyword substitutions.', '///', '/// The returned values hold tokens, not text: call Evaluate() at', '/// the point flat display text is needed (it resolves against', '/// the current locale\'s native tables and re-runs correctly', '/// after a language switch). Values are immutable and may be', '/// built, held, and evaluated from any thread.', 'class BuiltinStrings {', ' public:', ] def _emit(node: _StringTreeNode, indent: str) -> None: first = True for clsname, child in sorted(node.children.items()): if not first: lines.append('') first = False lines.append(f'{indent}class {clsname} {{') lines.append(f'{indent} public:') _emit(child, indent + ' ') lines.append(f'{indent}}};') for entry in node.methods: if not first: lines.append('') first = False lines.extend(_wrap_doc_comment(entry.docs, indent)) lines.append( f'{indent}static auto ' f'{entry.cpp_method_name}({_string_param_sig(entry)})' ' -> std::shared_ptr<const LangStr>;' ) if result.string_entries: _emit(_string_tree(result.string_entries), ' ') else: lines.append(' // (no string sources in the pinned package)') lines += [ '};', '', '} // namespace ballistica::base', '', f'#endif // {guard}', '', ] return '\n'.join(lines)
[docs] def render_strings_cc(result: BuildResult) -> str: """Render the full generated ``builtin_strings.cc`` content.""" lines: list[str] = [_LICENSE_LINE, '//'] lines += _STRINGS_FILE_BANNER lines += [ '', '#include "ballistica/base/assets/builtin_strings.h"', '', '#include <memory>', ] # MakeResource_ moves its subs vector, so <utility> rides with any # entries at all. if result.string_entries: lines.append('#include <utility>') lines += [ '#include <vector>', '', '#include "ballistica/base/support/lang_str.h"', '', 'namespace ballistica::base {', '', ] if not result.string_entries: lines += [ '// (no string sources in the pinned package)', '', '} // namespace ballistica::base', '', ] return '\n'.join(lines) lines += [ 'static auto MakeResource_(const char* name,', ' std::vector<LangStr::SubEntry> subs = {})', ' -> std::shared_ptr<const LangStr> {', ' auto out = std::make_shared<LangStr>();', ' out->form = LangStr::Form::kResource;', ' out->apverid = kBuiltinAssetsApverid;', ' out->name = name;', ' out->subs = std::move(subs);', ' return out;', '}', '', ] def _emit(node: _StringTreeNode, class_path: list[str]) -> None: for entry in node.methods: qual = '::'.join(['BuiltinStrings'] + class_path) lines.append( f'auto {qual}::{entry.cpp_method_name}' f'({_string_param_sig(entry)})' ' -> std::shared_ptr<const LangStr> {' ) if entry.params: subs = ', '.join( ( f'{{"{name}", {name}}}' if kind == 'count' else f'{{"{name}", std::move({name})}}' ) for name, kind in entry.params ) lines.append( f' return MakeResource_("{entry.logical_name}",' f' {{{subs}}});' ) else: lines.append(f' return MakeResource_("{entry.logical_name}");') lines.append('}') lines.append('') for clsname, child in sorted(node.children.items()): _emit(child, class_path + [clsname]) _emit(_string_tree(result.string_entries), []) lines += [ '} // namespace ballistica::base', '', ] return '\n'.join(lines)
_BEGIN_MARKER_PREFIX = '// __AUTOGENERATED_' _END_MARKER_PREFIX = '// __AUTOGENERATED_' def _splice_autogen( existing: str, begin_marker: str, end_marker: str, new_content: str ) -> str: """Replace content between begin/end markers in ``existing``. Marker lines (and any indentation in front of them) are preserved; only the content strictly between them is replaced. Raises ``CleanError`` if either marker isn't found or they're in the wrong order. """ lines = existing.split('\n') begin_idx: int | None = None end_idx: int | None = None for i, line in enumerate(lines): stripped = line.lstrip() if stripped == begin_marker: if begin_idx is not None: raise CleanError(f'Duplicate begin marker {begin_marker!r}.') begin_idx = i elif stripped == end_marker: if end_idx is not None: raise CleanError(f'Duplicate end marker {end_marker!r}.') end_idx = i if begin_idx is None: raise CleanError(f'Begin marker {begin_marker!r} not found.') if end_idx is None: raise CleanError(f'End marker {end_marker!r} not found.') if end_idx <= begin_idx: raise CleanError( f'End marker {end_marker!r} must come after begin ' f'marker {begin_marker!r}.' ) new_lines = ( lines[: begin_idx + 1] + ([new_content] if new_content else []) + lines[end_idx:] ) return '\n'.join(new_lines) # Project-relative paths of the files that hold autogen sections. TARGET_BASE_H = 'src/ballistica/base/base.h' TARGET_ASSETS_CC = 'src/ballistica/base/assets/assets.cc' # Fully-generated files (no splice markers; whole content is ours). TARGET_STRINGS_H = 'src/ballistica/base/assets/builtin_strings.h' TARGET_STRINGS_CC = 'src/ballistica/base/assets/builtin_strings.cc' _MARKERS_BASE_H = ( '// __AUTOGENERATED_BUILTIN_ASSET_IDS_BEGIN__', '// __AUTOGENERATED_BUILTIN_ASSET_IDS_END__', ) _MARKERS_ASSETS_CC = ( '// __AUTOGENERATED_BUILTIN_ASSET_LOAD_BEGIN__', '// __AUTOGENERATED_BUILTIN_ASSET_LOAD_END__', )
[docs] def compute_splices( projroot: Path, apverid: str, base_h_existing: str | None = None, assets_cc_existing: str | None = None, ) -> dict[str, str]: """Compute generated contents for all target files. Returns a dict keyed by project-relative path with the full new file content — the base.h / assets.cc splices (existing content + new autogen section) plus the fully-generated builtin_strings.h / .cc accessor files. Caller decides whether to write — typical use is "write only if contents differ from on-disk". ``base_h_existing`` / ``assets_cc_existing`` let the caller pass in already-read content (e.g. when integrated into a project updater that's already loaded the file); pass ``None`` to read from disk here. """ # Run the spliced result through clang-format so our output matches # what ``make format`` produces. Without this the generator emits # raw (e.g. single-line) content that the formatter then rewrites # (e.g. wrapping a long apverid assignment), leaving the two in a # tug-of-war and making any "is the splice up to date?" check # unreliable. The non-autogen parts of these files are already # clang-formatted, so this only normalizes the spliced region. from efrotools.code import format_cpp_str result = collect(projroot, apverid) if base_h_existing is None: base_h_existing = (projroot / TARGET_BASE_H).read_text() if assets_cc_existing is None: assets_cc_existing = (projroot / TARGET_ASSETS_CC).read_text() return { TARGET_BASE_H: format_cpp_str( projroot, _splice_autogen( base_h_existing, _MARKERS_BASE_H[0], _MARKERS_BASE_H[1], render_enum_block(result), ), filename='base.h', ), TARGET_ASSETS_CC: format_cpp_str( projroot, _splice_autogen( assets_cc_existing, _MARKERS_ASSETS_CC[0], _MARKERS_ASSETS_CC[1], render_load_block(result), ), filename='assets.cc', ), # The builtin-strings accessor files are fully generated (no # existing content in play). TARGET_STRINGS_H: format_cpp_str( projroot, render_strings_h(result), filename='builtin_strings.h', ), TARGET_STRINGS_CC: format_cpp_str( projroot, render_strings_cc(result), filename='builtin_strings.cc', ), }
[docs] def generate(projroot: Path, apverid: str, check: bool = False) -> bool: """Write generated content for ``apverid`` into all target files. Splices the base.h / assets.cc ``// __AUTOGENERATED_*__`` marker sections and fully regenerates the builtin_strings accessor files, writing each file only if the resulting content differs from what's on disk. Idempotent: a run with no changes leaves the files (and their mtimes) untouched. Returns True if anything was (or would be) changed. (``assetpins`` stages writes itself via :func:`compute_splices`; this convenience wrapper is used by the standalone ``gen_builtin_asset_ids`` pcommand.) """ spliced = compute_splices(projroot, apverid) changed = False for rel_path, new_text in spliced.items(): path = projroot / rel_path existing = path.read_text() if new_text == existing: continue changed = True if check: continue path.write_text(new_text) return changed
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum